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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -7
  3. data/.simplecov +6 -5
  4. data/Appraisals +5 -1
  5. data/CHANGELOG.md +34 -0
  6. data/README.md +64 -1
  7. data/docs/01-define-pricing-plans.md +6 -1
  8. data/docs/03-model-helpers.md +16 -0
  9. data/docs/06-gem-compatibility.md +35 -0
  10. data/docs/07-repricing.md +103 -0
  11. data/docs/08-feature-passes.md +192 -0
  12. data/gemfiles/rails_7.1.gemfile +26 -0
  13. data/gemfiles/rails_7.2.gemfile +3 -2
  14. data/gemfiles/rails_8.1.gemfile +2 -1
  15. data/lib/generators/pricing_plans/grants/grants_generator.rb +43 -0
  16. data/lib/generators/pricing_plans/grants/templates/create_pricing_plans_feature_grants.rb.erb +44 -0
  17. data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +29 -8
  18. data/lib/generators/pricing_plans/install/templates/initializer.rb +11 -0
  19. data/lib/generators/pricing_plans/passes/passes_generator.rb +31 -0
  20. data/lib/generators/pricing_plans/passes/templates/add_feature_pass_limits.rb.erb +19 -0
  21. data/lib/pricing_plans/engine.rb +1 -0
  22. data/lib/pricing_plans/feature_access.rb +110 -0
  23. data/lib/pricing_plans/models/assignment.rb +2 -4
  24. data/lib/pricing_plans/models/feature_grant.rb +212 -0
  25. data/lib/pricing_plans/overage_reporter.rb +7 -1
  26. data/lib/pricing_plans/pay_support.rb +92 -100
  27. data/lib/pricing_plans/plan.rb +113 -7
  28. data/lib/pricing_plans/plan_owner.rb +97 -7
  29. data/lib/pricing_plans/plan_owner_identity.rb +30 -0
  30. data/lib/pricing_plans/plan_resolver.rb +53 -37
  31. data/lib/pricing_plans/version.rb +1 -1
  32. data/lib/pricing_plans.rb +6 -1
  33. 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
- owner_id = plan_owner.respond_to?(:id) ? plan_owner.id : "N/A"
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
- if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions)
59
- log_debug "[PricingPlans::PaySupport] Checking plan_owner.subscriptions fallback"
60
- return subs.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?) }
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
- return nil unless pay_available?
30
+ current_subscriptions_for(plan_owner).first
31
+ end
69
32
 
70
- owner_id = plan_owner.respond_to?(:id) ? plan_owner.id : "N/A"
71
- log_debug "[PricingPlans::PaySupport] current_subscription_for called for #{plan_owner.class.name}##{owner_id}"
72
-
73
- # Prefer Pay's payment_processor API
74
- if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
75
- log_debug "[PricingPlans::PaySupport] payment_processor found: #{pp.class.name}##{pp.id}"
76
-
77
- # Check all subscriptions, not just the default-named one
78
- # Note: Don't call pp.subscription() without a name parameter, as it defaults to
79
- # looking for subscriptions named Pay.default_product_name (usually "default")
80
- if pp.respond_to?(:subscriptions)
81
- subs = pp.subscriptions
82
- log_debug "[PricingPlans::PaySupport] subscriptions relation: #{subs.class.name}, count: #{subs.count}"
83
-
84
- # Force array conversion to ensure we iterate properly
85
- subs_array = subs.respond_to?(:to_a) ? subs.to_a : subs
86
- log_debug "[PricingPlans::PaySupport] subscriptions array size: #{subs_array.size}"
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
- # Fallbacks for apps that surface subscriptions on the owner
103
- if plan_owner.respond_to?(:subscription)
104
- log_debug "[PricingPlans::PaySupport] Checking plan_owner.subscription fallback"
105
- subscription = plan_owner.subscription
106
- if subscription && (
107
- (subscription.respond_to?(:active?) && subscription.active?) ||
108
- (subscription.respond_to?(:on_trial?) && subscription.on_trial?) ||
109
- (subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?)
110
- )
111
- log_debug "[PricingPlans::PaySupport] current_subscription_for returning fallback subscription"
112
- return subscription
113
- end
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
- if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions)
117
- log_debug "[PricingPlans::PaySupport] Checking plan_owner.subscriptions fallback"
118
- found = subs.find do |sub|
119
- (sub.respond_to?(:on_trial?) && sub.on_trial?) ||
120
- (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) ||
121
- (sub.respond_to?(:active?) && sub.active?)
122
- end
123
- log_debug "[PricingPlans::PaySupport] current_subscription_for found in fallback: #{found ? "#{found.class.name}##{found.id}" : 'nil'}"
124
- return found if found
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
- log_debug "[PricingPlans::PaySupport] current_subscription_for returning nil"
128
- nil
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
@@ -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
- @limits[limit_key] = {
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: options.fetch(:after_limit, :block_usage),
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!(@limits[limit_key])
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
- # Validate grace only applies to blocking behaviors
534
- if limit[:grace] && limit[:after_limit] == :just_warn
535
- raise ConfigurationError, "Limit #{limit[:key]} cannot have grace with :just_warn after_limit"
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(name))
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(name))
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?(plan_owner_type: self.class.name, plan_owner_id: id)
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(plan_owner_type: self.class.name, plan_owner_id: id)
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
- plan = current_pricing_plan
263
- plan&.allows_feature?(feature_key) || false
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