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,653 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "integer_refinements"
|
|
4
|
+
|
|
5
|
+
module PricingPlans
|
|
6
|
+
class Plan
|
|
7
|
+
using IntegerRefinements
|
|
8
|
+
|
|
9
|
+
attr_reader :key, :features
|
|
10
|
+
|
|
11
|
+
def initialize(key)
|
|
12
|
+
@key = key
|
|
13
|
+
@name = nil
|
|
14
|
+
@description = nil
|
|
15
|
+
@bullets = []
|
|
16
|
+
@price = nil
|
|
17
|
+
@price_string = nil
|
|
18
|
+
@stripe_price = nil
|
|
19
|
+
@features = Set.new
|
|
20
|
+
@limits = {}
|
|
21
|
+
@credits_included = nil
|
|
22
|
+
@meta = {}
|
|
23
|
+
@cta_text = nil
|
|
24
|
+
@cta_url = nil
|
|
25
|
+
@default = false
|
|
26
|
+
@highlighted = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# DSL methods for plan configuration
|
|
30
|
+
def set_name(value)
|
|
31
|
+
@name = value.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def name(value = nil)
|
|
35
|
+
if value.nil?
|
|
36
|
+
@name || @key.to_s.titleize
|
|
37
|
+
else
|
|
38
|
+
set_name(value)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def set_description(value)
|
|
43
|
+
@description = value.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def description(value = nil)
|
|
47
|
+
if value.nil?
|
|
48
|
+
@description
|
|
49
|
+
else
|
|
50
|
+
set_description(value)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set_bullets(*values)
|
|
55
|
+
@bullets = values.flatten.map(&:to_s)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def bullets(*values)
|
|
59
|
+
if values.empty?
|
|
60
|
+
@bullets
|
|
61
|
+
else
|
|
62
|
+
set_bullets(*values)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_price(value)
|
|
67
|
+
@price = value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def price(value = nil)
|
|
71
|
+
if value.nil?
|
|
72
|
+
@price
|
|
73
|
+
else
|
|
74
|
+
set_price(value)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Rails-y ergonomics for UI: expose integer cents as optional helper
|
|
79
|
+
def price_cents
|
|
80
|
+
return nil unless @price
|
|
81
|
+
(
|
|
82
|
+
if @price.respond_to?(:to_f)
|
|
83
|
+
(@price.to_f * 100).round
|
|
84
|
+
else
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Ergonomic predicate for UI/logic (free means explicit 0 price or explicit "Free" label)
|
|
91
|
+
def free?
|
|
92
|
+
return false if @stripe_price
|
|
93
|
+
return true if @price.respond_to?(:to_i) && @price.to_i.zero?
|
|
94
|
+
return true if @price_string && @price_string.to_s.strip.casecmp("Free").zero?
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def set_price_string(value)
|
|
99
|
+
@price_string = value.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def price_string(value = nil)
|
|
103
|
+
if value.nil?
|
|
104
|
+
@price_string
|
|
105
|
+
else
|
|
106
|
+
set_price_string(value)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def set_stripe_price(value)
|
|
111
|
+
case value
|
|
112
|
+
when String
|
|
113
|
+
@stripe_price = { id: value }
|
|
114
|
+
when Hash
|
|
115
|
+
@stripe_price = value
|
|
116
|
+
else
|
|
117
|
+
raise ConfigurationError, "stripe_price must be a string or hash"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def stripe_price(value = nil)
|
|
122
|
+
if value.nil?
|
|
123
|
+
@stripe_price
|
|
124
|
+
else
|
|
125
|
+
set_stripe_price(value)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def set_meta(values)
|
|
130
|
+
@meta.merge!(values)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def meta(values = nil)
|
|
134
|
+
if values.nil?
|
|
135
|
+
@meta
|
|
136
|
+
else
|
|
137
|
+
set_meta(values)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# CTA helpers for pricing UI
|
|
142
|
+
def set_cta_text(value)
|
|
143
|
+
@cta_text = value&.to_s
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def cta_text(value = nil)
|
|
147
|
+
if value.nil?
|
|
148
|
+
@cta_text || PricingPlans.configuration.default_cta_text || default_cta_text_derived
|
|
149
|
+
else
|
|
150
|
+
set_cta_text(value)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def set_cta_url(value)
|
|
155
|
+
@cta_url = value&.to_s
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Unified ergonomic API:
|
|
159
|
+
# - Setter/getter: cta_url, cta_url("/checkout")
|
|
160
|
+
# - Resolver: cta_url(plan_owner: org)
|
|
161
|
+
def cta_url(value = :__no_arg__, plan_owner: nil)
|
|
162
|
+
unless value == :__no_arg__
|
|
163
|
+
set_cta_url(value)
|
|
164
|
+
return @cta_url
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return @cta_url if @cta_url
|
|
168
|
+
default = PricingPlans.configuration.default_cta_url
|
|
169
|
+
return default if default
|
|
170
|
+
# New default: if host app defines subscribe_path, prefer that
|
|
171
|
+
if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
|
|
172
|
+
return Rails.application.routes.url_helpers.subscribe_path(plan: key, interval: :month)
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Feature methods
|
|
178
|
+
def allows(*feature_keys)
|
|
179
|
+
feature_keys.flatten.each do |key|
|
|
180
|
+
@features.add(key.to_sym)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def allow(*feature_keys)
|
|
185
|
+
allows(*feature_keys)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def disallows(*feature_keys)
|
|
189
|
+
feature_keys.flatten.each do |key|
|
|
190
|
+
@features.delete(key.to_sym)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def disallow(*feature_keys)
|
|
195
|
+
disallows(*feature_keys)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def allows_feature?(feature_key)
|
|
199
|
+
@features.include?(feature_key.to_sym)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Limit methods
|
|
203
|
+
def set_limit(key, **options)
|
|
204
|
+
limit_key = key.to_sym
|
|
205
|
+
@limits[limit_key] = {
|
|
206
|
+
key: limit_key,
|
|
207
|
+
to: options[:to],
|
|
208
|
+
per: options[:per],
|
|
209
|
+
after_limit: options.fetch(:after_limit, :block_usage),
|
|
210
|
+
grace: options.fetch(:grace, 7.days),
|
|
211
|
+
warn_at: options.fetch(:warn_at, [0.6, 0.8, 0.95]),
|
|
212
|
+
count_scope: options[:count_scope]
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
validate_limit_options!(@limits[limit_key])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def limits(key=nil, **options)
|
|
219
|
+
if key.nil?
|
|
220
|
+
@limits
|
|
221
|
+
else
|
|
222
|
+
set_limit(key, **options)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def limit(key, **options)
|
|
227
|
+
set_limit(key, **options)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def unlimited(*keys)
|
|
231
|
+
keys.flatten.each do |key|
|
|
232
|
+
set_limit(key.to_sym, to: :unlimited)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def limit_for(key)
|
|
237
|
+
@limits[key.to_sym]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Credits display methods (cosmetic, for pricing UI)
|
|
241
|
+
# Single-currency credits. We do not tie credits to operations here.
|
|
242
|
+
def includes_credits(amount)
|
|
243
|
+
@credits_included = amount.to_i
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def credits_included(value = :__get__)
|
|
247
|
+
if value == :__get__
|
|
248
|
+
@credits_included
|
|
249
|
+
else
|
|
250
|
+
@credits_included = value.to_i
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Plan selection sugar
|
|
255
|
+
def default!(value = true)
|
|
256
|
+
@default = !!value
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def default?
|
|
260
|
+
!!@default
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def highlighted!(value = true)
|
|
264
|
+
@highlighted = !!value
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def highlighted?
|
|
268
|
+
return true if @highlighted
|
|
269
|
+
# Treat configuration.highlighted_plan as highlighted without consulting Registry to avoid recursion
|
|
270
|
+
begin
|
|
271
|
+
cfg = PricingPlans.configuration
|
|
272
|
+
return true if cfg && cfg.highlighted_plan && cfg.highlighted_plan.to_sym == @key
|
|
273
|
+
rescue StandardError
|
|
274
|
+
end
|
|
275
|
+
false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Syntactic sugar for popular/highlighted
|
|
279
|
+
def popular?
|
|
280
|
+
highlighted?
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Convenience booleans used by views/hosts
|
|
284
|
+
# (keep single definition above)
|
|
285
|
+
|
|
286
|
+
def purchasable?
|
|
287
|
+
!!@stripe_price || (!free? && !!@price)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Human label to display price in UIs. Prefers explicit string, then numeric, else contact.
|
|
291
|
+
def price_label
|
|
292
|
+
# Auto-fetch from processor (Stripe) if enabled and plan has stripe_price
|
|
293
|
+
cfg = PricingPlans.configuration
|
|
294
|
+
if cfg&.auto_price_labels_from_processor && stripe_price
|
|
295
|
+
begin
|
|
296
|
+
if defined?(::Stripe)
|
|
297
|
+
price_id = stripe_price.is_a?(Hash) ? (stripe_price[:id] || stripe_price[:month] || stripe_price[:year]) : stripe_price
|
|
298
|
+
if price_id
|
|
299
|
+
pr = ::Stripe::Price.retrieve(price_id)
|
|
300
|
+
amount = pr.unit_amount.to_f / 100.0
|
|
301
|
+
interval = pr.recurring&.interval
|
|
302
|
+
suffix = interval ? "/#{interval[0,3]}" : ""
|
|
303
|
+
return "$#{amount}#{suffix}"
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
rescue StandardError
|
|
307
|
+
# fallthrough to local derivation
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
# Allow host app override via resolver
|
|
311
|
+
if cfg&.price_label_resolver
|
|
312
|
+
begin
|
|
313
|
+
built = cfg.price_label_resolver.call(self)
|
|
314
|
+
return built if built
|
|
315
|
+
rescue StandardError
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
return "Free" if price && price.to_i.zero?
|
|
319
|
+
return price_string if price_string
|
|
320
|
+
return "$#{price}/mo" if price
|
|
321
|
+
return "Contact" if stripe_price || price.nil?
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# --- New semantic pricing API ---
|
|
326
|
+
|
|
327
|
+
# Compute semantic price parts for the given interval (:month or :year).
|
|
328
|
+
# Falls back to price_string when no numeric price exists.
|
|
329
|
+
def price_components(interval: :month)
|
|
330
|
+
# 1) Allow app override
|
|
331
|
+
if (resolver = PricingPlans.configuration.price_components_resolver)
|
|
332
|
+
begin
|
|
333
|
+
resolved = resolver.call(self, interval)
|
|
334
|
+
return resolved if resolved
|
|
335
|
+
rescue StandardError
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# 2) String-only prices
|
|
340
|
+
if price_string
|
|
341
|
+
return PricingPlans::PriceComponents.new(
|
|
342
|
+
present?: false,
|
|
343
|
+
currency: nil,
|
|
344
|
+
amount: nil,
|
|
345
|
+
amount_cents: nil,
|
|
346
|
+
interval: interval,
|
|
347
|
+
label: price_string,
|
|
348
|
+
monthly_equivalent_cents: nil
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# 3) Explicit numeric price (single interval, assume monthly semantics)
|
|
353
|
+
if price
|
|
354
|
+
cents = price_cents
|
|
355
|
+
cur = PricingPlans.configuration.default_currency_symbol
|
|
356
|
+
label = if interval == :month
|
|
357
|
+
"#{cur}#{price}/mo"
|
|
358
|
+
else
|
|
359
|
+
# Treat yearly as 12x when only a single numeric price is declared
|
|
360
|
+
"#{cur}#{(price.to_f * 12).round}/yr"
|
|
361
|
+
end
|
|
362
|
+
return PricingPlans::PriceComponents.new(
|
|
363
|
+
present?: true,
|
|
364
|
+
currency: cur,
|
|
365
|
+
amount: (interval == :month ? price.to_i : (price.to_f * 12).round).to_s,
|
|
366
|
+
amount_cents: (interval == :month ? cents : (cents.to_i * 12)),
|
|
367
|
+
interval: interval,
|
|
368
|
+
label: label,
|
|
369
|
+
monthly_equivalent_cents: cents
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# 4) Stripe price(s)
|
|
374
|
+
if stripe_price
|
|
375
|
+
comp = stripe_price_components(interval)
|
|
376
|
+
return comp if comp
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# 5) No price info at all → Contact
|
|
380
|
+
PricingPlans::PriceComponents.new(
|
|
381
|
+
present?: false,
|
|
382
|
+
currency: nil,
|
|
383
|
+
amount: nil,
|
|
384
|
+
amount_cents: nil,
|
|
385
|
+
interval: interval,
|
|
386
|
+
label: "Contact",
|
|
387
|
+
monthly_equivalent_cents: nil
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def monthly_price_components
|
|
392
|
+
price_components(interval: :month)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def yearly_price_components
|
|
396
|
+
price_components(interval: :year)
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def has_interval_prices?
|
|
400
|
+
sp = stripe_price
|
|
401
|
+
return true if sp.is_a?(Hash) && (sp[:month] || sp[:year])
|
|
402
|
+
return !price.nil? || !price_string.nil?
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def has_numeric_price?
|
|
406
|
+
!!price || !!stripe_price
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def price_label_for(interval)
|
|
410
|
+
pc = price_components(interval: interval)
|
|
411
|
+
pc.label
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Stripe convenience accessors (nil when interval not present)
|
|
415
|
+
def monthly_price_cents
|
|
416
|
+
pc = monthly_price_components
|
|
417
|
+
pc.present? ? pc.amount_cents : nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def yearly_price_cents
|
|
421
|
+
pc = yearly_price_components
|
|
422
|
+
pc.present? ? pc.amount_cents : nil
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def monthly_price_id
|
|
426
|
+
stripe_price_id_for(:month)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def yearly_price_id
|
|
430
|
+
stripe_price_id_for(:year)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def currency_symbol
|
|
434
|
+
if stripe_price
|
|
435
|
+
# Try to derive from Stripe API/cache; fall back to default
|
|
436
|
+
pr = fetch_stripe_price_record(preferred_price_id(:month) || preferred_price_id(:year))
|
|
437
|
+
if pr
|
|
438
|
+
return currency_symbol_from(pr)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
PricingPlans.configuration.default_currency_symbol
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Plan comparison helpers for CTA ergonomics
|
|
445
|
+
def current_for?(current_plan)
|
|
446
|
+
return false unless current_plan
|
|
447
|
+
current_plan.key.to_sym == key.to_sym
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def upgrade_from?(current_plan)
|
|
451
|
+
return false unless current_plan
|
|
452
|
+
comparable_price_cents(self) > comparable_price_cents(current_plan)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def downgrade_from?(current_plan)
|
|
456
|
+
return false unless current_plan
|
|
457
|
+
comparable_price_cents(self) < comparable_price_cents(current_plan)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def downgrade_blocked_reason(from: nil, plan_owner: nil)
|
|
461
|
+
return nil unless from
|
|
462
|
+
allowed, reason = PricingPlans.configuration.downgrade_policy.call(from: from, to: self, plan_owner: plan_owner)
|
|
463
|
+
allowed ? nil : (reason || "Downgrade not allowed")
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Pure-data view model for JS/Hotwire
|
|
467
|
+
def to_view_model
|
|
468
|
+
{
|
|
469
|
+
id: key.to_s,
|
|
470
|
+
key: key.to_s,
|
|
471
|
+
name: name,
|
|
472
|
+
description: description,
|
|
473
|
+
features: bullets, # alias in this gem
|
|
474
|
+
highlighted: highlighted?,
|
|
475
|
+
default: default?,
|
|
476
|
+
free: free?,
|
|
477
|
+
currency: currency_symbol,
|
|
478
|
+
monthly_price_cents: monthly_price_cents,
|
|
479
|
+
yearly_price_cents: yearly_price_cents,
|
|
480
|
+
monthly_price_id: monthly_price_id,
|
|
481
|
+
yearly_price_id: yearly_price_id,
|
|
482
|
+
price_label: price_label,
|
|
483
|
+
price_string: price_string,
|
|
484
|
+
limits: limits.transform_values { |v| v.dup }
|
|
485
|
+
}
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def validate!
|
|
489
|
+
validate_limits!
|
|
490
|
+
validate_pricing!
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
private
|
|
494
|
+
def validate_limits!
|
|
495
|
+
@limits.each do |key, limit|
|
|
496
|
+
validate_limit_options!(limit)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def validate_limit_options!(limit)
|
|
501
|
+
# Validate to: value
|
|
502
|
+
unless limit[:to] == :unlimited || limit[:to].is_a?(Integer) || (limit[:to].respond_to?(:to_i) && !limit[:to].is_a?(String))
|
|
503
|
+
raise ConfigurationError, "Limit #{limit[:key]} 'to' must be :unlimited, Integer, or respond to to_i"
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Validate after_limit values
|
|
507
|
+
valid_after_limit = [:grace_then_block, :block_usage, :just_warn]
|
|
508
|
+
unless valid_after_limit.include?(limit[:after_limit])
|
|
509
|
+
raise ConfigurationError, "Limit #{limit[:key]} after_limit must be one of #{valid_after_limit.join(', ')}"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Validate grace only applies to blocking behaviors
|
|
513
|
+
if limit[:grace] && limit[:after_limit] == :just_warn
|
|
514
|
+
raise ConfigurationError, "Limit #{limit[:key]} cannot have grace with :just_warn after_limit"
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Validate warn_at thresholds
|
|
518
|
+
if limit[:warn_at] && !limit[:warn_at].all? { |t| t.is_a?(Numeric) && t.between?(0, 1) }
|
|
519
|
+
raise ConfigurationError, "Limit #{limit[:key]} warn_at thresholds must be numbers between 0 and 1"
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# Validate count_scope only for persistent caps (no per-period)
|
|
523
|
+
if limit[:count_scope] && limit[:per]
|
|
524
|
+
raise ConfigurationError, "Limit #{limit[:key]} cannot set count_scope for per-period limits"
|
|
525
|
+
end
|
|
526
|
+
if limit[:count_scope]
|
|
527
|
+
cs = limit[:count_scope]
|
|
528
|
+
allowed = cs.respond_to?(:call) || cs.is_a?(Symbol) || cs.is_a?(Hash) || (cs.is_a?(Array) && cs.all? { |e| e.respond_to?(:call) || e.is_a?(Symbol) || e.is_a?(Hash) })
|
|
529
|
+
raise ConfigurationError, "Limit #{limit[:key]} count_scope must be a Proc, Symbol, Hash, or Array of these" unless allowed
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
def validate_pricing!
|
|
534
|
+
pricing_fields = [@price, @price_string, @stripe_price].compact
|
|
535
|
+
if pricing_fields.size > 1
|
|
536
|
+
raise ConfigurationError, "Plan #{@key} can only have one of: price, price_string, or stripe_price"
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# (cta_url resolver moved above with unified signature)
|
|
541
|
+
|
|
542
|
+
def default_cta_text_derived
|
|
543
|
+
return "Subscribe" if @stripe_price
|
|
544
|
+
return "Choose #{@name || @key.to_s.titleize}" if price || price_string
|
|
545
|
+
return "Choose plan" if @stripe_price.nil? && !price && !price_string
|
|
546
|
+
"Choose #{@name || @key.to_s.titleize}"
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def default_cta_url_derived
|
|
550
|
+
# If Stripe price present and Pay is used, UIs commonly route to checkout; we leave URL blank for app to decide.
|
|
551
|
+
nil
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# --- Internal helpers for Stripe fetching and caching ---
|
|
555
|
+
|
|
556
|
+
def stripe_price_id_for(interval)
|
|
557
|
+
sp = stripe_price
|
|
558
|
+
case sp
|
|
559
|
+
when Hash
|
|
560
|
+
case interval
|
|
561
|
+
when :month then sp[:month] || sp[:id]
|
|
562
|
+
when :year then sp[:year]
|
|
563
|
+
else sp[:id]
|
|
564
|
+
end
|
|
565
|
+
when String
|
|
566
|
+
sp
|
|
567
|
+
else
|
|
568
|
+
nil
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
def preferred_price_id(interval)
|
|
573
|
+
stripe_price_id_for(interval)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def stripe_price_components(interval)
|
|
577
|
+
return nil unless defined?(::Stripe)
|
|
578
|
+
price_id = preferred_price_id(interval)
|
|
579
|
+
return nil unless price_id
|
|
580
|
+
pr = fetch_stripe_price_record(price_id)
|
|
581
|
+
return nil unless pr
|
|
582
|
+
amount_cents = (pr.unit_amount || pr.unit_amount_decimal || 0).to_i
|
|
583
|
+
interval_sym = (pr.recurring&.interval == "year" ? :year : :month)
|
|
584
|
+
cur = currency_symbol_from(pr)
|
|
585
|
+
label = "#{cur}#{(amount_cents / 100.0).round}/#{interval_sym == :year ? 'yr' : 'mo'}"
|
|
586
|
+
monthly_equiv = interval_sym == :month ? amount_cents : (amount_cents / 12.0).round
|
|
587
|
+
PricingPlans::PriceComponents.new(
|
|
588
|
+
present?: true,
|
|
589
|
+
currency: cur,
|
|
590
|
+
amount: ((amount_cents / 100.0).round).to_i.to_s,
|
|
591
|
+
amount_cents: amount_cents,
|
|
592
|
+
interval: interval_sym,
|
|
593
|
+
label: label,
|
|
594
|
+
monthly_equivalent_cents: monthly_equiv
|
|
595
|
+
)
|
|
596
|
+
rescue StandardError
|
|
597
|
+
nil
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Normalize a plan into a comparable monthly price in cents for upgrades/downgrades
|
|
601
|
+
def comparable_price_cents(plan)
|
|
602
|
+
return 0 if plan.free?
|
|
603
|
+
pcm = plan.monthly_price_cents
|
|
604
|
+
return pcm if pcm
|
|
605
|
+
pcy = plan.yearly_price_cents
|
|
606
|
+
return (pcy.to_f / 12.0).round if pcy
|
|
607
|
+
0
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def currency_symbol_from(price_record)
|
|
611
|
+
code = price_record.try(:currency).to_s.upcase
|
|
612
|
+
case code
|
|
613
|
+
when "USD" then "$"
|
|
614
|
+
when "EUR" then "€"
|
|
615
|
+
when "GBP" then "£"
|
|
616
|
+
else PricingPlans.configuration.default_currency_symbol
|
|
617
|
+
end
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def fetch_stripe_price_record(price_id)
|
|
621
|
+
cfg = PricingPlans.configuration
|
|
622
|
+
cache = cfg.price_cache
|
|
623
|
+
cache_key = ["pricing_plans", "stripe_price", price_id].join(":")
|
|
624
|
+
if cache
|
|
625
|
+
cached = safe_cache_read(cache, cache_key)
|
|
626
|
+
return cached if cached
|
|
627
|
+
end
|
|
628
|
+
pr = ::Stripe::Price.retrieve(price_id)
|
|
629
|
+
if cache
|
|
630
|
+
safe_cache_write(cache, cache_key, pr, expires_in: cfg.price_cache_ttl)
|
|
631
|
+
end
|
|
632
|
+
pr
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def safe_cache_read(cache, key)
|
|
636
|
+
cache.respond_to?(:read) ? cache.read(key) : nil
|
|
637
|
+
rescue StandardError
|
|
638
|
+
nil
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
def safe_cache_write(cache, key, value, expires_in: nil)
|
|
642
|
+
if cache.respond_to?(:write)
|
|
643
|
+
if expires_in
|
|
644
|
+
cache.write(key, value, expires_in: expires_in)
|
|
645
|
+
else
|
|
646
|
+
cache.write(key, value)
|
|
647
|
+
end
|
|
648
|
+
end
|
|
649
|
+
rescue StandardError
|
|
650
|
+
# ignore cache errors
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|