pricing_plans 0.4.1 → 0.6.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.
@@ -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 = {}
@@ -203,20 +204,71 @@ module PricingPlans
203
204
  @features.include?(feature_key.to_sym)
204
205
  end
205
206
 
207
+ # Grandfathering: declare that owners whose qualifying pricing
208
+ # relationship predates the cutoff keep a feature the plan no longer
209
+ # carries. Pure config — no database state, no backfill, no rake task.
210
+ # The pricing history stays readable in the initializer forever:
211
+ #
212
+ # plan :indie do
213
+ # allows :api_access # :distribution removed 2026-08-31
214
+ # grandfather :distribution, subscribed_before: "2026-09-01"
215
+ # end
216
+ #
217
+ # The cutoff accepts a Time, Date, or String; date-only values are read
218
+ # as midnight UTC. The relationship timestamp comes from the current
219
+ # assignment and/or same-plan subscription. Those records keep created_at
220
+ # across in-place plan/price changes, so this models a continuous pricing
221
+ # relationship rather than unavailable plan-change history. For an exact
222
+ # materialized cohort or a promise that must survive anything, use an
223
+ # explicit per-owner grant instead: `owner.grant_feature!(:distribution)`.
224
+ def grandfather(feature_key, subscribed_before:)
225
+ @grandfathers[feature_key.to_sym] = normalize_grandfather_cutoff(subscribed_before)
226
+ end
227
+
228
+ def grandfathered_features
229
+ @grandfathers.keys
230
+ end
231
+
232
+ def grandfather_cutoff_for(feature_key)
233
+ @grandfathers[feature_key.to_sym]
234
+ end
235
+
236
+ def grandfathers_feature?(feature_key, relationship_started_at:)
237
+ cutoff = grandfather_cutoff_for(feature_key)
238
+ return false unless cutoff && relationship_started_at
239
+
240
+ relationship_started_at < cutoff
241
+ end
242
+
206
243
  # Limit methods
207
244
  def set_limit(key, **options)
208
245
  limit_key = key.to_sym
209
- @limits[limit_key] = {
246
+ after_limit = options.fetch(:after_limit, :block_usage)
247
+
248
+ # Grace only exists for :grace_then_block. Defaulting it onto every
249
+ # limit made `limit[:grace]` a lie for :block_usage/:just_warn limits
250
+ # (enforcement ignores it there), and downstream consumers reading the
251
+ # config naively would promise customers a grace window that does not
252
+ # exist. Explicit grace on a non-grace mode raises immediately, even if
253
+ # the caller supplied nil/false (truthiness must not erase intent).
254
+ if %i[block_usage just_warn].include?(after_limit) && options.key?(:grace)
255
+ raise ConfigurationError,
256
+ "Limit #{limit_key} cannot have grace with :#{after_limit} after_limit " \
257
+ "(grace only applies to :grace_then_block)"
258
+ end
259
+
260
+ limit = {
210
261
  key: limit_key,
211
262
  to: options[:to],
212
263
  per: options[:per],
213
- after_limit: options.fetch(:after_limit, :block_usage),
214
- grace: options.fetch(:grace, 7.days),
264
+ after_limit: after_limit,
265
+ grace: after_limit == :grace_then_block ? options.fetch(:grace, 7.days) : nil,
215
266
  warn_at: options.fetch(:warn_at, [0.6, 0.8, 0.95]),
216
267
  count_scope: options[:count_scope]
217
268
  }
218
269
 
219
- validate_limit_options!(@limits[limit_key])
270
+ validate_limit_options!(limit)
271
+ @limits[limit_key] = limit
220
272
  end
221
273
 
222
274
  def limits(key=nil, **options)
@@ -509,9 +561,44 @@ module PricingPlans
509
561
  def validate!
510
562
  validate_limits!
511
563
  validate_pricing!
564
+ validate_grandfathers!
512
565
  end
513
566
 
514
567
  private
568
+
569
+ def normalize_grandfather_cutoff(value)
570
+ cutoff =
571
+ if defined?(ActiveSupport::TimeWithZone) && value.is_a?(ActiveSupport::TimeWithZone)
572
+ value.utc.to_time
573
+ else
574
+ case value
575
+ when Time then value
576
+ when Date, String then value.to_time(:utc)
577
+ else
578
+ raise ConfigurationError,
579
+ "grandfather cutoff must be a Time, ActiveSupport::TimeWithZone, Date, or String " \
580
+ "(got #{value.class})"
581
+ end
582
+ end
583
+
584
+ raise ConfigurationError, "grandfather cutoff #{value.inspect} is not a parseable time" unless cutoff.is_a?(Time)
585
+
586
+ cutoff
587
+ rescue ArgumentError
588
+ raise ConfigurationError, "grandfather cutoff #{value.inspect} is not a parseable time"
589
+ end
590
+
591
+ def validate_grandfathers!
592
+ contradiction = @grandfathers.keys & @features.to_a
593
+ return if contradiction.empty?
594
+
595
+ raise ConfigurationError,
596
+ "Plan #{key.inspect} grandfathers #{contradiction.inspect} but also allows " \
597
+ "them via `allows`. Grandfathering is for features the plan no longer " \
598
+ "carries — remove them from `allows` (everyone has them) or from " \
599
+ "`grandfather` (nobody needs the exception)."
600
+ end
601
+
515
602
  def validate_limits!
516
603
  @limits.each do |key, limit|
517
604
  validate_limit_options!(limit)
@@ -530,9 +617,17 @@ module PricingPlans
530
617
  raise ConfigurationError, "Limit #{limit[:key]} after_limit must be one of #{valid_after_limit.join(', ')}"
531
618
  end
532
619
 
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"
620
+ # Grace only applies to :grace_then_block; anywhere else it would be
621
+ # stored but never honored, which is worse than an error.
622
+ if limit[:grace] && limit[:after_limit] != :grace_then_block
623
+ raise ConfigurationError,
624
+ "Limit #{limit[:key]} cannot have grace with :#{limit[:after_limit]} after_limit " \
625
+ "(grace only applies to :grace_then_block)"
626
+ end
627
+
628
+ if limit[:after_limit] == :grace_then_block && !valid_grace_window?(limit[:grace])
629
+ raise ConfigurationError,
630
+ "Limit #{limit[:key]} grace must be a positive duration of at least one second"
536
631
  end
537
632
 
538
633
  # Validate warn_at thresholds
@@ -551,6 +646,12 @@ module PricingPlans
551
646
  end
552
647
  end
553
648
 
649
+ def valid_grace_window?(grace)
650
+ grace.is_a?(Numeric) && grace.finite? && grace.to_i.positive?
651
+ rescue TypeError, RangeError
652
+ false
653
+ end
654
+
554
655
  def validate_pricing!
555
656
  # `price` and `stripe_price` are not alternatives: the number is the local
556
657
  # 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
@@ -212,27 +212,121 @@ module PricingPlans
212
212
  end
213
213
 
214
214
  def has_plan_assignment?
215
+ pricing_plan_overridden?
216
+ end
217
+
218
+ def pricing_plan_overridden?
215
219
  return false unless respond_to?(:id) && id.present?
216
- Assignment.exists?(plan_owner_type: self.class.name, plan_owner_id: id)
220
+ Assignment.exists?(PlanOwnerIdentity.conditions_for(self))
217
221
  end
218
222
 
219
223
  def plan_assignment
224
+ pricing_plan_override
225
+ end
226
+
227
+ def pricing_plan_override
220
228
  return nil unless respond_to?(:id) && id.present?
221
- Assignment.find_by(plan_owner_type: self.class.name, plan_owner_id: id)
229
+ Assignment.find_by(PlanOwnerIdentity.conditions_for(self))
230
+ end
231
+
232
+ def pricing_plan_override_source
233
+ pricing_plan_override&.source
234
+ end
235
+
236
+ def override_pricing_plan!(plan_key, source:)
237
+ PlanResolver.override_pricing_plan_for!(self, plan_key, source: source)
238
+ end
239
+
240
+ def clear_pricing_plan_override!
241
+ PlanResolver.clear_pricing_plan_override_for!(self)
222
242
  end
223
243
 
224
244
  def assign_pricing_plan!(plan_key, source: "manual")
225
- Assignment.assign_plan_to(self, plan_key, source: source)
245
+ LegacyPlanAssignmentApi.create_or_update_override!(
246
+ self,
247
+ plan_key,
248
+ source: source,
249
+ called_method_name: "assign_pricing_plan!"
250
+ )
226
251
  end
227
252
 
228
253
  def remove_pricing_plan!
229
- Assignment.remove_assignment_for(self)
254
+ LegacyPlanAssignmentApi.clear_override!(
255
+ self,
256
+ called_method_name: "remove_pricing_plan!"
257
+ )
258
+ end
259
+
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.
266
+ def plan_allows?(feature_key)
267
+ feature_entitlement_source(feature_key).present?
230
268
  end
231
269
 
232
- # Features
233
- def plan_allows?(feature_key)
234
- plan = current_pricing_plan
235
- plan&.allows_feature?(feature_key) || false
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)
312
+ FeatureGrant.grant_to!(self, feature_key, source: source, note: note, expires_at: expires_at)
313
+ end
314
+
315
+ # Revokes active grants for the feature (audit rows are kept, stamped
316
+ # with revoked_at). Plan- and grandfather-sourced entitlements are
317
+ # config, not grants, and are not affected.
318
+ def revoke_feature!(feature_key, note: nil)
319
+ FeatureGrant.revoke_for!(self, feature_key, note: note)
320
+ end
321
+
322
+ def feature_granted?(feature_key)
323
+ FeatureGrant.active_for?(self, feature_key)
324
+ end
325
+
326
+ def feature_grants
327
+ return FeatureGrant.none unless FeatureGrant.table_ready?
328
+
329
+ FeatureGrant.where(PlanOwnerIdentity.conditions_for(self))
236
330
  end
237
331
 
238
332
  # 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
@@ -18,6 +18,10 @@ module PricingPlans
18
18
  source == :assignment
19
19
  end
20
20
 
21
+ def pricing_plan_overridden?
22
+ assignment?
23
+ end
24
+
21
25
  def subscription?
22
26
  source == :subscription
23
27
  end
@@ -34,12 +38,23 @@ module PricingPlans
34
38
  assignment&.source
35
39
  end
36
40
 
41
+ def pricing_plan_override
42
+ assignment
43
+ end
44
+
45
+ def pricing_plan_override_source
46
+ assignment_source
47
+ end
48
+
37
49
  # Extends Struct#to_h with derived fields.
38
50
  # Note: this preserves the raw plan / assignment / subscription objects.
39
51
  def to_h
40
52
  super.merge(
41
53
  plan_key: plan_key,
42
- assignment_source: assignment_source
54
+ assignment_source: assignment_source,
55
+ pricing_plan_overridden: pricing_plan_overridden?,
56
+ pricing_plan_override: pricing_plan_override,
57
+ pricing_plan_override_source: pricing_plan_override_source
43
58
  )
44
59
  end
45
60
  end