pricing_plans 0.1.1 → 0.2.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/.claude/settings.local.json +6 -2
- data/CHANGELOG.md +6 -0
- data/README.md +5 -2
- data/docs/01-define-pricing-plans.md +50 -8
- data/lib/generators/pricing_plans/install/templates/initializer.rb +4 -0
- data/lib/pricing_plans/configuration.rb +8 -0
- data/lib/pricing_plans/controller_guards.rb +3 -2
- data/lib/pricing_plans/limit_checker.rb +5 -2
- data/lib/pricing_plans/limitable.rb +9 -2
- data/lib/pricing_plans/pay_support.rb +62 -18
- data/lib/pricing_plans/plan.rb +9 -0
- data/lib/pricing_plans/plan_resolver.rb +49 -6
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +7 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a94b5bd0d3211b4eb17838d133adbfcef2df64f1c36f5378c952d49e78a035d4
|
|
4
|
+
data.tar.gz: f549e3f70a0ae506489204ba59c2da0996b62b4f514cf6f68276a9e8a1278d91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 997d0a1039830243d8f5515197570fa84d07b3450482b448f7ef9acfb724accec12dad6fa0f60216162fc414afb26ba4f93169dd376ddb3590dec223d58c5eba
|
|
7
|
+
data.tar.gz: 59da0f6040c935251fe2c47a635ce2738b373b8d91ae6349be6441ca04d31deb1c349cf1bf4161ed658f7abfa96cc202eedde1aa4b079ddd236545f1fa468f5e
|
data/.claude/settings.local.json
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
"Bash(sed:*)",
|
|
10
10
|
"Bash(grep:*)",
|
|
11
11
|
"Bash(ruby:*)",
|
|
12
|
-
"Bash(find:*)"
|
|
12
|
+
"Bash(find:*)",
|
|
13
|
+
"mcp__context7__resolve-library-id",
|
|
14
|
+
"mcp__context7__get-library-docs",
|
|
15
|
+
"WebFetch(domain:github.com)",
|
|
16
|
+
"WebSearch"
|
|
13
17
|
],
|
|
14
18
|
"deny": []
|
|
15
19
|
}
|
|
16
|
-
}
|
|
20
|
+
}
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.0] - 2025-12-26
|
|
9
|
+
|
|
10
|
+
- Fix a bug in the pay gem integration that would always return the default pricing plan regardless of the actual Pay subscription
|
|
11
|
+
- Add hidden plans, enabling grandfathering, no-free-users use cases, etc.
|
|
12
|
+
- Prevent unlimited limits for limits that were undefined
|
|
13
|
+
|
|
8
14
|
## [0.1.1] - 2025-12-25
|
|
9
15
|
|
|
10
16
|
- Add support for Rails 8+
|
data/README.md
CHANGED
|
@@ -7,11 +7,13 @@ Enforce pricing plan limits with one-liners that read like plain English. Avoid
|
|
|
7
7
|
For example, this is how you define pricing plans and their entitlements:
|
|
8
8
|
```ruby
|
|
9
9
|
plan :pro do
|
|
10
|
-
allows :api_access
|
|
11
|
-
limits :projects, to: 5
|
|
10
|
+
allows :api_access # Features: blocked by default unless explicitly allowed
|
|
11
|
+
limits :projects, to: 5 # Limits: 0 by default unless a limit is set explicitly
|
|
12
12
|
end
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
Plans are **secure by default**: features are disabled and limits are set to 0 unless explicitly configured.
|
|
16
|
+
|
|
15
17
|
You can then gate features in your controllers:
|
|
16
18
|
```ruby
|
|
17
19
|
before_action :enforce_api_access!, only: [:create]
|
|
@@ -97,6 +99,7 @@ You can also display upgrade alerts to prompt users into upgrading to the next p
|
|
|
97
99
|
|
|
98
100
|

|
|
99
101
|
|
|
102
|
+
You can also grandfather users into old plans (hidden to other users), assign plans manually without requiring a payment (for testing, gifts, or employees), and much more!
|
|
100
103
|
|
|
101
104
|
## 🤓 Read the docs!
|
|
102
105
|
|
|
@@ -20,7 +20,9 @@ That's the basics! Let's dive in.
|
|
|
20
20
|
|
|
21
21
|
## Define what each plan gives
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Plans are **secure by default**: features are disabled and limits are set to 0 unless explicitly configured.
|
|
24
|
+
|
|
25
|
+
At a high level, a plan does **two** things:
|
|
24
26
|
(1) Gate features
|
|
25
27
|
(2) Enforce limits (quotas)
|
|
26
28
|
|
|
@@ -41,7 +43,7 @@ end
|
|
|
41
43
|
We're just **defining** what the plan does now. Later, we'll see [all the methods we can use to enforce these limits and gate these features](#gate-features-in-controllers) very easily.
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
**Features and limits are secure by default**: features are disabled and limits are set to 0 unless explicitly allowed. For clarity, you can explicitly state what a plan disallows, but this is just cosmetic:
|
|
45
47
|
|
|
46
48
|
```ruby
|
|
47
49
|
PricingPlans.configure do |config|
|
|
@@ -252,16 +254,15 @@ end
|
|
|
252
254
|
|
|
253
255
|
(Assuming, of course, that your `Project` model has an `active` scope)
|
|
254
256
|
|
|
255
|
-
|
|
257
|
+
**Undefined limits default to 0 (blocked).** To explicitly allow unlimited access, use the `unlimited` helper:
|
|
256
258
|
|
|
257
259
|
```ruby
|
|
258
260
|
PricingPlans.configure do |config|
|
|
259
|
-
plan :
|
|
260
|
-
price
|
|
261
|
-
|
|
261
|
+
plan :enterprise do
|
|
262
|
+
price 999
|
|
262
263
|
allows :api_access
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
unlimited :projects # Explicit unlimited
|
|
265
|
+
# :storage undefined → 0 (blocked)
|
|
265
266
|
end
|
|
266
267
|
end
|
|
267
268
|
```
|
|
@@ -314,6 +315,47 @@ end
|
|
|
314
315
|
|
|
315
316
|
You can also make a plan `default!`; and you can make a plan `highlighted!` to help you when building a pricing table.
|
|
316
317
|
|
|
318
|
+
### Hide plans from public lists
|
|
319
|
+
|
|
320
|
+
You can mark a plan as `hidden!` to exclude it from public-facing plan lists (`PricingPlans.plans`, `PricingPlans.for_pricing`, `PricingPlans.view_models`). Hidden plans are still accessible internally and can be assigned to users.
|
|
321
|
+
|
|
322
|
+
**Use cases for hidden plans:**
|
|
323
|
+
- **Default plan for unsubscribed users**: Create a `hidden!` plan with zero limits as your default for users who haven't subscribed yet. This is useful, for example, if you don't want free users in your app (everyone needs to pay) -- the `hidden!` plan would get assigned to every user by default (to implicitly block access to all features) until they subscribe
|
|
324
|
+
- **Grandfathered plans**: Old plans you no longer offer to new customers, but existing users still have
|
|
325
|
+
- **Internal/testing plans**: Plans for employees, beta testers, or special partnerships
|
|
326
|
+
- **Deprecated plans**: Plans being phased out but still active for some users
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
PricingPlans.configure do |config|
|
|
330
|
+
# Hidden default plan for users who haven't subscribed
|
|
331
|
+
plan :unsubscribed do
|
|
332
|
+
price 0
|
|
333
|
+
hidden! # Won't appear on pricing page
|
|
334
|
+
default!
|
|
335
|
+
# No limits defined - everything defaults to 0 (blocked)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Visible plans for your pricing page
|
|
339
|
+
plan :starter do
|
|
340
|
+
price 10
|
|
341
|
+
limit :projects, to: 5
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Grandfathered plan (hidden from new customers)
|
|
345
|
+
plan :legacy_2020 do
|
|
346
|
+
price 15
|
|
347
|
+
hidden! # Existing customers keep it, but won't show on pricing page
|
|
348
|
+
limit :projects, to: 100
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Important notes:**
|
|
354
|
+
- Hidden plans can be the `default!` plan (common pattern for "unsubscribed" users)
|
|
355
|
+
- Hidden plans **cannot** be the `highlighted!` plan (validation error - highlighted plans must be visible)
|
|
356
|
+
- Users can still be on hidden plans (via Pay subscription, manual assignment, or default)
|
|
357
|
+
- Internal APIs (`Registry.plans`, `PlanResolver`) can still access hidden plans
|
|
358
|
+
- Pay gem can still resolve subscriptions to hidden plans (useful for grandfathered customers)
|
|
317
359
|
|
|
318
360
|
## Link paid plans to Stripe prices (requires `pay`)
|
|
319
361
|
|
|
@@ -97,4 +97,8 @@ PricingPlans.configure do |config|
|
|
|
97
97
|
|
|
98
98
|
# Downgrade policy hook used by CTA ergonomics helpers
|
|
99
99
|
# config.downgrade_policy = ->(from:, to:, plan_owner:) { [true, nil] }
|
|
100
|
+
|
|
101
|
+
# Enable verbose debug logging for PricingPlans internals (Pay detection, plan resolution, etc).
|
|
102
|
+
# When set to true, detailed debug output will be printed to stdout, which can be helpful for troubleshooting.
|
|
103
|
+
# config.debug = false
|
|
100
104
|
end
|
|
@@ -9,6 +9,8 @@ module PricingPlans
|
|
|
9
9
|
attr_accessor :default_plan, :highlighted_plan, :period_cycle
|
|
10
10
|
# Optional ergonomics
|
|
11
11
|
attr_accessor :default_cta_text, :default_cta_url
|
|
12
|
+
# Debug mode - set to true to enable debug output
|
|
13
|
+
attr_accessor :debug
|
|
12
14
|
# Global controller ergonomics
|
|
13
15
|
# Optional global resolver for controller plan owner. Per-controller settings still win.
|
|
14
16
|
# Accepts:
|
|
@@ -70,6 +72,7 @@ module PricingPlans
|
|
|
70
72
|
@free_price_caption = "Forever free"
|
|
71
73
|
@interval_default_for_ui = :month
|
|
72
74
|
@downgrade_policy = ->(from:, to:, plan_owner:) { [true, nil] }
|
|
75
|
+
@debug = false
|
|
73
76
|
@plans = {}
|
|
74
77
|
@event_handlers = {
|
|
75
78
|
warning: {},
|
|
@@ -180,6 +183,11 @@ module PricingPlans
|
|
|
180
183
|
if @highlighted_plan && !@plans.key?(@highlighted_plan)
|
|
181
184
|
raise PricingPlans::ConfigurationError, "highlighted_plan #{@highlighted_plan} is not defined"
|
|
182
185
|
end
|
|
186
|
+
|
|
187
|
+
# Highlighted plan cannot be hidden (would never appear in pricing pages)
|
|
188
|
+
if @highlighted_plan && @plans[@highlighted_plan]&.hidden?
|
|
189
|
+
raise PricingPlans::ConfigurationError, "highlighted_plan #{@highlighted_plan} cannot be hidden"
|
|
190
|
+
end
|
|
183
191
|
end
|
|
184
192
|
|
|
185
193
|
def validate_plans!
|
|
@@ -188,9 +188,10 @@ module PricingPlans
|
|
|
188
188
|
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
189
189
|
limit_config = plan&.limit_for(limit_key)
|
|
190
190
|
|
|
191
|
-
# If no limit is configured,
|
|
191
|
+
# BREAKING CHANGE: If no limit is configured, block the action (secure by default)
|
|
192
|
+
# Previously this returned :within (allowed), now returns :blocked
|
|
192
193
|
unless limit_config
|
|
193
|
-
return Result.
|
|
194
|
+
return Result.blocked("Limit #{limit_key.to_s.humanize.downcase} not configured on this plan")
|
|
194
195
|
end
|
|
195
196
|
|
|
196
197
|
# Check if unlimited
|
|
@@ -20,7 +20,9 @@ module PricingPlans
|
|
|
20
20
|
def remaining(plan_owner, limit_key)
|
|
21
21
|
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
22
22
|
limit_config = plan&.limit_for(limit_key)
|
|
23
|
-
|
|
23
|
+
# BREAKING CHANGE: Undefined limits now default to 0 (blocked) instead of :unlimited
|
|
24
|
+
# This is secure-by-default: if a plan doesn't define a limit, access is blocked
|
|
25
|
+
return 0 unless limit_config
|
|
24
26
|
|
|
25
27
|
limit_amount = limit_config[:to]
|
|
26
28
|
return :unlimited if limit_amount == :unlimited
|
|
@@ -54,7 +56,8 @@ module PricingPlans
|
|
|
54
56
|
def limit_amount(plan_owner, limit_key)
|
|
55
57
|
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
56
58
|
limit_config = plan&.limit_for(limit_key)
|
|
57
|
-
|
|
59
|
+
# BREAKING CHANGE: Undefined limits now default to 0 (blocked) instead of :unlimited
|
|
60
|
+
return 0 unless limit_config
|
|
58
61
|
|
|
59
62
|
limit_config[:to]
|
|
60
63
|
end
|
|
@@ -189,10 +189,17 @@ module PricingPlans
|
|
|
189
189
|
|
|
190
190
|
return unless plan_owner_instance
|
|
191
191
|
|
|
192
|
-
#
|
|
192
|
+
# BREAKING CHANGE: Block when limit is not configured (secure by default)
|
|
193
193
|
plan = PlanResolver.effective_plan_for(plan_owner_instance)
|
|
194
194
|
limit_config = plan&.limit_for(limit_key)
|
|
195
|
-
|
|
195
|
+
|
|
196
|
+
# If limit is not configured, block creation (secure by default)
|
|
197
|
+
unless limit_config
|
|
198
|
+
message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key.to_s.humanize.downcase} limit not configured on this plan"
|
|
199
|
+
errors.add(:base, message)
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
|
|
196
203
|
return if limit_config[:to] == :unlimited
|
|
197
204
|
|
|
198
205
|
# For persistent caps, check if we'd exceed the limit
|
|
@@ -8,78 +8,122 @@ module PricingPlans
|
|
|
8
8
|
defined?(Pay)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
def log_debug(message)
|
|
12
|
+
puts message if PricingPlans.configuration&.debug
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def subscription_active_for?(plan_owner)
|
|
12
16
|
return false unless plan_owner
|
|
13
17
|
|
|
18
|
+
log_debug "[PricingPlans::PaySupport] subscription_active_for? called for #{plan_owner.class.name}##{plan_owner.id}"
|
|
19
|
+
|
|
14
20
|
# Prefer Pay's official API on the payment_processor
|
|
15
21
|
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
log_debug "[PricingPlans::PaySupport] payment_processor found: #{pp.class.name}##{pp.id}"
|
|
23
|
+
|
|
24
|
+
# Check all subscriptions, not just the default-named one
|
|
25
|
+
# Note: Don't call pp.subscribed?() without a name parameter, as it defaults to
|
|
26
|
+
# checking only for subscriptions named Pay.default_product_name (usually "default")
|
|
27
|
+
if pp.respond_to?(:subscriptions)
|
|
28
|
+
subs = pp.subscriptions
|
|
29
|
+
log_debug "[PricingPlans::PaySupport] subscriptions relation: #{subs.class.name}, count: #{subs.count}"
|
|
30
|
+
|
|
31
|
+
# Force array conversion to ensure we iterate through all subscriptions
|
|
32
|
+
# Some ActiveRecord relations might not enumerate properly in boolean context
|
|
33
|
+
subs_array = subs.respond_to?(:to_a) ? subs.to_a : subs
|
|
34
|
+
log_debug "[PricingPlans::PaySupport] subscriptions array size: #{subs_array.size}"
|
|
35
|
+
|
|
36
|
+
subs_array.each_with_index do |sub, idx|
|
|
37
|
+
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'}"
|
|
38
|
+
end
|
|
19
39
|
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
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?) }
|
|
41
|
+
log_debug "[PricingPlans::PaySupport] subscription_active_for? returning: #{result}"
|
|
42
|
+
return result
|
|
43
|
+
else
|
|
44
|
+
log_debug "[PricingPlans::PaySupport] payment_processor does not respond to :subscriptions"
|
|
22
45
|
end
|
|
46
|
+
else
|
|
47
|
+
log_debug "[PricingPlans::PaySupport] No payment_processor found or plan_owner doesn't respond to :payment_processor"
|
|
23
48
|
end
|
|
24
49
|
|
|
25
50
|
# Fallbacks for apps that surface Pay state on the owner
|
|
26
51
|
individual_active = (plan_owner.respond_to?(:subscribed?) && plan_owner.subscribed?) ||
|
|
27
52
|
(plan_owner.respond_to?(:on_trial?) && plan_owner.on_trial?) ||
|
|
28
53
|
(plan_owner.respond_to?(:on_grace_period?) && plan_owner.on_grace_period?)
|
|
54
|
+
log_debug "[PricingPlans::PaySupport] Fallback individual_active: #{individual_active}"
|
|
29
55
|
return true if individual_active
|
|
30
56
|
|
|
31
57
|
if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions)
|
|
58
|
+
log_debug "[PricingPlans::PaySupport] Checking plan_owner.subscriptions fallback"
|
|
32
59
|
return subs.any? { |sub| (sub.respond_to?(:active?) && sub.active?) || (sub.respond_to?(:on_trial?) && sub.on_trial?) || (sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) }
|
|
33
60
|
end
|
|
34
61
|
|
|
62
|
+
log_debug "[PricingPlans::PaySupport] subscription_active_for? returning false (no active subscription found)"
|
|
35
63
|
false
|
|
36
64
|
end
|
|
37
65
|
|
|
38
66
|
def current_subscription_for(plan_owner)
|
|
39
67
|
return nil unless pay_available?
|
|
40
68
|
|
|
69
|
+
log_debug "[PricingPlans::PaySupport] current_subscription_for called for #{plan_owner.class.name}##{plan_owner.id}"
|
|
70
|
+
|
|
41
71
|
# Prefer Pay's payment_processor API
|
|
42
72
|
if plan_owner.respond_to?(:payment_processor) && (pp = plan_owner.payment_processor)
|
|
43
|
-
|
|
44
|
-
subscription = pp.subscription
|
|
45
|
-
if subscription && (
|
|
46
|
-
(subscription.respond_to?(:active?) && subscription.active?) ||
|
|
47
|
-
(subscription.respond_to?(:on_trial?) && subscription.on_trial?) ||
|
|
48
|
-
(subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?)
|
|
49
|
-
)
|
|
50
|
-
return subscription
|
|
51
|
-
end
|
|
52
|
-
end
|
|
73
|
+
log_debug "[PricingPlans::PaySupport] payment_processor found: #{pp.class.name}##{pp.id}"
|
|
53
74
|
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
# Check all subscriptions, not just the default-named one
|
|
76
|
+
# Note: Don't call pp.subscription() without a name parameter, as it defaults to
|
|
77
|
+
# looking for subscriptions named Pay.default_product_name (usually "default")
|
|
78
|
+
if pp.respond_to?(:subscriptions)
|
|
79
|
+
subs = pp.subscriptions
|
|
80
|
+
log_debug "[PricingPlans::PaySupport] subscriptions relation: #{subs.class.name}, count: #{subs.count}"
|
|
81
|
+
|
|
82
|
+
# Force array conversion to ensure we iterate properly
|
|
83
|
+
subs_array = subs.respond_to?(:to_a) ? subs.to_a : subs
|
|
84
|
+
log_debug "[PricingPlans::PaySupport] subscriptions array size: #{subs_array.size}"
|
|
85
|
+
|
|
86
|
+
found = subs_array.find do |sub|
|
|
56
87
|
(sub.respond_to?(:on_trial?) && sub.on_trial?) ||
|
|
57
88
|
(sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) ||
|
|
58
89
|
(sub.respond_to?(:active?) && sub.active?)
|
|
59
90
|
end
|
|
91
|
+
log_debug "[PricingPlans::PaySupport] current_subscription_for found: #{found ? "#{found.class.name}##{found.id} (name: #{found.name})" : 'nil'}"
|
|
60
92
|
return found if found
|
|
93
|
+
else
|
|
94
|
+
log_debug "[PricingPlans::PaySupport] payment_processor does not respond to :subscriptions"
|
|
61
95
|
end
|
|
96
|
+
else
|
|
97
|
+
log_debug "[PricingPlans::PaySupport] No payment_processor found or plan_owner doesn't respond to :payment_processor"
|
|
62
98
|
end
|
|
63
99
|
|
|
64
100
|
# Fallbacks for apps that surface subscriptions on the owner
|
|
65
101
|
if plan_owner.respond_to?(:subscription)
|
|
102
|
+
log_debug "[PricingPlans::PaySupport] Checking plan_owner.subscription fallback"
|
|
66
103
|
subscription = plan_owner.subscription
|
|
67
104
|
if subscription && (
|
|
68
105
|
(subscription.respond_to?(:active?) && subscription.active?) ||
|
|
69
106
|
(subscription.respond_to?(:on_trial?) && subscription.on_trial?) ||
|
|
70
107
|
(subscription.respond_to?(:on_grace_period?) && subscription.on_grace_period?)
|
|
71
108
|
)
|
|
109
|
+
log_debug "[PricingPlans::PaySupport] current_subscription_for returning fallback subscription"
|
|
72
110
|
return subscription
|
|
73
111
|
end
|
|
74
112
|
end
|
|
75
113
|
|
|
76
114
|
if plan_owner.respond_to?(:subscriptions) && (subs = plan_owner.subscriptions)
|
|
77
|
-
|
|
115
|
+
log_debug "[PricingPlans::PaySupport] Checking plan_owner.subscriptions fallback"
|
|
116
|
+
found = subs.find do |sub|
|
|
78
117
|
(sub.respond_to?(:on_trial?) && sub.on_trial?) ||
|
|
79
118
|
(sub.respond_to?(:on_grace_period?) && sub.on_grace_period?) ||
|
|
80
119
|
(sub.respond_to?(:active?) && sub.active?)
|
|
81
120
|
end
|
|
121
|
+
log_debug "[PricingPlans::PaySupport] current_subscription_for found in fallback: #{found ? "#{found.class.name}##{found.id}" : 'nil'}"
|
|
122
|
+
return found if found
|
|
82
123
|
end
|
|
124
|
+
|
|
125
|
+
log_debug "[PricingPlans::PaySupport] current_subscription_for returning nil"
|
|
126
|
+
nil
|
|
83
127
|
end
|
|
84
128
|
end
|
|
85
129
|
end
|
data/lib/pricing_plans/plan.rb
CHANGED
|
@@ -24,6 +24,7 @@ module PricingPlans
|
|
|
24
24
|
@cta_url = nil
|
|
25
25
|
@default = false
|
|
26
26
|
@highlighted = false
|
|
27
|
+
@hidden = false
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# DSL methods for plan configuration
|
|
@@ -275,6 +276,14 @@ module PricingPlans
|
|
|
275
276
|
false
|
|
276
277
|
end
|
|
277
278
|
|
|
279
|
+
def hidden!(value = true)
|
|
280
|
+
@hidden = !!value
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def hidden?
|
|
284
|
+
!!@hidden
|
|
285
|
+
end
|
|
286
|
+
|
|
278
287
|
# Syntactic sugar for popular/highlighted
|
|
279
288
|
def popular?
|
|
280
289
|
highlighted?
|
|
@@ -3,24 +3,44 @@
|
|
|
3
3
|
module PricingPlans
|
|
4
4
|
class PlanResolver
|
|
5
5
|
class << self
|
|
6
|
+
def log_debug(message)
|
|
7
|
+
puts message if PricingPlans.configuration&.debug
|
|
8
|
+
end
|
|
9
|
+
|
|
6
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'}"
|
|
12
|
+
|
|
7
13
|
# 1. Check Pay subscription status first (no app-specific gate required)
|
|
8
|
-
|
|
14
|
+
pay_available = PaySupport.pay_available?
|
|
15
|
+
log_debug "[PricingPlans::PlanResolver] PaySupport.pay_available? = #{pay_available}"
|
|
16
|
+
log_debug "[PricingPlans::PlanResolver] defined?(Pay) = #{defined?(Pay)}"
|
|
17
|
+
|
|
18
|
+
if pay_available
|
|
19
|
+
log_debug "[PricingPlans::PlanResolver] Calling resolve_plan_from_pay..."
|
|
9
20
|
plan_from_pay = resolve_plan_from_pay(plan_owner)
|
|
21
|
+
log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returned: #{plan_from_pay ? plan_from_pay.key : 'nil'}"
|
|
10
22
|
return plan_from_pay if plan_from_pay
|
|
11
23
|
end
|
|
12
24
|
|
|
13
25
|
# 2. Check manual assignment
|
|
26
|
+
log_debug "[PricingPlans::PlanResolver] Checking for manual assignment..."
|
|
14
27
|
if plan_owner.respond_to?(:id)
|
|
15
28
|
assignment = Assignment.find_by(
|
|
16
29
|
plan_owner_type: plan_owner.class.name,
|
|
17
30
|
plan_owner_id: plan_owner.id
|
|
18
31
|
)
|
|
19
|
-
|
|
32
|
+
if assignment
|
|
33
|
+
log_debug "[PricingPlans::PlanResolver] Found manual assignment: #{assignment.plan_key}"
|
|
34
|
+
return Registry.plan(assignment.plan_key)
|
|
35
|
+
else
|
|
36
|
+
log_debug "[PricingPlans::PlanResolver] No manual assignment found"
|
|
37
|
+
end
|
|
20
38
|
end
|
|
21
39
|
|
|
22
40
|
# 3. Fall back to default plan
|
|
23
|
-
Registry.default_plan
|
|
41
|
+
default = Registry.default_plan
|
|
42
|
+
log_debug "[PricingPlans::PlanResolver] Returning default plan: #{default ? default.key : 'nil'}"
|
|
43
|
+
default
|
|
24
44
|
end
|
|
25
45
|
|
|
26
46
|
def plan_key_for(plan_owner)
|
|
@@ -43,21 +63,44 @@ module PricingPlans
|
|
|
43
63
|
end
|
|
44
64
|
|
|
45
65
|
def resolve_plan_from_pay(plan_owner)
|
|
46
|
-
|
|
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?) ||
|
|
47
71
|
plan_owner.respond_to?(:on_trial?) ||
|
|
48
72
|
plan_owner.respond_to?(:on_grace_period?) ||
|
|
49
73
|
plan_owner.respond_to?(:subscriptions)
|
|
50
74
|
|
|
75
|
+
log_debug "[PricingPlans::PlanResolver] has_payment_processor? #{has_payment_processor}"
|
|
76
|
+
log_debug "[PricingPlans::PlanResolver] has_pay_methods? #{has_pay_methods}"
|
|
77
|
+
|
|
78
|
+
# PaySupport will handle both payment_processor and direct Pay methods
|
|
79
|
+
return nil unless has_payment_processor || has_pay_methods
|
|
80
|
+
|
|
51
81
|
# Check if plan_owner has active subscription, trial, or grace period
|
|
52
|
-
|
|
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}"
|
|
85
|
+
|
|
86
|
+
if is_active
|
|
87
|
+
log_debug "[PricingPlans::PlanResolver] Calling PaySupport.current_subscription_for..."
|
|
53
88
|
subscription = PaySupport.current_subscription_for(plan_owner)
|
|
89
|
+
log_debug "[PricingPlans::PlanResolver] current_subscription_for returned: #{subscription ? subscription.class.name : 'nil'}"
|
|
54
90
|
return nil unless subscription
|
|
55
91
|
|
|
56
92
|
# Map processor plan to our plan
|
|
57
93
|
processor_plan = subscription.processor_plan
|
|
58
|
-
|
|
94
|
+
log_debug "[PricingPlans::PlanResolver] subscription.processor_plan = #{processor_plan.inspect}"
|
|
95
|
+
|
|
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
|
|
59
101
|
end
|
|
60
102
|
|
|
103
|
+
log_debug "[PricingPlans::PlanResolver] resolve_plan_from_pay returning nil"
|
|
61
104
|
nil
|
|
62
105
|
end
|
|
63
106
|
|
data/lib/pricing_plans.rb
CHANGED
|
@@ -77,8 +77,10 @@ module PricingPlans
|
|
|
77
77
|
|
|
78
78
|
# Zero-shim Plans API for host apps
|
|
79
79
|
# Returns an array of Plan objects in a sensible order (free → paid → enterprise/contact)
|
|
80
|
+
# Excludes hidden plans (use Registry.plans to access all plans including hidden)
|
|
80
81
|
def plans
|
|
81
82
|
array = Registry.plans.values
|
|
83
|
+
.reject(&:hidden?) # Filter out hidden plans from public API
|
|
82
84
|
array.sort_by do |p|
|
|
83
85
|
# Free first, then numeric price ascending, then price_string/stripe-price at the end
|
|
84
86
|
if p.price && p.price.to_f.zero?
|
|
@@ -106,9 +108,10 @@ module PricingPlans
|
|
|
106
108
|
end
|
|
107
109
|
|
|
108
110
|
# Opinionated next-plan suggestion: pick the smallest plan that satisfies current usage
|
|
111
|
+
# Always suggests visible plans only (never suggests hidden plans)
|
|
109
112
|
def suggest_next_plan_for(plan_owner, keys: nil)
|
|
110
113
|
current_plan = PlanResolver.effective_plan_for(plan_owner)
|
|
111
|
-
sorted = plans
|
|
114
|
+
sorted = plans # Only visible plans (hidden plans filtered out)
|
|
112
115
|
keys ||= (current_plan&.limits&.keys || [])
|
|
113
116
|
keys = keys.map(&:to_sym)
|
|
114
117
|
|
|
@@ -122,7 +125,9 @@ module PricingPlans
|
|
|
122
125
|
limit[:to] == :unlimited || LimitChecker.current_usage_for(plan_owner, key, limit) <= limit[:to].to_i
|
|
123
126
|
end
|
|
124
127
|
end
|
|
125
|
-
|
|
128
|
+
# Fallback logic: never suggest hidden plans
|
|
129
|
+
# If current_plan is hidden (e.g., :unsubscribed), suggest first visible plan instead
|
|
130
|
+
candidate || (current_plan unless current_plan&.hidden?) || sorted.first
|
|
126
131
|
end
|
|
127
132
|
|
|
128
133
|
# Optional view-model decorator for UIs (pure data, no HTML)
|