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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99983bea54e570eebe0d01fecb11245816a95ed60eb7cc87a61756009764817f
4
- data.tar.gz: 388d3c8b0791cf5388dda9d670a21fd53613105cefcf861c2769667f9f15fab1
3
+ metadata.gz: fee743f485f98161652b725ca25882ccdd0066500b4612d8df6cf023d22552d8
4
+ data.tar.gz: 61654ab323c68cde37eb9a258dc8ac3636d43e7abcae6407484a38b986342333
5
5
  SHA512:
6
- metadata.gz: f99451ddc7aaba6ea9cf5bebf81395ee88383cb5a27cd2efb491ae78a711d99115f4cb5c1fade02aaa9d8cc6b09afcefe96abc8e3f00cf772d4e797b66a13424
7
- data.tar.gz: bd14a7080334f2e6aeb215739d33ac3d91f97803a706dfcd83c8053b185ba36fd7797804e4a41a10500946acb00667dfabb6536fa3e184bbac41923e3adef375
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
@@ -0,0 +1,4 @@
1
+ {
2
+ "url": "https://context7.com/rameerez/pricing_plans",
3
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
4
+ }
@@ -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
- return unless current_usage >= limit_amount
109
+ after_limit = limit_config[:after_limit]
110
110
 
111
- case limit_config[:after_limit]
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 >= limit_amount.to_i
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
- return exceeded if after_limit == :block_usage
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 = find_state(plan_owner, limit_key)
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
- log_debug "[PricingPlans::PaySupport] subscription_active_for? called for #{plan_owner.class.name}##{plan_owner.id}"
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
- log_debug "[PricingPlans::PaySupport] current_subscription_for called for #{plan_owner.class.name}##{plan_owner.id}"
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
- log_debug "[PricingPlans::PlanResolver] effective_plan_for called for #{plan_owner.class.name}##{plan_owner.respond_to?(:id) ? plan_owner.id : 'N/A'}"
11
+ resolution_for(plan_owner).plan
12
+ end
12
13
 
13
- # 1. Check manual assignment FIRST (admin overrides take precedence)
14
- log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
15
- if plan_owner.respond_to?(:id)
16
- assignment = Assignment.find_by(
17
- plan_owner_type: plan_owner.class.name,
18
- plan_owner_id: plan_owner.id
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
- # 2. Check Pay subscription status
29
- pay_available = PaySupport.pay_available?
30
- log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"
31
- log_debug "[PricingPlans::PlanResolver] defined?(Pay) = #{defined?(Pay)}"
32
-
33
- if pay_available
34
- log_debug "[PricingPlans::PlanResolver] Calling resolve_plan_from_pay..."
35
- plan_from_pay = resolve_plan_from_pay(plan_owner)
36
- log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returned: #{plan_from_pay ? plan_from_pay.key : 'nil'}"
37
- return plan_from_pay if plan_from_pay
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 plan: #{default ? default.key : 'nil'}"
43
- default
44
- end
45
-
46
- def plan_key_for(plan_owner)
47
- effective_plan_for(plan_owner)&.key
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 resolve_plan_from_pay(plan_owner)
66
- log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay: checking if plan_owner has payment_processor or Pay methods..."
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
- log_debug "[PricingPlans::PlanResolver] has_payment_processor? #{has_payment_processor}"
76
- log_debug "[PricingPlans::PlanResolver] has_pay_methods? #{has_pay_methods}"
78
+ assignment = Assignment.find_by(
79
+ plan_owner_type: plan_owner.class.name,
80
+ plan_owner_id: plan_owner.id
81
+ )
77
82
 
78
- # PaySupport will handle both payment_processor and direct Pay methods
79
- return nil unless has_payment_processor || has_pay_methods
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
- # Check if plan_owner has active subscription, trial, or grace period
82
- log_debug "[PricingPlans::PlanResolver] Calling PaySupport.subscription_active_for?..."
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
- if is_active
87
- log_debug "[PricingPlans::PlanResolver] Calling PaySupport.current_subscription_for..."
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
- # Map processor plan to our plan
93
- processor_plan = subscription.processor_plan
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
- if processor_plan
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
- log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returning nil"
104
- nil
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
- return @grace_active_cache[key] = false unless state&.exceeded?
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&.grace_ends_at
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 >= limit_amount.to_i
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
- return @should_block_cache[key] = false unless exceeded
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0"
5
5
  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.3.1
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-02-16 00:00:00.000000000 Z
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