pricing_plans 0.3.1 → 0.4.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/CHANGELOG.md +13 -0
- data/README.md +5 -0
- data/context7.json +4 -0
- data/docs/03-model-helpers.md +24 -1
- data/lib/pricing_plans/callbacks.rb +9 -2
- data/lib/pricing_plans/exceeded_state_utils.rb +61 -0
- data/lib/pricing_plans/grace_manager.rb +30 -5
- data/lib/pricing_plans/pay_support.rb +4 -2
- data/lib/pricing_plans/plan_owner.rb +8 -0
- data/lib/pricing_plans/plan_resolution.rb +46 -0
- data/lib/pricing_plans/plan_resolver.rb +64 -63
- data/lib/pricing_plans/status_context.rb +59 -5
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fee743f485f98161652b725ca25882ccdd0066500b4612d8df6cf023d22552d8
|
|
4
|
+
data.tar.gz: 61654ab323c68cde37eb9a258dc8ac3636d43e7abcae6407484a38b986342333
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88408758acf45aabfb9281281b31d069aeb1db85cc3bccb96f31768f2f035fc6a5e27fab9405f15525e7d521fe32381a53adbf9a8802c8cad8dc002c8f21dc3b
|
|
7
|
+
data.tar.gz: ae1ddb7ace1f67c1d5ac9213c69fe3b14a52944f8b4beb90b616463e90b8377de1d2a135920841f96a81cc1bbeb097144ca535afea6f5aaff33db9b185fdc890
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## [0.4.0] - 2026-03-19
|
|
2
|
+
|
|
3
|
+
- **Add plan provenance helpers**: `current_pricing_plan_resolution`, `current_pricing_plan_source`, and `PlanResolver.resolution_for(plan_owner)` now expose whether the effective plan comes from a manual assignment, a Pay subscription, or the default plan
|
|
4
|
+
- **Preserve underlying billing context**: resolution objects include the current subscription when present, even when a manual assignment overrides it for entitlements
|
|
5
|
+
- **Clarify effective plan vs billing state**: docs now explicitly distinguish the effective pricing plan from Pay/Stripe subscription status
|
|
6
|
+
|
|
7
|
+
## [0.3.2] - 2026-02-25
|
|
8
|
+
|
|
9
|
+
- **Fix stale grace warnings after plan upgrades**: Grace/blocked flags now auto-clear when usage drops below limit (self-healing state)
|
|
10
|
+
- **Fix grace triggering at exact limit**: `grace_then_block` now uses `>` (over limit) not `>=` (at limit)
|
|
11
|
+
- **Add lazy grace creation**: Grace starts on-demand when checking status, even if callbacks were bypassed
|
|
12
|
+
- **Add `ExceededStateUtils` module**: DRY extraction for shared exceeded/blocked logic
|
|
13
|
+
|
|
1
14
|
## [0.3.1] - 2026-02-16
|
|
2
15
|
|
|
3
16
|
- **Add `has_plan_assignment?` helper**: Check if a plan owner has a manual assignment without full plan resolution
|
data/README.md
CHANGED
|
@@ -165,6 +165,7 @@ The `pricing_plans` gem needs three new models in the schema in order to work: `
|
|
|
165
165
|
- `PricingPlans::Assignment` allow manual plan overrides independent of billing system (or before you wire up Stripe/Pay). Great for admin toggles, trials, demos.
|
|
166
166
|
- What: The arbitrary `plan_key` and a `source` label (default "manual"). Unique per plan_owner.
|
|
167
167
|
- How it's used: `PlanResolver` checks manual assignment → Pay → default plan. Manual assignments (admin overrides) take precedence over subscription-based plans. You can call `assign_pricing_plan!` and `remove_pricing_plan!` on the plan_owner.
|
|
168
|
+
- Provenance helpers: `current_pricing_plan_source` tells you whether the effective plan came from `:assignment`, `:subscription`, or `:default`, and `current_pricing_plan_resolution` exposes the assignment and current subscription objects when you need both entitlement and billing context.
|
|
168
169
|
|
|
169
170
|
- `PricingPlans::EnforcementState` tracks per-plan_owner per-limit enforcement state for persistent caps and per-period allowances (grace/warnings/block state) in a race-safe way.
|
|
170
171
|
- What: `exceeded_at`, `blocked_at`, last warning info, and a small JSON `data` column where we persist plan-derived parameters like grace period seconds.
|
|
@@ -180,10 +181,14 @@ Enforcing pricing plans is one of those boring plumbing problems that look easy
|
|
|
180
181
|
|
|
181
182
|
- Safe under load: we use row locks and retries when setting grace/blocked/warning state, and we avoid firing the same event twice. See [grace_manager.rb](lib/pricing_plans/grace_manager.rb).
|
|
182
183
|
|
|
184
|
+
- Self-healing state: when usage drops below the limit (e.g., user deletes resources, upgrades plan, or reduces usage), stale exceeded/blocked flags are automatically cleared. Methods like `grace_active?` and `should_block?` will clear outdated enforcement state as a side effect. This prevents users from remaining incorrectly flagged after remediation.
|
|
185
|
+
|
|
183
186
|
- Accurate counting: persistent limits count live current rows (using `COUNT(*)`, make sure to index your foreign keys to make it fast at scale); per‑period limits record usage for the current window only. You can filter what counts with `count_scope` (Symbol/Hash/Proc/Array), and plan settings override model defaults. See [limitable.rb](lib/pricing_plans/limitable.rb) and [limit_checker.rb](lib/pricing_plans/limit_checker.rb).
|
|
184
187
|
|
|
185
188
|
- Clear rules: default is to block when you hit the cap; grace periods are opt‑in. In status/UI, 0 of 0 isn’t shown as blocked. See [plan.rb](lib/pricing_plans/plan.rb), [grace_manager.rb](lib/pricing_plans/grace_manager.rb), and [view_helpers.rb](lib/pricing_plans/view_helpers.rb).
|
|
186
189
|
|
|
190
|
+
- Semantic enforcement: for `grace_then_block`, grace periods start when usage goes *over* the limit (e.g., 6/5), not when it *reaches* the limit (5/5). This allows users to use their full allocation before grace begins. For `block_usage`, blocking occurs at or over the limit (e.g., at 5/5, the next creation is blocked).
|
|
191
|
+
|
|
187
192
|
- Simple controllers: one‑liners to guard actions, predictable redirect order (per‑call → per‑controller → global → pricing_path), and an optional central handler. See [controller_guards.rb](lib/pricing_plans/controller_guards.rb).
|
|
188
193
|
|
|
189
194
|
- Billing‑aware periods: supports billing cycle (when Pay is present), calendar month/week/day, custom time windows, and durations. See [period_calculator.rb](lib/pricing_plans/period_calculator.rb).
|
data/context7.json
ADDED
data/docs/03-model-helpers.md
CHANGED
|
@@ -295,10 +295,32 @@ You can also use the top-level equivalents if you prefer: `PricingPlans.severity
|
|
|
295
295
|
You can also check and override the current pricing plan for any user, which comes handy as an admin:
|
|
296
296
|
```ruby
|
|
297
297
|
user.current_pricing_plan # => PricingPlans::Plan
|
|
298
|
+
user.current_pricing_plan_source # => :assignment, :subscription, :default
|
|
299
|
+
user.current_pricing_plan_resolution # => PricingPlans::PlanResolution
|
|
298
300
|
user.assign_pricing_plan!(:pro) # manual assignment override
|
|
299
301
|
user.remove_pricing_plan! # remove manual override (fallback to default)
|
|
300
302
|
```
|
|
301
303
|
|
|
304
|
+
**Performance note:** Each call to `current_pricing_plan`, `current_pricing_plan_source`, or `current_pricing_plan_resolution` performs a fresh database lookup. If you need both the plan and its provenance, call `current_pricing_plan_resolution` once and read both values from that object — this avoids duplicate queries.
|
|
305
|
+
|
|
306
|
+
If you need the full provenance, use the resolution object:
|
|
307
|
+
|
|
308
|
+
```ruby
|
|
309
|
+
resolution = user.current_pricing_plan_resolution
|
|
310
|
+
|
|
311
|
+
resolution.plan.key # => :enterprise
|
|
312
|
+
resolution.source # => :assignment
|
|
313
|
+
resolution.assignment # => PricingPlans::Assignment | nil
|
|
314
|
+
resolution.assignment_source # => "admin" | "manual" | nil
|
|
315
|
+
resolution.subscription # => Pay subscription | nil
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
This distinction matters: the **effective pricing plan** is what controls entitlements and limits inside your app. The **Pay/Stripe subscription state** is billing-facing. A manual assignment may intentionally override the subscription-backed plan while still leaving the underlying subscription present for billing operations.
|
|
319
|
+
|
|
320
|
+
**Edge case:** `source` can be `:default` even when `subscription` is non-nil. This happens when a Pay subscription exists but its `processor_plan` (Stripe price ID) doesn't map to any plan in your registry. The subscription is preserved for billing context, but the effective plan falls back to your configured default.
|
|
321
|
+
|
|
322
|
+
`resolution.to_h` is handy for inspection and tests, but it preserves the raw `plan`, `assignment`, and `subscription` objects. If you need a JSON-safe payload, build one explicitly from the scalar fields you care about.
|
|
323
|
+
|
|
302
324
|
### Misc
|
|
303
325
|
|
|
304
326
|
```ruby
|
|
@@ -311,7 +333,8 @@ And finally, you get very thin convenient wrappers if you're using the `pay` gem
|
|
|
311
333
|
```ruby
|
|
312
334
|
# Pay (Stripe) convenience (returns false/nil when Pay is absent)
|
|
313
335
|
# Note: this is billing-facing state, distinct from our in-app
|
|
314
|
-
# enforcement grace which is tracked per-limit
|
|
336
|
+
# enforcement grace which is tracked per-limit, and distinct from
|
|
337
|
+
# the effective plan resolved by current_pricing_plan.
|
|
315
338
|
user.pay_subscription_active? # => true/false
|
|
316
339
|
user.pay_on_trial? # => true/false
|
|
317
340
|
user.pay_on_grace_period? # => true/false
|
|
@@ -106,17 +106,24 @@ module PricingPlans
|
|
|
106
106
|
return if limit_config[:to] == :unlimited
|
|
107
107
|
|
|
108
108
|
limit_amount = limit_config[:to].to_i
|
|
109
|
-
|
|
109
|
+
after_limit = limit_config[:after_limit]
|
|
110
110
|
|
|
111
|
-
case
|
|
111
|
+
case after_limit
|
|
112
112
|
when :just_warn
|
|
113
|
+
return unless current_usage >= limit_amount
|
|
114
|
+
|
|
113
115
|
# Just emit warning, don't track grace/block
|
|
114
116
|
check_and_emit_warnings!(plan_owner, limit_key, current_usage, limit_amount)
|
|
115
117
|
when :block_usage
|
|
118
|
+
return unless current_usage >= limit_amount
|
|
119
|
+
|
|
116
120
|
# Do NOT mark as blocked here - this callback runs after SUCCESSFUL creation.
|
|
117
121
|
# Block events are emitted from validation when creation is actually blocked.
|
|
118
122
|
nil
|
|
119
123
|
when :grace_then_block
|
|
124
|
+
# Grace semantics are for over-limit usage, not exact-at-limit.
|
|
125
|
+
return unless current_usage > limit_amount
|
|
126
|
+
|
|
120
127
|
# Start grace period if not already in grace/blocked
|
|
121
128
|
unless GraceManager.grace_active?(plan_owner, limit_key) || GraceManager.should_block?(plan_owner, limit_key)
|
|
122
129
|
GraceManager.mark_exceeded!(plan_owner, limit_key, grace_period: limit_config[:grace])
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Shared utilities for checking and managing exceeded state.
|
|
5
|
+
#
|
|
6
|
+
# This module provides common logic for determining whether usage has exceeded
|
|
7
|
+
# limits and for clearing stale exceeded flags. It is included by both
|
|
8
|
+
# GraceManager (class methods) and StatusContext (instance methods) to ensure
|
|
9
|
+
# consistent behavior.
|
|
10
|
+
#
|
|
11
|
+
# NOTE: Methods that modify state (`clear_exceeded_flags!`) are intentionally
|
|
12
|
+
# included here. The design decision is that grace/block checks should be
|
|
13
|
+
# "self-healing" - if usage drops below the limit, stale exceeded flags are
|
|
14
|
+
# automatically cleared. This prevents situations where users remain incorrectly
|
|
15
|
+
# flagged as exceeded after deleting resources or after plan upgrades.
|
|
16
|
+
module ExceededStateUtils
|
|
17
|
+
# Determine if usage has exceeded the limit based on the after_limit policy.
|
|
18
|
+
#
|
|
19
|
+
# For :grace_then_block, exceeded means strictly OVER the limit (>).
|
|
20
|
+
# For :block_usage and :just_warn, exceeded means AT or over the limit (>=).
|
|
21
|
+
#
|
|
22
|
+
# This distinction exists because:
|
|
23
|
+
# - :block_usage blocks creation of the Nth item (at limit = blocked)
|
|
24
|
+
# - :grace_then_block allows the Nth item, only starting grace when OVER
|
|
25
|
+
#
|
|
26
|
+
# @param current_usage [Integer] Current usage count
|
|
27
|
+
# @param limit_amount [Integer, Symbol] The configured limit (may be :unlimited)
|
|
28
|
+
# @param after_limit [Symbol] The enforcement policy (:block_usage, :grace_then_block, :just_warn)
|
|
29
|
+
# @return [Boolean] true if usage is considered exceeded for this policy
|
|
30
|
+
def exceeded_now?(current_usage, limit_amount, after_limit:)
|
|
31
|
+
# 0-of-0 is a special case: not considered exceeded for UX purposes
|
|
32
|
+
return false if limit_amount.to_i.zero? && current_usage.to_i.zero?
|
|
33
|
+
|
|
34
|
+
if after_limit == :grace_then_block
|
|
35
|
+
current_usage > limit_amount.to_i
|
|
36
|
+
else
|
|
37
|
+
current_usage >= limit_amount.to_i
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Clear exceeded and blocked flags from an enforcement state record.
|
|
42
|
+
#
|
|
43
|
+
# This is called when usage drops below the limit to "heal" stale state.
|
|
44
|
+
# Uses update_columns for efficiency (skips validations/callbacks).
|
|
45
|
+
#
|
|
46
|
+
# @param state [EnforcementState] The state record to clear
|
|
47
|
+
# @return [EnforcementState, nil] The updated state, or nil if no updates needed
|
|
48
|
+
def clear_exceeded_flags!(state)
|
|
49
|
+
return unless state
|
|
50
|
+
|
|
51
|
+
updates = {}
|
|
52
|
+
updates[:exceeded_at] = nil if state.exceeded_at.present?
|
|
53
|
+
updates[:blocked_at] = nil if state.blocked_at.present?
|
|
54
|
+
return state if updates.empty?
|
|
55
|
+
|
|
56
|
+
updates[:updated_at] = Time.current
|
|
57
|
+
state.update_columns(updates)
|
|
58
|
+
state
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
module PricingPlans
|
|
4
4
|
class GraceManager
|
|
5
5
|
class << self
|
|
6
|
+
include ExceededStateUtils
|
|
6
7
|
def mark_exceeded!(plan_owner, limit_key, grace_period: nil)
|
|
7
8
|
with_lock(plan_owner, limit_key) do |state|
|
|
8
9
|
# Ensure state is for the current window for per-period limits
|
|
@@ -36,6 +37,14 @@ module PricingPlans
|
|
|
36
37
|
def grace_active?(plan_owner, limit_key)
|
|
37
38
|
state = fresh_state_or_nil(plan_owner, limit_key)
|
|
38
39
|
return false unless state&.exceeded?
|
|
40
|
+
|
|
41
|
+
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
42
|
+
limit_config = plan&.limit_for(limit_key)
|
|
43
|
+
unless currently_exceeded?(plan_owner, limit_key, limit_config)
|
|
44
|
+
clear_exceeded_flags!(state)
|
|
45
|
+
return false
|
|
46
|
+
end
|
|
47
|
+
|
|
39
48
|
!state.grace_expired?
|
|
40
49
|
end
|
|
41
50
|
|
|
@@ -51,11 +60,16 @@ module PricingPlans
|
|
|
51
60
|
limit_amount = limit_config[:to]
|
|
52
61
|
return false if limit_amount == :unlimited
|
|
53
62
|
current_usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config)
|
|
54
|
-
exceeded = current_usage
|
|
55
|
-
# Treat 0-of-0 as not blocked for UX/severity/status purposes
|
|
56
|
-
exceeded = false if limit_amount.to_i.zero? && current_usage.to_i.zero?
|
|
63
|
+
exceeded = exceeded_now?(current_usage, limit_amount, after_limit: after_limit)
|
|
57
64
|
|
|
58
|
-
|
|
65
|
+
unless exceeded
|
|
66
|
+
if (state = fresh_state_or_nil(plan_owner, limit_key))
|
|
67
|
+
clear_exceeded_flags!(state)
|
|
68
|
+
end
|
|
69
|
+
return false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
return true if after_limit == :block_usage
|
|
59
73
|
|
|
60
74
|
# For :grace_then_block, check if grace period expired
|
|
61
75
|
state = fresh_state_or_nil(plan_owner, limit_key)
|
|
@@ -119,7 +133,7 @@ module PricingPlans
|
|
|
119
133
|
end
|
|
120
134
|
|
|
121
135
|
def grace_ends_at(plan_owner, limit_key)
|
|
122
|
-
state =
|
|
136
|
+
state = fresh_state_or_nil(plan_owner, limit_key)
|
|
123
137
|
state&.grace_ends_at
|
|
124
138
|
end
|
|
125
139
|
|
|
@@ -158,6 +172,17 @@ module PricingPlans
|
|
|
158
172
|
EnforcementState.find_by(plan_owner: plan_owner, limit_key: limit_key.to_s)
|
|
159
173
|
end
|
|
160
174
|
|
|
175
|
+
def currently_exceeded?(plan_owner, limit_key, limit_config = nil)
|
|
176
|
+
limit_config ||= PlanResolver.effective_plan_for(plan_owner)&.limit_for(limit_key)
|
|
177
|
+
return false unless limit_config
|
|
178
|
+
|
|
179
|
+
limit_amount = limit_config[:to]
|
|
180
|
+
return false if limit_amount == :unlimited
|
|
181
|
+
|
|
182
|
+
current_usage = LimitChecker.current_usage_for(plan_owner, limit_key, limit_config)
|
|
183
|
+
exceeded_now?(current_usage, limit_amount, after_limit: limit_config[:after_limit])
|
|
184
|
+
end
|
|
185
|
+
|
|
161
186
|
# Returns nil if state is stale for the current period window for per-period limits
|
|
162
187
|
def fresh_state_or_nil(plan_owner, limit_key)
|
|
163
188
|
state = find_state(plan_owner, limit_key)
|
|
@@ -15,7 +15,8 @@ module PricingPlans
|
|
|
15
15
|
def subscription_active_for?(plan_owner)
|
|
16
16
|
return false unless plan_owner
|
|
17
17
|
|
|
18
|
-
|
|
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}"
|
|
19
20
|
|
|
20
21
|
# Prefer Pay's official API on the payment_processor
|
|
21
22
|
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
@@ -66,7 +67,8 @@ module PricingPlans
|
|
|
66
67
|
def current_subscription_for(plan_owner)
|
|
67
68
|
return nil unless pay_available?
|
|
68
69
|
|
|
69
|
-
|
|
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}"
|
|
70
72
|
|
|
71
73
|
# Prefer Pay's payment_processor API
|
|
72
74
|
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
@@ -198,6 +198,14 @@ module PricingPlans
|
|
|
198
198
|
PlanResolver.effective_plan_for(self)
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
def current_pricing_plan_resolution
|
|
202
|
+
PlanResolver.resolution_for(self)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def current_pricing_plan_source
|
|
206
|
+
current_pricing_plan_resolution.source
|
|
207
|
+
end
|
|
208
|
+
|
|
201
209
|
def on_free_plan?
|
|
202
210
|
plan = current_pricing_plan || PricingPlans::Registry.default_plan
|
|
203
211
|
plan&.free? || false
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
class PlanResolution < Struct.new(:plan, :source, :assignment, :subscription, keyword_init: true)
|
|
5
|
+
SOURCES = [:assignment, :subscription, :default].freeze
|
|
6
|
+
|
|
7
|
+
def initialize(**attributes)
|
|
8
|
+
super
|
|
9
|
+
|
|
10
|
+
unless SOURCES.include?(source)
|
|
11
|
+
raise ArgumentError, "Invalid source: #{source.inspect}. Must be one of: #{SOURCES.inspect}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def assignment?
|
|
18
|
+
source == :assignment
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def subscription?
|
|
22
|
+
source == :subscription
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default?
|
|
26
|
+
source == :default
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def plan_key
|
|
30
|
+
plan&.key
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def assignment_source
|
|
34
|
+
assignment&.source
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Extends Struct#to_h with derived fields.
|
|
38
|
+
# Note: this preserves the raw plan / assignment / subscription objects.
|
|
39
|
+
def to_h
|
|
40
|
+
super.merge(
|
|
41
|
+
plan_key: plan_key,
|
|
42
|
+
assignment_source: assignment_source
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -8,43 +8,52 @@ module PricingPlans
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def effective_plan_for(plan_owner)
|
|
11
|
-
|
|
11
|
+
resolution_for(plan_owner).plan
|
|
12
|
+
end
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
def plan_key_for(plan_owner)
|
|
15
|
+
resolution_for(plan_owner).plan_key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolution_for(plan_owner)
|
|
19
|
+
log_debug "[PricingPlans::PlanResolver] resolution_for called for #{plan_owner.class.name}##{plan_owner.respond_to?(:id) ? plan_owner.id : 'N/A'}"
|
|
20
|
+
|
|
21
|
+
assignment = assignment_for(plan_owner)
|
|
22
|
+
subscription = current_subscription_for(plan_owner)
|
|
23
|
+
|
|
24
|
+
if assignment
|
|
25
|
+
log_debug "[PricingPlans::PlanResolver] Returning assignment-backed resolution: #{assignment.plan_key}"
|
|
26
|
+
return PlanResolution.new(
|
|
27
|
+
plan: Registry.plan(assignment.plan_key),
|
|
28
|
+
source: :assignment,
|
|
29
|
+
assignment: assignment,
|
|
30
|
+
subscription: subscription
|
|
19
31
|
)
|
|
20
|
-
if assignment
|
|
21
|
-
log_debug "[PricingPlans::PlanResolver] Found manual assignment: #{assignment.plan_key}"
|
|
22
|
-
return Registry.plan(assignment.plan_key)
|
|
23
|
-
else
|
|
24
|
-
log_debug "[PricingPlans::PlanResolver] No manual assignment found"
|
|
25
|
-
end
|
|
26
32
|
end
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
if subscription
|
|
35
|
+
processor_plan = subscription.processor_plan
|
|
36
|
+
log_debug "[PricingPlans::PlanResolver] resolution_for subscription processor_plan = #{processor_plan.inspect}"
|
|
37
|
+
|
|
38
|
+
if processor_plan && (plan = plan_from_processor_plan(processor_plan))
|
|
39
|
+
log_debug "[PricingPlans::PlanResolver] Returning subscription-backed resolution: #{plan.key}"
|
|
40
|
+
return PlanResolution.new(
|
|
41
|
+
plan: plan,
|
|
42
|
+
source: :subscription,
|
|
43
|
+
assignment: nil,
|
|
44
|
+
subscription: subscription
|
|
45
|
+
)
|
|
46
|
+
end
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
# 3. Fall back to default plan
|
|
41
49
|
default = Registry.default_plan
|
|
42
|
-
log_debug "[PricingPlans::PlanResolver] Returning default
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
log_debug "[PricingPlans::PlanResolver] Returning default-backed resolution: #{default ? default.key : 'nil'}"
|
|
51
|
+
PlanResolution.new(
|
|
52
|
+
plan: default,
|
|
53
|
+
source: :default,
|
|
54
|
+
assignment: nil,
|
|
55
|
+
subscription: subscription
|
|
56
|
+
)
|
|
48
57
|
end
|
|
49
58
|
|
|
50
59
|
def assign_plan_manually!(plan_owner, plan_key, source: "manual")
|
|
@@ -62,46 +71,38 @@ module PricingPlans
|
|
|
62
71
|
PaySupport.pay_available?
|
|
63
72
|
end
|
|
64
73
|
|
|
65
|
-
def
|
|
66
|
-
log_debug "[PricingPlans::PlanResolver]
|
|
67
|
-
|
|
68
|
-
# Check if plan_owner has payment_processor (preferred) or Pay methods directly (fallback)
|
|
69
|
-
has_payment_processor = plan_owner.respond_to?(:payment_processor)
|
|
70
|
-
has_pay_methods = plan_owner.respond_to?(:subscribed?) ||
|
|
71
|
-
plan_owner.respond_to?(:on_trial?) ||
|
|
72
|
-
plan_owner.respond_to?(:on_grace_period?) ||
|
|
73
|
-
plan_owner.respond_to?(:subscriptions)
|
|
74
|
+
def assignment_for(plan_owner)
|
|
75
|
+
log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
|
|
76
|
+
return nil unless plan_owner.respond_to?(:id)
|
|
74
77
|
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
assignment = Assignment.find_by(
|
|
79
|
+
plan_owner_type: plan_owner.class.name,
|
|
80
|
+
plan_owner_id: plan_owner.id
|
|
81
|
+
)
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
83
|
+
if assignment
|
|
84
|
+
log_debug "[PricingPlans::PlanResolver] Found manual assignment: #{assignment.plan_key}"
|
|
85
|
+
else
|
|
86
|
+
log_debug "[PricingPlans::PlanResolver] No manual assignment found"
|
|
87
|
+
end
|
|
80
88
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
is_active = PaySupport.subscription_active_for?(plan_owner)
|
|
84
|
-
log_debug "[PricingPlans::PlanResolver] subscription_active_for? returned: #{is_active}"
|
|
89
|
+
assignment
|
|
90
|
+
end
|
|
85
91
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
subscription = PaySupport.current_subscription_for(plan_owner)
|
|
89
|
-
log_debug "[PricingPlans::PlanResolver] current_subscription_for returned: #{subscription ? subscription.class.name : 'nil'}"
|
|
90
|
-
return nil unless subscription
|
|
92
|
+
def current_subscription_for(plan_owner)
|
|
93
|
+
return nil unless plan_owner
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
log_debug "[PricingPlans::PlanResolver] subscription.processor_plan = #{processor_plan.inspect}"
|
|
95
|
+
pay_available = pay_available?
|
|
96
|
+
log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
matched_plan = plan_from_processor_plan(processor_plan)
|
|
98
|
-
log_debug "[PricingPlans::PlanResolver] plan_from_processor_plan returned: #{matched_plan ? matched_plan.key : 'nil'}"
|
|
99
|
-
return matched_plan
|
|
100
|
-
end
|
|
101
|
-
end
|
|
98
|
+
return nil unless pay_available
|
|
102
99
|
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
# This intentionally delegates the active/trial/grace filtering contract
|
|
101
|
+
# to PaySupport.current_subscription_for so resolution_for can preserve
|
|
102
|
+
# the same billing context wherever it is called.
|
|
103
|
+
subscription = PaySupport.current_subscription_for(plan_owner)
|
|
104
|
+
log_debug "[PricingPlans::PlanResolver] current_subscription_for returned: #{subscription ? subscription.class.name : 'nil'}"
|
|
105
|
+
subscription
|
|
105
106
|
end
|
|
106
107
|
|
|
107
108
|
def plan_from_processor_plan(processor_plan)
|
|
@@ -7,6 +7,8 @@ module PricingPlans
|
|
|
7
7
|
#
|
|
8
8
|
# Thread-safe by design: each call to status() gets its own context instance.
|
|
9
9
|
class StatusContext
|
|
10
|
+
include ExceededStateUtils
|
|
11
|
+
|
|
10
12
|
attr_reader :plan_owner
|
|
11
13
|
|
|
12
14
|
def initialize(plan_owner)
|
|
@@ -67,7 +69,26 @@ module PricingPlans
|
|
|
67
69
|
return @grace_active_cache[key] if @grace_active_cache.key?(key)
|
|
68
70
|
|
|
69
71
|
state = fresh_enforcement_state(limit_key)
|
|
70
|
-
|
|
72
|
+
|
|
73
|
+
# If no state exists but we're over limit for grace_then_block, lazily create grace
|
|
74
|
+
unless state&.exceeded?
|
|
75
|
+
if should_lazily_start_grace?(limit_key)
|
|
76
|
+
limit_config = limit_config_for(limit_key)
|
|
77
|
+
GraceManager.mark_exceeded!(@plan_owner, limit_key, grace_period: limit_config[:grace])
|
|
78
|
+
# Clear caches to get fresh state
|
|
79
|
+
@fresh_enforcement_state_cache&.delete(key)
|
|
80
|
+
@enforcement_state_cache&.delete(key)
|
|
81
|
+
state = fresh_enforcement_state(limit_key)
|
|
82
|
+
else
|
|
83
|
+
return @grace_active_cache[key] = false
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless currently_exceeded?(limit_key)
|
|
88
|
+
clear_exceeded_flags!(state)
|
|
89
|
+
return @grace_active_cache[key] = false
|
|
90
|
+
end
|
|
91
|
+
|
|
71
92
|
@grace_active_cache[key] = !state.grace_expired?
|
|
72
93
|
end
|
|
73
94
|
|
|
@@ -77,7 +98,10 @@ module PricingPlans
|
|
|
77
98
|
return @grace_ends_at_cache[key] if @grace_ends_at_cache.key?(key)
|
|
78
99
|
|
|
79
100
|
state = fresh_enforcement_state(limit_key)
|
|
80
|
-
@grace_ends_at_cache[key] = state&.
|
|
101
|
+
return @grace_ends_at_cache[key] = nil unless state&.exceeded?
|
|
102
|
+
return @grace_ends_at_cache[key] = nil unless grace_active?(limit_key)
|
|
103
|
+
|
|
104
|
+
@grace_ends_at_cache[key] = state.grace_ends_at
|
|
81
105
|
end
|
|
82
106
|
|
|
83
107
|
# Cached should block check - implemented directly to avoid GraceManager's plan resolution
|
|
@@ -95,13 +119,16 @@ module PricingPlans
|
|
|
95
119
|
return @should_block_cache[key] = false if limit_amount == :unlimited
|
|
96
120
|
|
|
97
121
|
current_usage = current_usage_for(limit_key)
|
|
98
|
-
exceeded = current_usage
|
|
99
|
-
exceeded = false if limit_amount.to_i.zero? && current_usage.to_i.zero?
|
|
122
|
+
exceeded = exceeded_now?(current_usage, limit_amount, after_limit: after_limit)
|
|
100
123
|
|
|
101
124
|
return @should_block_cache[key] = exceeded if after_limit == :block_usage
|
|
102
125
|
|
|
103
126
|
# For :grace_then_block, check if grace period expired
|
|
104
|
-
|
|
127
|
+
unless exceeded
|
|
128
|
+
state = fresh_enforcement_state(limit_key)
|
|
129
|
+
clear_exceeded_flags!(state) if state
|
|
130
|
+
return @should_block_cache[key] = false
|
|
131
|
+
end
|
|
105
132
|
|
|
106
133
|
state = fresh_enforcement_state(limit_key)
|
|
107
134
|
return @should_block_cache[key] = false unless state&.exceeded?
|
|
@@ -339,5 +366,32 @@ module PricingPlans
|
|
|
339
366
|
highest_warn = warn_thresholds.max.to_f * 100.0
|
|
340
367
|
(percent >= highest_warn && highest_warn.positive?) ? :warning : :ok
|
|
341
368
|
end
|
|
369
|
+
|
|
370
|
+
def currently_exceeded?(limit_key)
|
|
371
|
+
limit_config = limit_config_for(limit_key)
|
|
372
|
+
return false unless limit_config
|
|
373
|
+
|
|
374
|
+
limit_amount = limit_config[:to]
|
|
375
|
+
return false if limit_amount == :unlimited
|
|
376
|
+
|
|
377
|
+
current_usage = current_usage_for(limit_key)
|
|
378
|
+
exceeded_now?(current_usage, limit_amount, after_limit: limit_config[:after_limit])
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Check if we should lazily start grace for this limit.
|
|
382
|
+
# This handles edge cases where usage increased without triggering callbacks
|
|
383
|
+
# (e.g., status changes, bulk imports, manual DB updates).
|
|
384
|
+
def should_lazily_start_grace?(limit_key)
|
|
385
|
+
limit_config = limit_config_for(limit_key)
|
|
386
|
+
return false unless limit_config
|
|
387
|
+
return false unless limit_config[:after_limit] == :grace_then_block
|
|
388
|
+
|
|
389
|
+
limit_amount = limit_config[:to]
|
|
390
|
+
return false if limit_amount == :unlimited
|
|
391
|
+
|
|
392
|
+
current_usage = current_usage_for(limit_key)
|
|
393
|
+
current_usage > limit_amount.to_i
|
|
394
|
+
end
|
|
395
|
+
|
|
342
396
|
end
|
|
343
397
|
end
|
data/lib/pricing_plans.rb
CHANGED
|
@@ -21,12 +21,14 @@ module PricingPlans
|
|
|
21
21
|
autoload :Configuration, "pricing_plans/configuration"
|
|
22
22
|
autoload :Registry, "pricing_plans/registry"
|
|
23
23
|
autoload :Plan, "pricing_plans/plan"
|
|
24
|
+
autoload :PlanResolution, "pricing_plans/plan_resolution"
|
|
24
25
|
autoload :DSL, "pricing_plans/dsl"
|
|
25
26
|
autoload :IntegerRefinements, "pricing_plans/integer_refinements"
|
|
26
27
|
autoload :PlanResolver, "pricing_plans/plan_resolver"
|
|
27
28
|
autoload :PaySupport, "pricing_plans/pay_support"
|
|
28
29
|
autoload :LimitChecker, "pricing_plans/limit_checker"
|
|
29
30
|
autoload :LimitableRegistry, "pricing_plans/limit_checker"
|
|
31
|
+
autoload :ExceededStateUtils, "pricing_plans/exceeded_state_utils"
|
|
30
32
|
autoload :GraceManager, "pricing_plans/grace_manager"
|
|
31
33
|
autoload :Callbacks, "pricing_plans/callbacks"
|
|
32
34
|
autoload :PeriodCalculator, "pricing_plans/period_calculator"
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pricing_plans
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-03-19 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -70,6 +70,7 @@ files:
|
|
|
70
70
|
- LICENSE.txt
|
|
71
71
|
- README.md
|
|
72
72
|
- Rakefile
|
|
73
|
+
- context7.json
|
|
73
74
|
- docs/01-define-pricing-plans.md
|
|
74
75
|
- docs/02-controller-helpers.md
|
|
75
76
|
- docs/03-model-helpers.md
|
|
@@ -93,6 +94,7 @@ files:
|
|
|
93
94
|
- lib/pricing_plans/controller_rescues.rb
|
|
94
95
|
- lib/pricing_plans/dsl.rb
|
|
95
96
|
- lib/pricing_plans/engine.rb
|
|
97
|
+
- lib/pricing_plans/exceeded_state_utils.rb
|
|
96
98
|
- lib/pricing_plans/grace_manager.rb
|
|
97
99
|
- lib/pricing_plans/integer_refinements.rb
|
|
98
100
|
- lib/pricing_plans/job_guards.rb
|
|
@@ -106,6 +108,7 @@ files:
|
|
|
106
108
|
- lib/pricing_plans/period_calculator.rb
|
|
107
109
|
- lib/pricing_plans/plan.rb
|
|
108
110
|
- lib/pricing_plans/plan_owner.rb
|
|
111
|
+
- lib/pricing_plans/plan_resolution.rb
|
|
109
112
|
- lib/pricing_plans/plan_resolver.rb
|
|
110
113
|
- lib/pricing_plans/price_components.rb
|
|
111
114
|
- lib/pricing_plans/registry.rb
|