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.
- checksums.yaml +7 -0
- data/.claude/settings.local.json +16 -0
- data/.rubocop.yml +137 -0
- data/CHANGELOG.md +83 -0
- data/LICENSE.txt +21 -0
- data/README.md +241 -0
- data/Rakefile +15 -0
- data/docs/01-define-pricing-plans.md +372 -0
- data/docs/02-controller-helpers.md +223 -0
- data/docs/03-model-helpers.md +318 -0
- data/docs/04-views.md +121 -0
- data/docs/05-semantic-pricing.md +159 -0
- data/docs/06-gem-compatibility.md +99 -0
- data/docs/images/pricing_plans_ruby_rails_gem_pricing_table.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg +0 -0
- data/docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg +0 -0
- data/docs/images/product_creation_blocked.jpg +0 -0
- data/lib/generators/pricing_plans/install/install_generator.rb +42 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +91 -0
- data/lib/generators/pricing_plans/install/templates/initializer.rb +100 -0
- data/lib/pricing_plans/association_limit_registry.rb +45 -0
- data/lib/pricing_plans/configuration.rb +189 -0
- data/lib/pricing_plans/controller_guards.rb +574 -0
- data/lib/pricing_plans/controller_rescues.rb +115 -0
- data/lib/pricing_plans/dsl.rb +44 -0
- data/lib/pricing_plans/engine.rb +69 -0
- data/lib/pricing_plans/grace_manager.rb +227 -0
- data/lib/pricing_plans/integer_refinements.rb +48 -0
- data/lib/pricing_plans/job_guards.rb +24 -0
- data/lib/pricing_plans/limit_checker.rb +157 -0
- data/lib/pricing_plans/limitable.rb +286 -0
- data/lib/pricing_plans/models/assignment.rb +55 -0
- data/lib/pricing_plans/models/enforcement_state.rb +45 -0
- data/lib/pricing_plans/models/usage.rb +51 -0
- data/lib/pricing_plans/overage_reporter.rb +77 -0
- data/lib/pricing_plans/pay_support.rb +85 -0
- data/lib/pricing_plans/period_calculator.rb +183 -0
- data/lib/pricing_plans/plan.rb +653 -0
- data/lib/pricing_plans/plan_owner.rb +287 -0
- data/lib/pricing_plans/plan_resolver.rb +85 -0
- data/lib/pricing_plans/price_components.rb +16 -0
- data/lib/pricing_plans/registry.rb +182 -0
- data/lib/pricing_plans/result.rb +109 -0
- data/lib/pricing_plans/version.rb +5 -0
- data/lib/pricing_plans/view_helpers.rb +58 -0
- data/lib/pricing_plans.rb +645 -0
- data/sig/pricing_plans.rbs +4 -0
- 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
|