pricing_plans 0.5.0 → 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.
@@ -22,8 +22,7 @@ module PricingPlans
22
22
 
23
23
  def self.create_or_update_pricing_plan_override_for!(plan_owner, plan_key, source:)
24
24
  assignment = find_or_initialize_by(
25
- plan_owner_type: plan_owner.class.name,
26
- plan_owner_id: plan_owner.id
25
+ PlanOwnerIdentity.conditions_for(plan_owner)
27
26
  )
28
27
 
29
28
  assignment.assign_attributes(
@@ -37,8 +36,7 @@ module PricingPlans
37
36
 
38
37
  def self.clear_pricing_plan_override_for!(plan_owner)
39
38
  where(
40
- plan_owner_type: plan_owner.class.name,
41
- plan_owner_id: plan_owner.id
39
+ PlanOwnerIdentity.conditions_for(plan_owner)
42
40
  ).destroy_all
43
41
  end
44
42
 
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PricingPlans
4
+ # A per-owner feature entitlement that overrides plan resolution: comps,
5
+ # beta access, sales exceptions, support remediation, or a grandfather
6
+ # promise that must survive cancellation. Where the `grandfather` plan DSL
7
+ # is cohort-level policy (config, no state), a grant is an individual,
8
+ # auditable exception (a row, with a reason).
9
+ #
10
+ # Grants are never deleted by the API: revoking stamps `revoked_at` so the
11
+ # answer to "why does this customer have this?" survives the revocation.
12
+ class FeatureGrant < ActiveRecord::Base
13
+ self.table_name = "pricing_plans_feature_grants"
14
+
15
+ belongs_to :plan_owner, polymorphic: true
16
+
17
+ validates :plan_owner, presence: true
18
+ validates :feature_key, presence: true
19
+ validates :source, presence: true
20
+ validate :expires_at_must_be_parseable
21
+
22
+ scope :for_feature, ->(feature_key) { where(feature_key: feature_key.to_s) }
23
+ scope :active, lambda {
24
+ where(revoked_at: nil).where("expires_at IS NULL OR expires_at > ?", Time.current)
25
+ }
26
+
27
+ def active?
28
+ revoked_at.nil? && (expires_at.nil? || expires_at > Time.current)
29
+ end
30
+
31
+ def revoke!(note: nil)
32
+ return self if revoked_at.present?
33
+
34
+ update!(revoked_at: Time.current, note: [self.note, note].compact.presence&.join(" | "))
35
+ self
36
+ end
37
+
38
+ class << self
39
+ # Idempotent: updates the active grant for (owner, feature) if one
40
+ # exists, otherwise creates it. Revoked grants stay behind as history.
41
+ def grant_to!(plan_owner, feature_key, source: "manual", note: nil, expires_at: nil)
42
+ ensure_table!
43
+ ensure_persisted_owner!(plan_owner)
44
+
45
+ # Serialize grant writes through the owner row. A lookup followed by
46
+ # create is otherwise race-prone, and a portable partial unique index
47
+ # cannot express "unrevoked and unexpired" consistently on every
48
+ # supported database.
49
+ with_locked_owner(plan_owner) do
50
+ grant = active.find_by(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
51
+ if grant
52
+ grant.update!(source: source.to_s, note: note, expires_at: expires_at)
53
+ grant
54
+ else
55
+ create!(
56
+ plan_owner: plan_owner,
57
+ feature_key: feature_key.to_s,
58
+ source: source.to_s,
59
+ note: note,
60
+ expires_at: expires_at
61
+ )
62
+ end
63
+ end
64
+ end
65
+
66
+ def revoke_for!(plan_owner, feature_key, note: nil)
67
+ ensure_table!
68
+ ensure_persisted_owner!(plan_owner)
69
+
70
+ with_locked_owner(plan_owner) do
71
+ active
72
+ .where(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s))
73
+ .map { |grant| grant.revoke!(note: note) }
74
+ end
75
+ end
76
+
77
+ def active_for?(plan_owner, feature_key)
78
+ return false unless table_ready?
79
+
80
+ active.where(owner_conditions(plan_owner).merge(feature_key: feature_key.to_s)).exists?
81
+ end
82
+
83
+ # The grants table ships with fresh installs; apps that installed an
84
+ # earlier version add it with `rails generate pricing_plans:grants`.
85
+ # Plan-level checks (`allows` and `grandfather`) work without it, so
86
+ # its absence only disables per-owner grants — silently for reads,
87
+ # loudly for writes.
88
+ def table_ready?
89
+ table_exists?
90
+ rescue ActiveRecord::ActiveRecordError
91
+ false
92
+ end
93
+
94
+ private
95
+
96
+ def ensure_table!
97
+ return if table_ready?
98
+
99
+ raise ConfigurationError,
100
+ "The #{table_name} table does not exist. Run " \
101
+ "`rails generate pricing_plans:grants && rails db:migrate` to add it."
102
+ end
103
+
104
+ def owner_conditions(plan_owner)
105
+ PlanOwnerIdentity.conditions_for(plan_owner)
106
+ end
107
+
108
+ def ensure_persisted_owner!(plan_owner)
109
+ persisted_active_record = plan_owner.respond_to?(:persisted?) &&
110
+ plan_owner.persisted? &&
111
+ plan_owner.class.respond_to?(:base_class)
112
+ return if persisted_active_record
113
+
114
+ raise ArgumentError, "plan_owner must be a persisted Active Record"
115
+ end
116
+
117
+ def with_locked_owner(plan_owner)
118
+ owner_class = plan_owner.class.base_class
119
+
120
+ owner_class.transaction do
121
+ # Lock a fresh copy so granting a feature never reloads or rejects a
122
+ # caller that happens to have unrelated unsaved changes.
123
+ owner_class.unscoped.lock.find(plan_owner.id)
124
+ yield
125
+ end
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def expires_at_must_be_parseable
132
+ raw_value = expires_at_before_type_cast
133
+ return if raw_value.nil? || (raw_value.is_a?(String) && raw_value.blank?)
134
+ return if expires_at.respond_to?(:to_time)
135
+
136
+ errors.add(:expires_at, "must be a valid time")
137
+ end
138
+ end
139
+ end
@@ -10,6 +10,7 @@ module PricingPlans
10
10
  :overage,
11
11
  :grace_active,
12
12
  :grace_ends_at,
13
+ :grace_window,
13
14
  keyword_init: true
14
15
  )
15
16
 
@@ -37,7 +38,12 @@ module PricingPlans
37
38
  allowed: allowed,
38
39
  overage: over_by,
39
40
  grace_active: GraceManager.grace_active?(plan_owner, limit_key),
40
- grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key)
41
+ grace_ends_at: GraceManager.grace_ends_at(plan_owner, limit_key),
42
+ # The CONFIGURED window on the target plan (nil unless the limit
43
+ # is :grace_then_block, where alone grace is honored) — so
44
+ # downgrade/dunning UX can say "after a N-day grace window"
45
+ # without re-deriving enforcement rules.
46
+ grace_window: limit_config[:grace]
41
47
  )
42
48
  end.compact
43
49
  end
@@ -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
@@ -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,76 @@ 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)
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))
264
330
  end
265
331
 
266
332
  # Syntactic sugar for feature checks: