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.
- checksums.yaml +4 -4
- data/.rubocop.yml +4 -7
- data/.simplecov +6 -5
- data/Appraisals +5 -1
- data/CHANGELOG.md +19 -0
- data/README.md +48 -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 +98 -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 +32 -0
- data/lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb +22 -8
- data/lib/generators/pricing_plans/install/templates/initializer.rb +6 -0
- data/lib/pricing_plans/engine.rb +1 -0
- data/lib/pricing_plans/models/assignment.rb +2 -4
- data/lib/pricing_plans/models/feature_grant.rb +139 -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 +108 -7
- data/lib/pricing_plans/plan_owner.rb +73 -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 +3 -1
- metadata +10 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {}
|
|
@@ -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
|
-
|
|
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:
|
|
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!(
|
|
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
|
-
#
|
|
534
|
-
|
|
535
|
-
|
|
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(
|
|
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,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
|
-
|
|
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)
|
|
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:
|