pricing_plans 0.5.0 → 0.7.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 +4 -4
- data/.rubocop.yml +4 -7
- data/.simplecov +6 -5
- data/Appraisals +5 -1
- data/CHANGELOG.md +34 -0
- data/README.md +64 -1
- data/docs/01-define-pricing-plans.md +6 -1
- data/docs/03-model-helpers.md +16 -0
- data/docs/06-gem-compatibility.md +35 -0
- data/docs/07-repricing.md +103 -0
- data/docs/08-feature-passes.md +192 -0
- data/gemfiles/rails_7.1.gemfile +26 -0
- data/gemfiles/rails_7.2.gemfile +3 -2
- data/gemfiles/rails_8.1.gemfile +2 -1
- data/lib/generators/pricing_plans/grants/grants_generator.rb +43 -0
- data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +44 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +29 -8
- data/lib/generators/pricing_plans/install/templates/initializer.rb +11 -0
- data/lib/generators/pricing_plans/passes/passes_generator.rb +31 -0
- data/lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb +19 -0
- data/lib/pricing_plans/engine.rb +1 -0
- data/lib/pricing_plans/feature_access.rb +110 -0
- data/lib/pricing_plans/models/assignment.rb +2 -4
- data/lib/pricing_plans/models/feature_grant.rb +212 -0
- data/lib/pricing_plans/overage_reporter.rb +7 -1
- data/lib/pricing_plans/pay_support.rb +92 -100
- data/lib/pricing_plans/plan.rb +113 -7
- data/lib/pricing_plans/plan_owner.rb +97 -7
- data/lib/pricing_plans/plan_owner_identity.rb +30 -0
- data/lib/pricing_plans/plan_resolver.rb +53 -37
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +6 -1
- metadata +14 -4
|
@@ -12,120 +12,112 @@ module PricingPlans
|
|
|
12
12
|
puts message if PricingPlans.configuration&.debug
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Whether an owner currently has a billing relationship that should keep
|
|
16
|
+
# plan entitlements. Besides Pay's active/trial/grace states, this includes
|
|
17
|
+
# past_due while the processor is retrying payment. Canceled and unpaid
|
|
18
|
+
# subscriptions remain ineligible.
|
|
15
19
|
def subscription_active_for?(plan_owner)
|
|
16
20
|
return false unless plan_owner
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
log_debug "[PricingPlans::PaySupport] subscription_active_for? called for #{plan_owner.class.name}##{owner_id}"
|
|
20
|
-
|
|
21
|
-
# Prefer Pay's official API on the payment_processor
|
|
22
|
-
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
23
|
-
log_debug "[PricingPlans::PaySupport] payment_processor found: #{pp.class.name}##{pp.id}"
|
|
24
|
-
|
|
25
|
-
# Check all subscriptions, not just the default-named one
|
|
26
|
-
# Note: Don't call pp.subscribed?() without a name parameter, as it defaults to
|
|
27
|
-
# checking only for subscriptions named Pay.default_product_name (usually "default")
|
|
28
|
-
if pp.respond_to?(:subscriptions)
|
|
29
|
-
subs = pp.subscriptions
|
|
30
|
-
log_debug "[PricingPlans::PaySupport] subscriptions relation: #{subs.class.name}, count: #{subs.count}"
|
|
31
|
-
|
|
32
|
-
# Force array conversion to ensure we iterate through all subscriptions
|
|
33
|
-
# Some ActiveRecord relations might not enumerate properly in boolean context
|
|
34
|
-
subs_array = subs.respond_to?(:to_a) ? subs.to_a : subs
|
|
35
|
-
log_debug "[PricingPlans::PaySupport] subscriptions array size: #{subs_array.size}"
|
|
36
|
-
|
|
37
|
-
subs_array.each_with_index do |sub, idx|
|
|
38
|
-
log_debug "[PricingPlans::PaySupport] [#{idx}] Subscription: #{sub.class.name}##{sub.id}, name: #{sub.name rescue 'N/A'}, status: #{sub.status rescue 'N/A'}, active?: #{sub.active? rescue 'N/A'}, on_trial?: #{sub.on_trial? rescue 'N/A'}, on_grace_period?: #{sub.on_grace_period? rescue 'N/A'}"
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
result = subs_array.any? { |sub| (sub.respond_to?(:active?) && sub.active?) || (sub.respond_to?(:on_trial?) && sub.on_trial?) || (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) }
|
|
42
|
-
log_debug "[PricingPlans::PaySupport] subscription_active_for? returning: #{result}"
|
|
43
|
-
return result
|
|
44
|
-
else
|
|
45
|
-
log_debug "[PricingPlans::PaySupport] payment_processor does not respond to :subscriptions"
|
|
46
|
-
end
|
|
47
|
-
else
|
|
48
|
-
log_debug "[PricingPlans::PaySupport] No payment_processor found or plan_owner doesn't respond to :payment_processor"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Fallbacks for apps that surface Pay state on the owner
|
|
52
|
-
individual_active = (plan_owner.respond_to?(:subscribed?) && plan_owner.subscribed?) ||
|
|
53
|
-
(plan_owner.respond_to?(:on_trial?) && plan_owner.on_trial?) ||
|
|
54
|
-
(plan_owner.respond_to?(:on_grace_period?) && plan_owner.on_grace_period?)
|
|
55
|
-
log_debug "[PricingPlans::PaySupport] Fallback individual_active: #{individual_active}"
|
|
56
|
-
return true if individual_active
|
|
22
|
+
log_debug "[PricingPlans::PaySupport] subscription_active_for? called for #{owner_label(plan_owner)}"
|
|
57
23
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
log_debug "[PricingPlans::PaySupport] subscription_active_for? returning false (no active subscription found)"
|
|
64
|
-
false
|
|
24
|
+
result = current_subscriptions_for(plan_owner).any?
|
|
25
|
+
log_debug "[PricingPlans::PaySupport] subscription_active_for? returning: #{result}"
|
|
26
|
+
result
|
|
65
27
|
end
|
|
66
28
|
|
|
67
29
|
def current_subscription_for(plan_owner)
|
|
68
|
-
|
|
30
|
+
current_subscriptions_for(plan_owner).first
|
|
31
|
+
end
|
|
69
32
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
found = subs_array.find do |sub|
|
|
89
|
-
(sub.respond_to?(:on_trial?) && sub.on_trial?) ||
|
|
90
|
-
(sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) ||
|
|
91
|
-
(sub.respond_to?(:active?) && sub.active?)
|
|
92
|
-
end
|
|
93
|
-
log_debug "[PricingPlans::PaySupport] current_subscription_for found: #{found ? "#{found.class.name}##{found.id} (name: #{found.name})" : 'nil'}"
|
|
94
|
-
return found if found
|
|
95
|
-
else
|
|
96
|
-
log_debug "[PricingPlans::PaySupport] payment_processor does not respond to :subscriptions"
|
|
97
|
-
end
|
|
98
|
-
else
|
|
99
|
-
log_debug "[PricingPlans::PaySupport] No payment_processor found or plan_owner doesn't respond to :payment_processor"
|
|
33
|
+
# Returns every entitlement-bearing subscription from the canonical Pay
|
|
34
|
+
# customer. PlanResolver can then prefer one whose processor_plan appears
|
|
35
|
+
# in the registry instead of accidentally choosing an unrelated active
|
|
36
|
+
# subscription that happened to be returned first.
|
|
37
|
+
def current_subscriptions_for(plan_owner)
|
|
38
|
+
return [] unless plan_owner && pay_available?
|
|
39
|
+
|
|
40
|
+
log_debug "[PricingPlans::PaySupport] current_subscriptions_for called for #{owner_label(plan_owner)}"
|
|
41
|
+
|
|
42
|
+
processor_subscriptions = current_payment_processor_subscriptions(plan_owner)
|
|
43
|
+
return processor_subscriptions unless processor_subscriptions.empty?
|
|
44
|
+
|
|
45
|
+
subscriptions = []
|
|
46
|
+
subscriptions << plan_owner.subscription if plan_owner.respond_to?(:subscription)
|
|
47
|
+
subscriptions.concat(subscription_collection(plan_owner))
|
|
48
|
+
|
|
49
|
+
current_subscriptions_in(subscriptions.compact.uniq).tap do |current|
|
|
50
|
+
log_debug "[PricingPlans::PaySupport] found #{current.size} current owner subscription(s)"
|
|
100
51
|
end
|
|
52
|
+
end
|
|
101
53
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
54
|
+
def subscription_current?(subscription)
|
|
55
|
+
return false unless subscription
|
|
56
|
+
return false if subscription.respond_to?(:ended?) && subscription.ended?
|
|
57
|
+
# Pay's Stripe adapter keeps status="past_due" while a void pause can
|
|
58
|
+
# independently become effective. Do not let the explicit past_due
|
|
59
|
+
# entitlement below revive a subscription that Pay says is currently
|
|
60
|
+
# paused. A scheduled future pause remains current through
|
|
61
|
+
# `on_grace_period?`, matching Pay's own active? semantics.
|
|
62
|
+
return false if subscription.respond_to?(:pause_active?) && subscription.pause_active?
|
|
63
|
+
|
|
64
|
+
state_predicates = %i[active? on_trial? on_grace_period? past_due?]
|
|
65
|
+
state_predicates.any? do |predicate|
|
|
66
|
+
subscription.respond_to?(predicate) && subscription.public_send(predicate)
|
|
114
67
|
end
|
|
68
|
+
end
|
|
115
69
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
70
|
+
def subscription_collection(record)
|
|
71
|
+
return [] unless record.respond_to?(:subscriptions)
|
|
72
|
+
|
|
73
|
+
subscriptions = record.subscriptions
|
|
74
|
+
return [] unless subscriptions
|
|
75
|
+
|
|
76
|
+
subscriptions.respond_to?(:to_a) ? subscriptions.to_a : Array(subscriptions)
|
|
77
|
+
end
|
|
78
|
+
private_class_method :subscription_collection
|
|
79
|
+
|
|
80
|
+
def current_payment_processor_subscriptions(plan_owner)
|
|
81
|
+
return [] unless plan_owner.respond_to?(:payment_processor)
|
|
82
|
+
|
|
83
|
+
payment_processor = payment_processor_for(plan_owner)
|
|
84
|
+
return [] unless payment_processor
|
|
85
|
+
|
|
86
|
+
current_subscriptions_in(subscription_collection(payment_processor)).tap do |subscriptions|
|
|
87
|
+
next if subscriptions.empty?
|
|
88
|
+
|
|
89
|
+
log_debug "[PricingPlans::PaySupport] found #{subscriptions.size} current payment-processor subscription(s)"
|
|
125
90
|
end
|
|
91
|
+
end
|
|
92
|
+
private_class_method :current_payment_processor_subscriptions
|
|
93
|
+
|
|
94
|
+
# Pay::Attributes overrides the generated `payment_processor` association
|
|
95
|
+
# reader: when no row exists and a default processor is configured, merely
|
|
96
|
+
# calling it creates a Pay::Customer. Plan lookup is a read operation and
|
|
97
|
+
# must never acquire that side effect. Read the underlying Active Record
|
|
98
|
+
# association directly when it exists; retain the public-reader fallback
|
|
99
|
+
# for PORO integrations and older Pay-shaped adapters.
|
|
100
|
+
def payment_processor_for(plan_owner)
|
|
101
|
+
reflection =
|
|
102
|
+
if plan_owner.class.respond_to?(:reflect_on_association)
|
|
103
|
+
plan_owner.class.reflect_on_association(:payment_processor)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
return plan_owner.association(:payment_processor).reader if reflection && plan_owner.respond_to?(:association)
|
|
107
|
+
|
|
108
|
+
plan_owner.payment_processor
|
|
109
|
+
end
|
|
110
|
+
private_class_method :payment_processor_for
|
|
111
|
+
|
|
112
|
+
def current_subscriptions_in(subscriptions)
|
|
113
|
+
Array(subscriptions).select { |subscription| subscription_current?(subscription) }
|
|
114
|
+
end
|
|
115
|
+
private_class_method :current_subscriptions_in
|
|
126
116
|
|
|
127
|
-
|
|
128
|
-
|
|
117
|
+
def owner_label(plan_owner)
|
|
118
|
+
owner_id = plan_owner.respond_to?(:id) ? plan_owner.id : "N/A"
|
|
119
|
+
"#{plan_owner.class.name}##{owner_id}"
|
|
129
120
|
end
|
|
121
|
+
private_class_method :owner_label
|
|
130
122
|
end
|
|
131
123
|
end
|
data/lib/pricing_plans/plan.rb
CHANGED
|
@@ -17,6 +17,7 @@ module PricingPlans
|
|
|
17
17
|
@price_string = nil
|
|
18
18
|
@stripe_price = nil
|
|
19
19
|
@features = Set.new
|
|
20
|
+
@grandfathers = {}
|
|
20
21
|
@limits = {}
|
|
21
22
|
@credits_included = nil
|
|
22
23
|
@meta = {}
|
|
@@ -181,6 +182,11 @@ module PricingPlans
|
|
|
181
182
|
# Feature methods
|
|
182
183
|
def allows(*feature_keys)
|
|
183
184
|
feature_keys.flatten.each do |key|
|
|
185
|
+
unless key.is_a?(Symbol) || key.is_a?(String)
|
|
186
|
+
raise ConfigurationError,
|
|
187
|
+
"`allows` takes feature names only (got #{key.inspect}). Plan quotas live in " \
|
|
188
|
+
"`limits`; per-owner capacities live on feature passes (`issue_feature_pass!(..., limits: {})`)."
|
|
189
|
+
end
|
|
184
190
|
@features.add(key.to_sym)
|
|
185
191
|
end
|
|
186
192
|
end
|
|
@@ -203,20 +209,71 @@ module PricingPlans
|
|
|
203
209
|
@features.include?(feature_key.to_sym)
|
|
204
210
|
end
|
|
205
211
|
|
|
212
|
+
# Grandfathering: declare that owners whose qualifying pricing
|
|
213
|
+
# relationship predates the cutoff keep a feature the plan no longer
|
|
214
|
+
# carries. Pure config — no database state, no backfill, no rake task.
|
|
215
|
+
# The pricing history stays readable in the initializer forever:
|
|
216
|
+
#
|
|
217
|
+
# plan :indie do
|
|
218
|
+
# allows :api_access # :distribution removed 2026-08-31
|
|
219
|
+
# grandfather :distribution, subscribed_before: "2026-09-01"
|
|
220
|
+
# end
|
|
221
|
+
#
|
|
222
|
+
# The cutoff accepts a Time, Date, or String; date-only values are read
|
|
223
|
+
# as midnight UTC. The relationship timestamp comes from the current
|
|
224
|
+
# assignment and/or same-plan subscription. Those records keep created_at
|
|
225
|
+
# across in-place plan/price changes, so this models a continuous pricing
|
|
226
|
+
# relationship rather than unavailable plan-change history. For an exact
|
|
227
|
+
# materialized cohort or a promise that must survive anything, use an
|
|
228
|
+
# explicit per-owner grant instead: `owner.grant_feature!(:distribution)`.
|
|
229
|
+
def grandfather(feature_key, subscribed_before:)
|
|
230
|
+
@grandfathers[feature_key.to_sym] = normalize_grandfather_cutoff(subscribed_before)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def grandfathered_features
|
|
234
|
+
@grandfathers.keys
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def grandfather_cutoff_for(feature_key)
|
|
238
|
+
@grandfathers[feature_key.to_sym]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def grandfathers_feature?(feature_key, relationship_started_at:)
|
|
242
|
+
cutoff = grandfather_cutoff_for(feature_key)
|
|
243
|
+
return false unless cutoff && relationship_started_at
|
|
244
|
+
|
|
245
|
+
relationship_started_at < cutoff
|
|
246
|
+
end
|
|
247
|
+
|
|
206
248
|
# Limit methods
|
|
207
249
|
def set_limit(key, **options)
|
|
208
250
|
limit_key = key.to_sym
|
|
209
|
-
|
|
251
|
+
after_limit = options.fetch(:after_limit, :block_usage)
|
|
252
|
+
|
|
253
|
+
# Grace only exists for :grace_then_block. Defaulting it onto every
|
|
254
|
+
# limit made `limit[:grace]` a lie for :block_usage/:just_warn limits
|
|
255
|
+
# (enforcement ignores it there), and downstream consumers reading the
|
|
256
|
+
# config naively would promise customers a grace window that does not
|
|
257
|
+
# exist. Explicit grace on a non-grace mode raises immediately, even if
|
|
258
|
+
# the caller supplied nil/false (truthiness must not erase intent).
|
|
259
|
+
if %i[block_usage just_warn].include?(after_limit) && options.key?(:grace)
|
|
260
|
+
raise ConfigurationError,
|
|
261
|
+
"Limit #{limit_key} cannot have grace with :#{after_limit} after_limit " \
|
|
262
|
+
"(grace only applies to :grace_then_block)"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
limit = {
|
|
210
266
|
key: limit_key,
|
|
211
267
|
to: options[:to],
|
|
212
268
|
per: options[:per],
|
|
213
|
-
after_limit:
|
|
214
|
-
grace: options.fetch(:grace, 7.days),
|
|
269
|
+
after_limit: after_limit,
|
|
270
|
+
grace: after_limit == :grace_then_block ? options.fetch(:grace, 7.days) : nil,
|
|
215
271
|
warn_at: options.fetch(:warn_at, [0.6, 0.8, 0.95]),
|
|
216
272
|
count_scope: options[:count_scope]
|
|
217
273
|
}
|
|
218
274
|
|
|
219
|
-
validate_limit_options!(
|
|
275
|
+
validate_limit_options!(limit)
|
|
276
|
+
@limits[limit_key] = limit
|
|
220
277
|
end
|
|
221
278
|
|
|
222
279
|
def limits(key=nil, **options)
|
|
@@ -509,9 +566,44 @@ module PricingPlans
|
|
|
509
566
|
def validate!
|
|
510
567
|
validate_limits!
|
|
511
568
|
validate_pricing!
|
|
569
|
+
validate_grandfathers!
|
|
512
570
|
end
|
|
513
571
|
|
|
514
572
|
private
|
|
573
|
+
|
|
574
|
+
def normalize_grandfather_cutoff(value)
|
|
575
|
+
cutoff =
|
|
576
|
+
if defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone)
|
|
577
|
+
value.utc.to_time
|
|
578
|
+
else
|
|
579
|
+
case value
|
|
580
|
+
when Time then value
|
|
581
|
+
when Date, String then value.to_time(:utc)
|
|
582
|
+
else
|
|
583
|
+
raise ConfigurationError,
|
|
584
|
+
"grandfather cutoff must be a Time, ActiveSupport::TimeWithZone, Date, or String " \
|
|
585
|
+
"(got #{value.class})"
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
raise ConfigurationError, "grandfather cutoff #{value.inspect} is not a parseable time" unless cutoff.is_a?(Time)
|
|
590
|
+
|
|
591
|
+
cutoff
|
|
592
|
+
rescue ArgumentError
|
|
593
|
+
raise ConfigurationError, "grandfather cutoff #{value.inspect} is not a parseable time"
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def validate_grandfathers!
|
|
597
|
+
contradiction = @grandfathers.keys & @features.to_a
|
|
598
|
+
return if contradiction.empty?
|
|
599
|
+
|
|
600
|
+
raise ConfigurationError,
|
|
601
|
+
"Plan #{key.inspect} grandfathers #{contradiction.inspect} but also allows " \
|
|
602
|
+
"them via `allows`. Grandfathering is for features the plan no longer " \
|
|
603
|
+
"carries — remove them from `allows` (everyone has them) or from " \
|
|
604
|
+
"`grandfather` (nobody needs the exception)."
|
|
605
|
+
end
|
|
606
|
+
|
|
515
607
|
def validate_limits!
|
|
516
608
|
@limits.each do |key, limit|
|
|
517
609
|
validate_limit_options!(limit)
|
|
@@ -530,9 +622,17 @@ module PricingPlans
|
|
|
530
622
|
raise ConfigurationError, "Limit #{limit[:key]} after_limit must be one of #{valid_after_limit.join(', ')}"
|
|
531
623
|
end
|
|
532
624
|
|
|
533
|
-
#
|
|
534
|
-
|
|
535
|
-
|
|
625
|
+
# Grace only applies to :grace_then_block; anywhere else it would be
|
|
626
|
+
# stored but never honored, which is worse than an error.
|
|
627
|
+
if limit[:grace] && limit[:after_limit] != :grace_then_block
|
|
628
|
+
raise ConfigurationError,
|
|
629
|
+
"Limit #{limit[:key]} cannot have grace with :#{limit[:after_limit]} after_limit " \
|
|
630
|
+
"(grace only applies to :grace_then_block)"
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
if limit[:after_limit] == :grace_then_block && !valid_grace_window?(limit[:grace])
|
|
634
|
+
raise ConfigurationError,
|
|
635
|
+
"Limit #{limit[:key]} grace must be a positive duration of at least one second"
|
|
536
636
|
end
|
|
537
637
|
|
|
538
638
|
# Validate warn_at thresholds
|
|
@@ -551,6 +651,12 @@ module PricingPlans
|
|
|
551
651
|
end
|
|
552
652
|
end
|
|
553
653
|
|
|
654
|
+
def valid_grace_window?(grace)
|
|
655
|
+
grace.is_a?(Numeric) && grace.finite? && grace.to_i.positive?
|
|
656
|
+
rescue TypeError, RangeError
|
|
657
|
+
false
|
|
658
|
+
end
|
|
659
|
+
|
|
554
660
|
def validate_pricing!
|
|
555
661
|
# `price` and `stripe_price` are not alternatives: the number is the local
|
|
556
662
|
# source of truth for display and plan comparison (resolved with no network
|
|
@@ -158,7 +158,7 @@ module PricingPlans
|
|
|
158
158
|
owners.join(enforcement_states)
|
|
159
159
|
.on(
|
|
160
160
|
enforcement_states[:plan_owner_id].eq(owners[:id])
|
|
161
|
-
.and(enforcement_states[:plan_owner_type].eq(
|
|
161
|
+
.and(enforcement_states[:plan_owner_type].eq(PlanOwnerIdentity.type_for(self)))
|
|
162
162
|
)
|
|
163
163
|
.join_sources
|
|
164
164
|
)
|
|
@@ -174,7 +174,7 @@ module PricingPlans
|
|
|
174
174
|
owners.join(enforcement_states, Arel::Nodes::OuterJoin)
|
|
175
175
|
.on(
|
|
176
176
|
enforcement_states[:plan_owner_id].eq(owners[:id])
|
|
177
|
-
.and(enforcement_states[:plan_owner_type].eq(
|
|
177
|
+
.and(enforcement_states[:plan_owner_type].eq(PlanOwnerIdentity.type_for(self)))
|
|
178
178
|
.and(enforcement_states[:exceeded_at].not_eq(nil))
|
|
179
179
|
)
|
|
180
180
|
.join_sources
|
|
@@ -217,7 +217,7 @@ module PricingPlans
|
|
|
217
217
|
|
|
218
218
|
def pricing_plan_overridden?
|
|
219
219
|
return false unless respond_to?(:id) && id.present?
|
|
220
|
-
Assignment.exists?(
|
|
220
|
+
Assignment.exists?(PlanOwnerIdentity.conditions_for(self))
|
|
221
221
|
end
|
|
222
222
|
|
|
223
223
|
def plan_assignment
|
|
@@ -226,7 +226,7 @@ module PricingPlans
|
|
|
226
226
|
|
|
227
227
|
def pricing_plan_override
|
|
228
228
|
return nil unless respond_to?(:id) && id.present?
|
|
229
|
-
Assignment.find_by(
|
|
229
|
+
Assignment.find_by(PlanOwnerIdentity.conditions_for(self))
|
|
230
230
|
end
|
|
231
231
|
|
|
232
232
|
def pricing_plan_override_source
|
|
@@ -257,10 +257,100 @@ module PricingPlans
|
|
|
257
257
|
)
|
|
258
258
|
end
|
|
259
259
|
|
|
260
|
-
# Features
|
|
260
|
+
# Features. True when the owner is entitled to the feature by ANY source:
|
|
261
|
+
# the resolved plan carries it, the plan grandfathers it and the owner's
|
|
262
|
+
# qualifying relationship predates the cutoff, or the owner holds an
|
|
263
|
+
# active per-owner grant. Every caller of plan_allows? (and the
|
|
264
|
+
# plan_allows_x? sugar)
|
|
265
|
+
# honors grandfathering and grants with no app changes.
|
|
261
266
|
def plan_allows?(feature_key)
|
|
262
|
-
|
|
263
|
-
|
|
267
|
+
feature_entitlement_source(feature_key).present?
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Why is (or isn't) this owner entitled to a feature?
|
|
271
|
+
# => :plan | :grandfather | :grant | nil
|
|
272
|
+
def feature_entitlement_source(feature_key)
|
|
273
|
+
resolution = current_pricing_plan_resolution
|
|
274
|
+
plan = resolution.plan
|
|
275
|
+
|
|
276
|
+
return :plan if plan&.allows_feature?(feature_key)
|
|
277
|
+
|
|
278
|
+
if plan&.grandfathers_feature?(
|
|
279
|
+
feature_key,
|
|
280
|
+
relationship_started_at: pricing_relationship_started_at(resolution)
|
|
281
|
+
)
|
|
282
|
+
return :grandfather
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
return :grant if FeatureGrant.active_for?(self, feature_key)
|
|
286
|
+
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# The earliest qualifying relationship timestamp carried by the current
|
|
291
|
+
# resolution. An assignment always belongs to its resolved plan. An
|
|
292
|
+
# underlying subscription only participates when its processor price maps
|
|
293
|
+
# to that same plan, preventing an old subscription on a different plan
|
|
294
|
+
# from manufacturing grandfather rights for a new override.
|
|
295
|
+
def pricing_relationship_started_at(resolution = current_pricing_plan_resolution)
|
|
296
|
+
timestamps = []
|
|
297
|
+
timestamps << resolution.assignment.created_at if resolution.assignment?
|
|
298
|
+
|
|
299
|
+
subscription = resolution.subscription
|
|
300
|
+
if subscription.respond_to?(:processor_plan) && subscription.respond_to?(:created_at)
|
|
301
|
+
subscription_plan = PlanResolver.plan_for_processor_plan(subscription.processor_plan)
|
|
302
|
+
timestamps << subscription.created_at if subscription_plan&.key == resolution.plan_key
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
timestamps.compact.min
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Per-owner feature grants: individual, auditable exceptions on top of
|
|
309
|
+
# plan resolution (comps, beta access, sales exceptions, promises that
|
|
310
|
+
# must survive cancellation). See PricingPlans::FeatureGrant.
|
|
311
|
+
def grant_feature!(feature_key, source: "manual", note: nil, expires_at: nil, **options)
|
|
312
|
+
FeatureGrant.grant_to!(self, feature_key, source: source, note: note, expires_at: expires_at, **options)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Create-only sales/support pass: never replace an existing customer promise.
|
|
316
|
+
def issue_feature_pass!(feature_key, source:, **options)
|
|
317
|
+
FeatureGrant.grant_to!(self, feature_key, source: source, **options, replace: false)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def feature_access(feature_key)
|
|
321
|
+
FeatureAccess.new(self, feature_key)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# The block and cumulative reservation share a transaction. Read live capacity
|
|
325
|
+
# in the usage callable, after locking; do not pass a precomputed count.
|
|
326
|
+
# All competing writers must use this API and the owner's database connection.
|
|
327
|
+
def with_feature_access!(feature_key, amount: 0, usage: -> { {} })
|
|
328
|
+
raise ArgumentError, "a block is required" unless block_given?
|
|
329
|
+
raise ArgumentError, "usage must be callable so it is read under the lock" unless usage.respond_to?(:call)
|
|
330
|
+
FeatureAccess.validate_amount!(amount)
|
|
331
|
+
FeatureGrant.with_owner_lock(self) do
|
|
332
|
+
access = feature_access(feature_key)
|
|
333
|
+
access.check!(amount: amount, usage: usage.call)
|
|
334
|
+
access.grant.__send__(:record_usage!, amount) if access.grant && amount.positive?
|
|
335
|
+
yield access
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Revokes active grants for the feature (audit rows are kept, stamped
|
|
340
|
+
# with revoked_at). Plan- and grandfather-sourced entitlements are
|
|
341
|
+
# config, not grants, and are not affected.
|
|
342
|
+
def revoke_feature!(feature_key, note: nil)
|
|
343
|
+
FeatureGrant.revoke_for!(self, feature_key, note: note)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def feature_granted?(feature_key)
|
|
347
|
+
FeatureGrant.active_for?(self, feature_key)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def feature_grants
|
|
351
|
+
return FeatureGrant.none unless FeatureGrant.table_ready?
|
|
352
|
+
|
|
353
|
+
FeatureGrant.where(PlanOwnerIdentity.conditions_for(self))
|
|
264
354
|
end
|
|
265
355
|
|
|
266
356
|
# Syntactic sugar for feature checks:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Builds the identity Rails stores for polymorphic plan-owner associations.
|
|
5
|
+
#
|
|
6
|
+
# Rails stores an Active Record model's polymorphic_name (normally its STI
|
|
7
|
+
# base class), not necessarily `record.class.name`. Keeping this in one place
|
|
8
|
+
# prevents reads and writes from disagreeing for STI subclasses and respects
|
|
9
|
+
# the host application's `store_full_class_name` configuration.
|
|
10
|
+
module PlanOwnerIdentity
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def type_for(plan_owner_or_class)
|
|
14
|
+
plan_owner_class = plan_owner_or_class.is_a?(Class) ? plan_owner_or_class : plan_owner_or_class.class
|
|
15
|
+
|
|
16
|
+
if plan_owner_class.respond_to?(:polymorphic_name)
|
|
17
|
+
plan_owner_class.polymorphic_name
|
|
18
|
+
else
|
|
19
|
+
plan_owner_class.name
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def conditions_for(plan_owner)
|
|
24
|
+
{
|
|
25
|
+
plan_owner_type: type_for(plan_owner),
|
|
26
|
+
plan_owner_id: plan_owner.respond_to?(:id) ? plan_owner.id : nil
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|