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,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Mix-in for the configured plan owner class (e.g., Organization)
|
|
5
|
+
# Provides readable, owner-centric helpers.
|
|
6
|
+
module PlanOwner
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
base.singleton_class.prepend HasManyInterceptor
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Define English-y sugar methods on the plan owner for a specific limit key.
|
|
13
|
+
# Idempotent: skips if methods already exist.
|
|
14
|
+
def self.define_limit_sugar_methods(plan_owner_class, limit_key)
|
|
15
|
+
key = limit_key.to_sym
|
|
16
|
+
within_m = :"#{key}_within_plan_limits?"
|
|
17
|
+
remaining_m = :"#{key}_remaining"
|
|
18
|
+
percent_m = :"#{key}_percent_used"
|
|
19
|
+
grace_active_m = :"#{key}_grace_active?"
|
|
20
|
+
grace_ends_m = :"#{key}_grace_ends_at"
|
|
21
|
+
blocked_m = :"#{key}_blocked?"
|
|
22
|
+
|
|
23
|
+
unless plan_owner_class.method_defined?(within_m)
|
|
24
|
+
plan_owner_class.define_method(within_m) do |by: 1|
|
|
25
|
+
LimitChecker.within_limit?(self, key, by: by)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
unless plan_owner_class.method_defined?(remaining_m)
|
|
30
|
+
plan_owner_class.define_method(remaining_m) do
|
|
31
|
+
LimitChecker.plan_limit_remaining(self, key)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unless plan_owner_class.method_defined?(percent_m)
|
|
36
|
+
plan_owner_class.define_method(percent_m) do
|
|
37
|
+
LimitChecker.plan_limit_percent_used(self, key)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unless plan_owner_class.method_defined?(grace_active_m)
|
|
42
|
+
plan_owner_class.define_method(grace_active_m) do
|
|
43
|
+
GraceManager.grace_active?(self, key)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
unless plan_owner_class.method_defined?(grace_ends_m)
|
|
48
|
+
plan_owner_class.define_method(grace_ends_m) do
|
|
49
|
+
GraceManager.grace_ends_at(self, key)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless plan_owner_class.method_defined?(blocked_m)
|
|
54
|
+
plan_owner_class.define_method(blocked_m) do
|
|
55
|
+
GraceManager.should_block?(self, key)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module HasManyInterceptor
|
|
61
|
+
def has_many(name, scope = nil, **options, &extension)
|
|
62
|
+
limited_opts = options.delete(:limited_by_pricing_plans)
|
|
63
|
+
reflection = super(name, scope, **options, &extension)
|
|
64
|
+
|
|
65
|
+
if limited_opts
|
|
66
|
+
config = limited_opts == true ? {} : limited_opts.dup
|
|
67
|
+
limit_key = (config.delete(:limit_key) || name).to_sym
|
|
68
|
+
per = config.delete(:per)
|
|
69
|
+
error_after_limit = config.delete(:error_after_limit)
|
|
70
|
+
count_scope = config.delete(:count_scope)
|
|
71
|
+
|
|
72
|
+
# Define English-y sugar methods on the plan owner immediately
|
|
73
|
+
PricingPlans::PlanOwner.define_limit_sugar_methods(self, limit_key)
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
assoc_reflection = reflect_on_association(name)
|
|
77
|
+
child_klass = assoc_reflection.klass
|
|
78
|
+
foreign_key = assoc_reflection.foreign_key.to_s
|
|
79
|
+
|
|
80
|
+
# Find the child's belongs_to backref to this plan owner
|
|
81
|
+
inferred_owner = child_klass.reflections.values.find { |r| r.macro == :belongs_to && r.foreign_key.to_s == foreign_key }&.name
|
|
82
|
+
# If foreign_key doesn't match (e.g., child uses :organization), prefer association matching owner class semantic name
|
|
83
|
+
owner_name_sym = self.name.underscore.to_sym
|
|
84
|
+
inferred_owner ||= (child_klass.reflections.key?(owner_name_sym.to_s) ? owner_name_sym : nil)
|
|
85
|
+
# Common conventions fallback
|
|
86
|
+
inferred_owner ||= %i[organization account user team company workspace tenant].find { |cand| child_klass.reflections.key?(cand.to_s) }
|
|
87
|
+
# Final fallback to underscored class name
|
|
88
|
+
inferred_owner ||= owner_name_sym
|
|
89
|
+
|
|
90
|
+
child_klass.include PricingPlans::Limitable unless child_klass.ancestors.include?(PricingPlans::Limitable)
|
|
91
|
+
child_klass.limited_by_pricing_plans(
|
|
92
|
+
limit_key,
|
|
93
|
+
plan_owner: inferred_owner,
|
|
94
|
+
per: per,
|
|
95
|
+
error_after_limit: error_after_limit,
|
|
96
|
+
count_scope: count_scope
|
|
97
|
+
)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
# If child class cannot be resolved yet, register for later resolution
|
|
100
|
+
PricingPlans::AssociationLimitRegistry.register(
|
|
101
|
+
plan_owner_class: self,
|
|
102
|
+
association_name: name,
|
|
103
|
+
options: { limit_key: limit_key, per: per, error_after_limit: error_after_limit, count_scope: count_scope }
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
reflection
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
module ClassMethods
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def within_plan_limits?(limit_key, by: 1)
|
|
116
|
+
LimitChecker.within_limit?(self, limit_key, by: by)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def plan_limit_remaining(limit_key)
|
|
120
|
+
LimitChecker.plan_limit_remaining(self, limit_key)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def plan_limit_percent_used(limit_key)
|
|
124
|
+
LimitChecker.plan_limit_percent_used(self, limit_key)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def current_pricing_plan
|
|
128
|
+
PlanResolver.effective_plan_for(self)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def on_free_plan?
|
|
132
|
+
plan = current_pricing_plan || PricingPlans::Registry.default_plan
|
|
133
|
+
plan&.free? || false
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def assign_pricing_plan!(plan_key, source: "manual")
|
|
137
|
+
Assignment.assign_plan_to(self, plan_key, source: source)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def remove_pricing_plan!
|
|
141
|
+
Assignment.remove_assignment_for(self)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Features
|
|
145
|
+
def plan_allows?(feature_key)
|
|
146
|
+
plan = current_pricing_plan
|
|
147
|
+
plan&.allows_feature?(feature_key) || false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Syntactic sugar for feature checks:
|
|
151
|
+
# Allows calls like `plan_owner.plan_allows_api_access?` which map to
|
|
152
|
+
# `plan_owner.plan_allows?(:api_access)`.
|
|
153
|
+
def method_missing(method_name, *args, &block)
|
|
154
|
+
if (m = method_name.to_s.match(/^plan_allows_(.+)\?$/))
|
|
155
|
+
feature_key = m[1].to_sym
|
|
156
|
+
return plan_allows?(feature_key)
|
|
157
|
+
end
|
|
158
|
+
super
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
162
|
+
return true if method_name.to_s.start_with?("plan_allows_") && method_name.to_s.end_with?("?")
|
|
163
|
+
super
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Pay (Stripe) convenience wrappers (return false/nil if Pay not available)
|
|
167
|
+
# Pay (Stripe) state — billing-facing, NOT used by our enforcement logic
|
|
168
|
+
def pay_subscription_active?
|
|
169
|
+
PaySupport.subscription_active_for?(self)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def pay_on_trial?
|
|
173
|
+
sub = PaySupport.current_subscription_for(self)
|
|
174
|
+
!!(sub && sub.respond_to?(:on_trial?) && sub.on_trial?)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def pay_on_grace_period?
|
|
178
|
+
sub = PaySupport.current_subscription_for(self)
|
|
179
|
+
!!(sub && sub.respond_to?(:on_grace_period?) && sub.on_grace_period?)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Per-limit grace helpers managed by PricingPlans
|
|
183
|
+
def grace_active_for?(limit_key)
|
|
184
|
+
GraceManager.grace_active?(self, limit_key)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def grace_ends_at_for(limit_key)
|
|
188
|
+
GraceManager.grace_ends_at(self, limit_key)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def grace_remaining_seconds_for(limit_key)
|
|
192
|
+
ends_at = grace_ends_at_for(limit_key)
|
|
193
|
+
return 0 unless ends_at
|
|
194
|
+
[0, (ends_at - Time.current).to_i].max
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def grace_remaining_days_for(limit_key)
|
|
198
|
+
(grace_remaining_seconds_for(limit_key) / 86_400.0).ceil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def plan_blocked_for?(limit_key)
|
|
202
|
+
GraceManager.should_block?(self, limit_key)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Aggregate helpers across multiple limit keys
|
|
206
|
+
def any_grace_active_for?(*limit_keys)
|
|
207
|
+
limit_keys.flatten.any? { |k| GraceManager.grace_active?(self, k) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def earliest_grace_ends_at_for(*limit_keys)
|
|
211
|
+
times = limit_keys.flatten.map { |k| GraceManager.grace_ends_at(self, k) }.compact
|
|
212
|
+
times.min
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Rails-y wrappers for usage/status (defaults to all configured limits)
|
|
216
|
+
def limit(limit_key)
|
|
217
|
+
PricingPlans.status(self, limits: [limit_key]).first
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def limits(*limit_keys)
|
|
221
|
+
keys = normalize_limit_keys(limit_keys)
|
|
222
|
+
PricingPlans.status(self, limits: keys)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def limits_summary(*limit_keys)
|
|
226
|
+
limits(*limit_keys)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def limits_severity(*limit_keys)
|
|
230
|
+
keys = normalize_limit_keys(limit_keys)
|
|
231
|
+
PricingPlans.highest_severity_for(self, *keys)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def limits_message(*limit_keys)
|
|
235
|
+
keys = normalize_limit_keys(limit_keys)
|
|
236
|
+
PricingPlans.combine_messages_for(self, *keys)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Global overview for banner building
|
|
240
|
+
def limits_overview(*limit_keys)
|
|
241
|
+
keys = normalize_limit_keys(limit_keys)
|
|
242
|
+
PricingPlans.overview_for(self, *keys)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# View-friendly, English-like convenience helpers (pure data decisions)
|
|
246
|
+
def limit_severity(limit_key)
|
|
247
|
+
PricingPlans.severity_for(self, limit_key)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def limit_message(limit_key)
|
|
251
|
+
PricingPlans.message_for(self, limit_key)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def limit_overage(limit_key)
|
|
255
|
+
PricingPlans.overage_for(self, limit_key)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def attention_required_for_limit?(limit_key)
|
|
259
|
+
PricingPlans.attention_required?(self, limit_key)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def approaching_limit?(limit_key, at: nil)
|
|
263
|
+
PricingPlans.approaching_limit?(self, limit_key, at: at)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Returns { text:, url: }
|
|
267
|
+
def plan_cta
|
|
268
|
+
PricingPlans.cta_for(self)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Returns the pure-data alert view model for a single limit key
|
|
272
|
+
def limit_alert(limit_key)
|
|
273
|
+
PricingPlans.alert_for(self, limit_key)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private
|
|
277
|
+
|
|
278
|
+
def normalize_limit_keys(limit_keys)
|
|
279
|
+
keys = limit_keys.flatten
|
|
280
|
+
if keys.empty?
|
|
281
|
+
plan = PlanResolver.effective_plan_for(self)
|
|
282
|
+
keys = plan ? plan.limits.keys : []
|
|
283
|
+
end
|
|
284
|
+
keys
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class PlanResolver
|
|
5
|
+
class << self
|
|
6
|
+
def effective_plan_for(plan_owner)
|
|
7
|
+
# 1. Check Pay subscription status first (no app-specific gate required)
|
|
8
|
+
if PaySupport.pay_available?
|
|
9
|
+
plan_from_pay = resolve_plan_from_pay(plan_owner)
|
|
10
|
+
return plan_from_pay if plan_from_pay
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# 2. Check manual assignment
|
|
14
|
+
if plan_owner.respond_to?(:id)
|
|
15
|
+
assignment = Assignment.find_by(
|
|
16
|
+
plan_owner_type: plan_owner.class.name,
|
|
17
|
+
plan_owner_id: plan_owner.id
|
|
18
|
+
)
|
|
19
|
+
return Registry.plan(assignment.plan_key) if assignment
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# 3. Fall back to default plan
|
|
23
|
+
Registry.default_plan
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def plan_key_for(plan_owner)
|
|
27
|
+
effective_plan_for(plan_owner)&.key
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def assign_plan_manually!(plan_owner, plan_key, source: "manual")
|
|
31
|
+
Assignment.assign_plan_to(plan_owner, plan_key, source: source)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def remove_manual_assignment!(plan_owner)
|
|
35
|
+
Assignment.remove_assignment_for(plan_owner)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Backward-compatible shim for tests that stub pay_available?
|
|
41
|
+
def pay_available?
|
|
42
|
+
PaySupport.pay_available?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def resolve_plan_from_pay(plan_owner)
|
|
46
|
+
return nil unless plan_owner.respond_to?(:subscribed?) ||
|
|
47
|
+
plan_owner.respond_to?(:on_trial?) ||
|
|
48
|
+
plan_owner.respond_to?(:on_grace_period?) ||
|
|
49
|
+
plan_owner.respond_to?(:subscriptions)
|
|
50
|
+
|
|
51
|
+
# Check if plan_owner has active subscription, trial, or grace period
|
|
52
|
+
if PaySupport.subscription_active_for?(plan_owner)
|
|
53
|
+
subscription = PaySupport.current_subscription_for(plan_owner)
|
|
54
|
+
return nil unless subscription
|
|
55
|
+
|
|
56
|
+
# Map processor plan to our plan
|
|
57
|
+
processor_plan = subscription.processor_plan
|
|
58
|
+
return plan_from_processor_plan(processor_plan) if processor_plan
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def plan_from_processor_plan(processor_plan)
|
|
65
|
+
# Look through all plans to find one matching this Stripe price
|
|
66
|
+
Registry.plans.values.find do |plan|
|
|
67
|
+
stripe_price = plan.stripe_price
|
|
68
|
+
next unless stripe_price
|
|
69
|
+
|
|
70
|
+
case stripe_price
|
|
71
|
+
when String
|
|
72
|
+
stripe_price == processor_plan
|
|
73
|
+
when Hash
|
|
74
|
+
stripe_price[:id] == processor_plan ||
|
|
75
|
+
stripe_price[:month] == processor_plan ||
|
|
76
|
+
stripe_price[:year] == processor_plan ||
|
|
77
|
+
stripe_price.values.include?(processor_plan)
|
|
78
|
+
else
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Pure-data value object describing a plan price in semantic parts.
|
|
5
|
+
# UI-agnostic. Useful to render classic pricing typography and power JS toggles.
|
|
6
|
+
PriceComponents = Struct.new(
|
|
7
|
+
:present?, # boolean: true when numeric price is available
|
|
8
|
+
:currency, # String: currency symbol, e.g. "$", "€"
|
|
9
|
+
:amount, # String: human whole amount (no decimals) e.g. "29"
|
|
10
|
+
:amount_cents, # Integer: total cents e.g. 2900
|
|
11
|
+
:interval, # Symbol: :month or :year
|
|
12
|
+
:label, # String: friendly label e.g. "$29/mo" or "Contact"
|
|
13
|
+
:monthly_equivalent_cents, # Integer: same-month or yearly/12 rounded
|
|
14
|
+
keyword_init: true
|
|
15
|
+
)
|
|
16
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class Registry
|
|
5
|
+
class << self
|
|
6
|
+
def build_from_configuration(configuration)
|
|
7
|
+
@plans = configuration.plans.dup
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@event_handlers = configuration.event_handlers.dup
|
|
10
|
+
|
|
11
|
+
validate_registry!
|
|
12
|
+
lint_usage_credits_integration!
|
|
13
|
+
attach_plan_owner_helpers!
|
|
14
|
+
attach_pending_association_limits!
|
|
15
|
+
|
|
16
|
+
self
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def clear!
|
|
20
|
+
@plans = nil
|
|
21
|
+
@configuration = nil
|
|
22
|
+
@event_handlers = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def plans
|
|
26
|
+
@plans || {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def plan(key)
|
|
30
|
+
plan_obj = plans[key.to_sym]
|
|
31
|
+
raise PlanNotFoundError, "Plan #{key} not found" unless plan_obj
|
|
32
|
+
plan_obj
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def plan_exists?(key)
|
|
36
|
+
plans.key?(key.to_sym)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def configuration
|
|
40
|
+
@configuration
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def event_handlers
|
|
44
|
+
@event_handlers || { warning: {}, grace_start: {}, block: {} }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def plan_owner_class
|
|
48
|
+
return nil unless @configuration
|
|
49
|
+
|
|
50
|
+
value = @configuration.plan_owner_class
|
|
51
|
+
return nil unless value
|
|
52
|
+
|
|
53
|
+
case value
|
|
54
|
+
when String
|
|
55
|
+
value.constantize
|
|
56
|
+
when Class
|
|
57
|
+
value
|
|
58
|
+
else
|
|
59
|
+
raise ConfigurationError, "plan_owner_class must be a string or class"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def default_plan
|
|
64
|
+
return nil unless @configuration
|
|
65
|
+
plan(@configuration.default_plan)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def highlighted_plan
|
|
69
|
+
return nil unless @configuration
|
|
70
|
+
if @configuration.highlighted_plan
|
|
71
|
+
return plan(@configuration.highlighted_plan)
|
|
72
|
+
end
|
|
73
|
+
# Fallback to plan flagged highlighted in DSL
|
|
74
|
+
plans.values.find(&:highlighted?)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def emit_event(event_type, limit_key, *args)
|
|
78
|
+
handler = event_handlers.dig(event_type, limit_key)
|
|
79
|
+
handler&.call(*args)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def validate_registry!
|
|
85
|
+
# Check for duplicate stripe price IDs
|
|
86
|
+
stripe_prices = plans.values
|
|
87
|
+
.map(&:stripe_price)
|
|
88
|
+
.compact
|
|
89
|
+
.flat_map do |sp|
|
|
90
|
+
case sp
|
|
91
|
+
when String
|
|
92
|
+
[sp]
|
|
93
|
+
when Hash
|
|
94
|
+
# Extract all price ID values from the hash
|
|
95
|
+
[sp[:id], sp[:month], sp[:year]].compact
|
|
96
|
+
else
|
|
97
|
+
[]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
duplicates = stripe_prices.group_by(&:itself).select { |_, v| v.size > 1 }.keys
|
|
102
|
+
if duplicates.any?
|
|
103
|
+
raise ConfigurationError, "Duplicate Stripe price IDs found: #{duplicates.join(', ')}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validate limit configurations
|
|
107
|
+
validate_limit_consistency!
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def attach_plan_owner_helpers!
|
|
111
|
+
klass = plan_owner_class rescue nil
|
|
112
|
+
return unless klass
|
|
113
|
+
return if klass.included_modules.include?(PricingPlans::PlanOwner)
|
|
114
|
+
klass.include(PricingPlans::PlanOwner)
|
|
115
|
+
rescue StandardError
|
|
116
|
+
# If plan_owner class isn't available yet, skip silently.
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def attach_pending_association_limits!
|
|
120
|
+
PricingPlans::AssociationLimitRegistry.flush_pending!
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_limit_consistency!
|
|
124
|
+
all_limits = plans.values.flat_map do |plan|
|
|
125
|
+
plan.limits.map { |key, limit| [plan.key, key, limit] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Group by limit key to check consistency
|
|
129
|
+
limit_groups = all_limits.group_by { |_, limit_key, _| limit_key }
|
|
130
|
+
|
|
131
|
+
limit_groups.each do |limit_key, limit_configs|
|
|
132
|
+
# Filter out unlimited limits from consistency check
|
|
133
|
+
non_unlimited_configs = limit_configs.reject { |_, _, limit| limit[:to] == :unlimited }
|
|
134
|
+
|
|
135
|
+
# Check that all non-unlimited plans with the same limit key use consistent per: configuration
|
|
136
|
+
per_values = non_unlimited_configs.map { |_, _, limit| limit[:per] }.uniq
|
|
137
|
+
|
|
138
|
+
# Remove nil values to check if there are mixed per/non-per configurations
|
|
139
|
+
non_nil_per_values = per_values.compact
|
|
140
|
+
|
|
141
|
+
# If we have both nil and non-nil per values, that's inconsistent
|
|
142
|
+
# If we have multiple different non-nil per values, that's also inconsistent
|
|
143
|
+
has_nil = per_values.include?(nil)
|
|
144
|
+
has_non_nil = non_nil_per_values.any?
|
|
145
|
+
|
|
146
|
+
if (has_nil && has_non_nil) || non_nil_per_values.size > 1
|
|
147
|
+
raise ConfigurationError,
|
|
148
|
+
"Inconsistent 'per' configuration for limit '#{limit_key}': #{per_values.compact}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def usage_credits_available?
|
|
154
|
+
defined?(UsageCredits)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def lint_usage_credits_integration!
|
|
158
|
+
# With single-currency credits, we only enforce separation of concerns:
|
|
159
|
+
# - pricing_plans shows declared total credits per plan (cosmetic)
|
|
160
|
+
# - usage_credits owns operations, costs, fulfillment, and spending
|
|
161
|
+
# There is no per-operation credits declaration here anymore.
|
|
162
|
+
# Still enforce that if you choose to model a metered dimension as credits in your app,
|
|
163
|
+
# you should not also define a per-period limit with the same semantic key.
|
|
164
|
+
credit_operation_keys = if usage_credits_available?
|
|
165
|
+
UsageCredits.registry.operations.keys.map(&:to_sym) rescue []
|
|
166
|
+
else
|
|
167
|
+
[]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
plans.each do |_plan_key, plan|
|
|
171
|
+
plan.limits.each do |limit_key, limit|
|
|
172
|
+
next unless limit[:per] # Only per-period limits
|
|
173
|
+
if credit_operation_keys.include?(limit_key.to_sym)
|
|
174
|
+
raise ConfigurationError,
|
|
175
|
+
"Limit '#{limit_key}' is also a usage_credits operation. Use credits (usage_credits) OR a per-period limit (pricing_plans), not both."
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class Result
|
|
5
|
+
STATES = [:within, :warning, :grace, :blocked].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :state, :message, :limit_key, :plan_owner, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(state:, message:, limit_key: nil, plan_owner: nil, metadata: {})
|
|
10
|
+
unless STATES.include?(state)
|
|
11
|
+
raise ArgumentError, "Invalid state: #{state}. Must be one of: #{STATES}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
@state = state
|
|
15
|
+
@message = message
|
|
16
|
+
@limit_key = limit_key
|
|
17
|
+
@plan_owner = plan_owner
|
|
18
|
+
@metadata = metadata
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ok?
|
|
22
|
+
@state == :within
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
alias_method :within?, :ok?
|
|
26
|
+
|
|
27
|
+
def warning?
|
|
28
|
+
@state == :warning
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def grace?
|
|
32
|
+
@state == :grace
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def blocked?
|
|
36
|
+
@state == :blocked
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def success?
|
|
40
|
+
ok? || warning? || grace?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def failure?
|
|
44
|
+
blocked?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Helper methods for view rendering
|
|
48
|
+
def css_class
|
|
49
|
+
case @state
|
|
50
|
+
when :within
|
|
51
|
+
"success"
|
|
52
|
+
when :warning
|
|
53
|
+
"warning"
|
|
54
|
+
when :grace
|
|
55
|
+
"warning"
|
|
56
|
+
when :blocked
|
|
57
|
+
"error"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def icon
|
|
62
|
+
case @state
|
|
63
|
+
when :within
|
|
64
|
+
"✓"
|
|
65
|
+
when :warning
|
|
66
|
+
"⚠"
|
|
67
|
+
when :grace
|
|
68
|
+
"⏳"
|
|
69
|
+
when :blocked
|
|
70
|
+
"🚫"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
state: @state,
|
|
77
|
+
message: @message,
|
|
78
|
+
limit_key: @limit_key,
|
|
79
|
+
metadata: @metadata,
|
|
80
|
+
ok: ok?,
|
|
81
|
+
warning: warning?,
|
|
82
|
+
grace: grace?,
|
|
83
|
+
blocked: blocked?
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def inspect
|
|
88
|
+
"#<PricingPlans::Result state=#{@state} message=\"#{@message}\">"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
def within(message = "Within limit", **options)
|
|
93
|
+
new(state: :within, message: message, **options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def warning(message, **options)
|
|
97
|
+
new(state: :warning, message: message, **options)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def grace(message, **options)
|
|
101
|
+
new(state: :grace, message: message, **options)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def blocked(message, **options)
|
|
105
|
+
new(state: :blocked, message: message, **options)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|