pricing_plans 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/settings.local.json +16 -0
  3. data/.rubocop.yml +137 -0
  4. data/CHANGELOG.md +83 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +241 -0
  7. data/Rakefile +15 -0
  8. data/docs/01-define-pricing-plans.md +372 -0
  9. data/docs/02-controller-helpers.md +223 -0
  10. data/docs/03-model-helpers.md +318 -0
  11. data/docs/04-views.md +121 -0
  12. data/docs/05-semantic-pricing.md +159 -0
  13. data/docs/06-gem-compatibility.md +99 -0
  14. data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
  15. data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
  16. data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
  17. data/docs/images/product_creation_blocked.jpg +0 -0
  18. data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
  19. data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
  20. data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
  21. data/lib/pricing_plans/association_limit_registry.rb +45 -0
  22. data/lib/pricing_plans/configuration.rb +189 -0
  23. data/lib/pricing_plans/controller_guards.rb +574 -0
  24. data/lib/pricing_plans/controller_rescues.rb +115 -0
  25. data/lib/pricing_plans/dsl.rb +44 -0
  26. data/lib/pricing_plans/engine.rb +69 -0
  27. data/lib/pricing_plans/grace_manager.rb +227 -0
  28. data/lib/pricing_plans/integer_refinements.rb +48 -0
  29. data/lib/pricing_plans/job_guards.rb +24 -0
  30. data/lib/pricing_plans/limit_checker.rb +157 -0
  31. data/lib/pricing_plans/limitable.rb +286 -0
  32. data/lib/pricing_plans/models/assignment.rb +55 -0
  33. data/lib/pricing_plans/models/enforcement_state.rb +45 -0
  34. data/lib/pricing_plans/models/usage.rb +51 -0
  35. data/lib/pricing_plans/overage_reporter.rb +77 -0
  36. data/lib/pricing_plans/pay_support.rb +85 -0
  37. data/lib/pricing_plans/period_calculator.rb +183 -0
  38. data/lib/pricing_plans/plan.rb +653 -0
  39. data/lib/pricing_plans/plan_owner.rb +287 -0
  40. data/lib/pricing_plans/plan_resolver.rb +85 -0
  41. data/lib/pricing_plans/price_components.rb +16 -0
  42. data/lib/pricing_plans/registry.rb +182 -0
  43. data/lib/pricing_plans/result.rb +109 -0
  44. data/lib/pricing_plans/version.rb +5 -0
  45. data/lib/pricing_plans/view_helpers.rb +58 -0
  46. data/lib/pricing_plans.rb +645 -0
  47. data/sig/pricing_plans.rbs +4 -0
  48. metadata +236 -0
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ class PeriodCalculator
5
+ class << self
6
+ def window_for(plan_owner, limit_key)
7
+ plan = PlanResolver.effective_plan_for(plan_owner)
8
+ limit_config = plan&.limit_for(limit_key)
9
+
10
+ period_type = determine_period_type(limit_config)
11
+ calculate_window_for_period(plan_owner, period_type)
12
+ end
13
+
14
+ private
15
+
16
+ # Backward-compatible shim for tests that stub pay_available?
17
+ def pay_available?
18
+ PaySupport.pay_available?
19
+ end
20
+
21
+ def determine_period_type(limit_config)
22
+ # First check the limit's specific per: configuration
23
+ return limit_config[:per] if limit_config&.dig(:per)
24
+
25
+ # Fall back to global configuration
26
+ Registry.configuration.period_cycle
27
+ end
28
+
29
+ def calculate_window_for_period(plan_owner, period_type)
30
+ case period_type
31
+ when :billing_cycle
32
+ billing_cycle_window(plan_owner)
33
+ when :calendar_month, :month
34
+ calendar_month_window
35
+ when :calendar_week, :week
36
+ calendar_week_window
37
+ when :calendar_day, :day
38
+ calendar_day_window
39
+ when ->(x) { x.respond_to?(:call) }
40
+ # Custom callable
41
+ result = period_type.call(plan_owner)
42
+ validate_custom_window!(result)
43
+ result
44
+ else
45
+ # Handle ActiveSupport duration objects
46
+ if period_type.respond_to?(:seconds)
47
+ duration_window(period_type)
48
+ else
49
+ raise ConfigurationError, "Unknown period type: #{period_type}"
50
+ end
51
+ end
52
+ end
53
+
54
+ def billing_cycle_window(plan_owner)
55
+ # Respect tests that stub pay availability
56
+ return fallback_window unless pay_available?
57
+
58
+ subscription = nil
59
+ if plan_owner.respond_to?(:subscription)
60
+ subscription = plan_owner.subscription
61
+ end
62
+ if subscription.nil? && plan_owner.respond_to?(:subscriptions)
63
+ # Prefer a sub with explicit period anchors
64
+ subscription = plan_owner.subscriptions.find do |sub|
65
+ sub.respond_to?(:current_period_start) && sub.respond_to?(:current_period_end)
66
+ end
67
+ # Otherwise, fall back to any active/trial/grace subscription
68
+ subscription ||= plan_owner.subscriptions.find do |sub|
69
+ (sub.respond_to?(:active?) && sub.active?) ||
70
+ (sub.respond_to?(:on_trial?) && sub.on_trial?) ||
71
+ (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?)
72
+ end
73
+ end
74
+ subscription ||= PaySupport.current_subscription_for(plan_owner)
75
+
76
+ return fallback_window unless subscription
77
+
78
+ # Use Pay's billing cycle anchor if available
79
+ if subscription.respond_to?(:current_period_start) &&
80
+ subscription.respond_to?(:current_period_end)
81
+ [subscription.current_period_start, subscription.current_period_end]
82
+ elsif subscription.respond_to?(:created_at)
83
+ # Calculate from subscription creation date
84
+ start_time = subscription.created_at
85
+ monthly_window_from(start_time)
86
+ else
87
+ fallback_window
88
+ end
89
+ end
90
+
91
+ def calendar_month_window
92
+ now = Time.current
93
+ start_time = now.beginning_of_month
94
+ end_time = now.end_of_month
95
+ [start_time, end_time]
96
+ end
97
+
98
+ def calendar_week_window
99
+ now = Time.current
100
+ start_time = now.beginning_of_week
101
+ end_time = now.end_of_week
102
+ [start_time, end_time]
103
+ end
104
+
105
+ def calendar_day_window
106
+ now = Time.current
107
+ start_time = now.beginning_of_day
108
+ end_time = now.end_of_day
109
+ [start_time, end_time]
110
+ end
111
+
112
+ def duration_window(duration)
113
+ now = Time.current
114
+ start_time = now.beginning_of_day
115
+ end_time = start_time + duration
116
+ [start_time, end_time]
117
+ end
118
+
119
+ def monthly_window_from(anchor_date)
120
+ now = Time.current
121
+
122
+ # Find the current period based on anchor date
123
+ months_since = ((now.year - anchor_date.year) * 12 + (now.month - anchor_date.month))
124
+
125
+ start_time = anchor_date + months_since.months
126
+ end_time = start_time + 1.month
127
+
128
+ # If we've passed this period, move to the next one
129
+ if now >= end_time
130
+ start_time = end_time
131
+ end_time = start_time + 1.month
132
+ end
133
+
134
+ [start_time, end_time]
135
+ end
136
+
137
+ # Removed duplicate Pay helpers; centralized in PaySupport
138
+
139
+ def fallback_window
140
+ # Default to calendar month if billing cycle unavailable
141
+ calendar_month_window
142
+ end
143
+
144
+ def validate_custom_window!(window)
145
+ unless window.is_a?(Array) && window.size == 2
146
+ raise ConfigurationError, "Custom period callable must return [start_time, end_time]"
147
+ end
148
+
149
+ start_time, end_time = window
150
+
151
+ unless start_time&.respond_to?(:to_time) && end_time&.respond_to?(:to_time)
152
+ raise ConfigurationError, "Custom period window times must respond to :to_time"
153
+ end
154
+
155
+ begin
156
+ # Convert explicitly to UTC to avoid Rails 8.1 to_time deprecation noise
157
+ start_time_converted =
158
+ if start_time.is_a?(Time)
159
+ start_time
160
+ elsif start_time.respond_to?(:to_time)
161
+ start_time.to_time(:utc)
162
+ else
163
+ Time.parse(start_time.to_s)
164
+ end
165
+
166
+ end_time_converted =
167
+ if end_time.is_a?(Time)
168
+ end_time
169
+ elsif end_time.respond_to?(:to_time)
170
+ end_time.to_time(:utc)
171
+ else
172
+ Time.parse(end_time.to_s)
173
+ end
174
+ if end_time_converted <= start_time_converted
175
+ raise ConfigurationError, "Custom period end_time must be after start_time"
176
+ end
177
+ rescue NoMethodError
178
+ raise ConfigurationError, "Custom period window times must respond to :to_time"
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end