pricing_plans 0.3.2 → 0.4.1
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 +1 -0
- data/context7.json +4 -0
- data/docs/01-define-pricing-plans.md +20 -0
- data/docs/03-model-helpers.md +24 -1
- data/docs/04-views.md +1 -1
- data/docs/05-semantic-pricing.md +3 -0
- data/lib/pricing_plans/pay_support.rb +4 -2
- data/lib/pricing_plans/plan.rb +20 -8
- 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/version.rb +1 -1
- data/lib/pricing_plans.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1a22c4cf12c06ba9c2e7d649811c286044ae8ff5f9586bea223584fa10b054e4
|
|
4
|
+
data.tar.gz: 55ee30afed8af0fd32018ca6d706ab0efe4cd745346fc24787d90ddbcf815071
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf40f8507ad67ca84c0e46e390db6fdb65e13ea247f5f1a12446c88ec6eaff59d0b519f2951538a59858f2b4222c4257112c5f13d4697a78fc84124e6c713782
|
|
7
|
+
data.tar.gz: 2a3906414a684300257e1b20b1f4b3cbd278ef7631c7a4f7c675f006483cb107145c323e61072e0fe2fe91976ca140b86dc617d3a14cc1495a010cf8c897c19a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## [0.4.1] - 2026-08-24
|
|
2
|
+
|
|
3
|
+
- **Never 500 a pricing page when Stripe is unreachable**: `Plan#currency_symbol` was the one Stripe lookup without a rescue — a Stripe outage, rate limit, or missing API key (any test/CI environment) raised straight through the pricing page. It now degrades to `default_currency_symbol` like every other presentation method (#24)
|
|
4
|
+
- **`price` and `stripe_price` can be declared together**: the numeric price is the local source of truth for display and plan comparison (no network in the request path); the Stripe id stays the billing identity for checkout and subscription matching. Previously `validate_pricing!` forced a choice, which meant Stripe-priced plans had no local number — and when the live lookup failed, `comparable_price_cents` silently became 0 for every paid plan, so `upgrade_from?` and `next_upgrade_plan` stopped offering upgrades with no error and no log line (#25)
|
|
5
|
+
- Only `price_string` remains exclusive, since a label cannot be compared numerically
|
|
6
|
+
- Known edge: for a both-declared plan the yearly figure derives as 12x monthly; a discounted yearly Stripe price needs `price_components_resolver`
|
|
7
|
+
|
|
8
|
+
## [0.4.0] - 2026-03-19
|
|
9
|
+
|
|
10
|
+
- **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
|
|
11
|
+
- **Preserve underlying billing context**: resolution objects include the current subscription when present, even when a manual assignment overrides it for entitlements
|
|
12
|
+
- **Clarify effective plan vs billing state**: docs now explicitly distinguish the effective pricing plan from Pay/Stripe subscription status
|
|
13
|
+
|
|
1
14
|
## [0.3.2] - 2026-02-25
|
|
2
15
|
|
|
3
16
|
- **Fix stale grace warnings after plan upgrades**: Grace/blocked flags now auto-clear when usage drops below limit (self-healing state)
|
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.
|
data/context7.json
ADDED
|
@@ -493,6 +493,26 @@ end
|
|
|
493
493
|
|
|
494
494
|
`stripe_price` accepts String or Hash (e.g., `{ month:, year:, id: }`) and the `pricing_plans` PlanResolver maps against Pay's `subscription.processor_plan`.
|
|
495
495
|
|
|
496
|
+
### Declaring both a `price` and a `stripe_price`
|
|
497
|
+
|
|
498
|
+
You can declare both, and it's recommended for paid plans:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
plan :pro do
|
|
502
|
+
price 29
|
|
503
|
+
stripe_price month: "price_123abc", year: "price_456def"
|
|
504
|
+
end
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
They answer different questions, so they're not alternatives:
|
|
508
|
+
|
|
509
|
+
- `stripe_price` is the **billing identity**: what checkout charges, and what a Pay subscription is matched against.
|
|
510
|
+
- `price` is the **local source of truth for display and plan comparison**.
|
|
511
|
+
|
|
512
|
+
When both are set, the number wins for `price_label`, `price_components`, `currency_symbol` and upgrade/downgrade comparisons, so pricing pages and upgrade CTAs render without a single Stripe API call. Without a numeric price, all of that depends on a live `Stripe::Price.retrieve`; if that fails (Stripe unreachable, missing API key, cold cache), every paid plan compares as $0 and upgrade prompts silently vanish.
|
|
513
|
+
|
|
514
|
+
`price_string` remains exclusive with both: it's a label ("Contact us"), not a number, so it can't be compared.
|
|
515
|
+
|
|
496
516
|
|
|
497
517
|
## Example: define an enterprise plan
|
|
498
518
|
|
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
|
data/docs/04-views.md
CHANGED
|
@@ -15,7 +15,7 @@ Each `PricingPlans::Plan` responds to:
|
|
|
15
15
|
- `plan.name`
|
|
16
16
|
- `plan.description`
|
|
17
17
|
- `plan.bullets` → Array of strings
|
|
18
|
-
- `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this.
|
|
18
|
+
- `plan.price_label` → The `price` or `price_string` you've defined for the plan. If `stripe_price` is set (and no numeric `price` is declared) and the Stripe gem is available, it auto-fetches the live price from Stripe. You can override or disable this.
|
|
19
19
|
- `plan.cta_text`
|
|
20
20
|
- `plan.cta_url`
|
|
21
21
|
- `plan.metadata` → Optional hash for UI/presentation attributes (icons, colors, badges)
|
data/docs/05-semantic-pricing.md
CHANGED
|
@@ -42,6 +42,7 @@ plan.currency_symbol # "$" or derived from Stripe
|
|
|
42
42
|
Notes:
|
|
43
43
|
|
|
44
44
|
- If `stripe_price` is configured, we derive cents, currency, and interval from the Stripe Price (and cache it).
|
|
45
|
+
- If a numeric `price` is declared alongside `stripe_price`, the local number wins and no Stripe call is made (the yearly interval is then derived as 12× the monthly number; use `price_components_resolver` if your yearly price is discounted).
|
|
45
46
|
- If `price 0` (free), we return components with `present? == true`, amount 0 and the configured default currency symbol.
|
|
46
47
|
- If only `price_string` is set (e.g., "Contact us"), components return `present? == false`, `label == price_string`.
|
|
47
48
|
|
|
@@ -139,6 +140,8 @@ end
|
|
|
139
140
|
|
|
140
141
|
By default, if a plan has `stripe_price` configured and the `stripe` gem is present, we auto-fetch the Stripe Price and render a friendly label (e.g., `$29/mo`). This mirrors Pay’s use of Stripe Prices.
|
|
141
142
|
|
|
143
|
+
Declaring a numeric `price` next to `stripe_price` opts that plan out: the local number is rendered and no Stripe call is made.
|
|
144
|
+
|
|
142
145
|
|
|
143
146
|
To disable auto-fetching globally:
|
|
144
147
|
|
|
@@ -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)
|
data/lib/pricing_plans/plan.rb
CHANGED
|
@@ -301,9 +301,11 @@ module PricingPlans
|
|
|
301
301
|
|
|
302
302
|
# Human label to display price in UIs. Prefers explicit string, then numeric, else contact.
|
|
303
303
|
def price_label
|
|
304
|
-
# Auto-fetch from processor (Stripe) if enabled and plan has stripe_price
|
|
304
|
+
# Auto-fetch from processor (Stripe) if enabled and plan has stripe_price.
|
|
305
|
+
# A locally declared numeric price wins: it is the source of truth for
|
|
306
|
+
# display, and honoring it keeps rendering off the network entirely.
|
|
305
307
|
cfg = PricingPlans.configuration
|
|
306
|
-
if cfg&.auto_price_labels_from_processor && stripe_price
|
|
308
|
+
if cfg&.auto_price_labels_from_processor && stripe_price && price.nil?
|
|
307
309
|
begin
|
|
308
310
|
if defined?(::Stripe)
|
|
309
311
|
price_id = stripe_price.is_a?(Hash) ? (stripe_price[:id] || stripe_price[:month] || stripe_price[:year]) : stripe_price
|
|
@@ -443,11 +445,17 @@ module PricingPlans
|
|
|
443
445
|
end
|
|
444
446
|
|
|
445
447
|
def currency_symbol
|
|
446
|
-
|
|
448
|
+
# A locally declared numeric price is rendered in the configured currency
|
|
449
|
+
# (see #price_components), so don't ask Stripe when we have one.
|
|
450
|
+
if stripe_price && price.nil?
|
|
447
451
|
# Try to derive from Stripe API/cache; fall back to default
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
452
|
+
begin
|
|
453
|
+
pr = fetch_stripe_price_record(preferred_price_id(:month) || preferred_price_id(:year))
|
|
454
|
+
if pr
|
|
455
|
+
return currency_symbol_from(pr)
|
|
456
|
+
end
|
|
457
|
+
rescue StandardError
|
|
458
|
+
# Stripe unreachable, rate-limited or unconfigured: never take down a pricing page
|
|
451
459
|
end
|
|
452
460
|
end
|
|
453
461
|
PricingPlans.configuration.default_currency_symbol
|
|
@@ -544,8 +552,12 @@ module PricingPlans
|
|
|
544
552
|
end
|
|
545
553
|
|
|
546
554
|
def validate_pricing!
|
|
547
|
-
|
|
548
|
-
|
|
555
|
+
# `price` and `stripe_price` are not alternatives: the number is the local
|
|
556
|
+
# source of truth for display and plan comparison (resolved with no network
|
|
557
|
+
# call), while the Stripe id stays the billing identity used by checkout and
|
|
558
|
+
# by subscription -> plan matching. Only `price_string` remains exclusive,
|
|
559
|
+
# since it is a label that cannot be compared numerically.
|
|
560
|
+
if @price_string && (@price || @stripe_price)
|
|
549
561
|
raise ConfigurationError, "Plan #{@key} can only have one of: price, price_string, or stripe_price"
|
|
550
562
|
end
|
|
551
563
|
end
|
|
@@ -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)
|
data/lib/pricing_plans.rb
CHANGED
|
@@ -21,6 +21,7 @@ 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"
|
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.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-08-24 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
|
|
@@ -107,6 +108,7 @@ files:
|
|
|
107
108
|
- lib/pricing_plans/period_calculator.rb
|
|
108
109
|
- lib/pricing_plans/plan.rb
|
|
109
110
|
- lib/pricing_plans/plan_owner.rb
|
|
111
|
+
- lib/pricing_plans/plan_resolution.rb
|
|
110
112
|
- lib/pricing_plans/plan_resolver.rb
|
|
111
113
|
- lib/pricing_plans/price_components.rb
|
|
112
114
|
- lib/pricing_plans/registry.rb
|