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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1822c0e4c0d410596c9640baec0d6546256db8ea402744fbb4d47917e39fd88
4
- data.tar.gz: b0ae7556f2181fd2a99df2ddb9f4a9cc7f9ab3b7298346fa260d6e3b7c059d70
3
+ metadata.gz: a94b5bd0d3211b4eb17838d133adbfcef2df64f1c36f5378c952d49e78a035d4
4
+ data.tar.gz: f549e3f70a0ae506489204ba59c2da0996b62b4f514cf6f68276a9e8a1278d91
5
5
  SHA512:
6
- metadata.gz: 121f9fd26e721c59dbf0e7a8ec67647792df985343224f4087538b9607571bfa1f1cc74e23532caad7ae60db2049436540b490fedef660458dbd85beda795365
7
- data.tar.gz: e42d52966e3f7ae4c32b90e766fbb58e50d1c7c3489b3a5f0176ffac7b34613f3907d89d050d68ca9d2cd9a8bfcf71779d614537314d9ac28faf698eef79af2a
6
+ metadata.gz: 997d0a1039830243d8f5515197570fa84d07b3450482b448f7ef9acfb724accec12dad6fa0f60216162fc414afb26ba4f93169dd376ddb3590dec223d58c5eba
7
+ data.tar.gz: 59da0f6040c935251fe2c47a635ce2738b373b8d91ae6349be6441ca04d31deb1c349cf1bf4161ed658f7abfa96cc202eedde1aa4b079ddd236545f1fa468f5e
@@ -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
  ![pricing_plans Ruby on Rails gem - pricing plan upgrade prompt](/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg)
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
- At a high level, a plan needs to do **two** things:
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
- All features are disabled by default unless explicitly made available with the `allows` keyword. However, for clarity we can explicitly say what the plan disallows:
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
- You can also make something unlimited (again, just syntactic sugar to be explicit, everything is unlimited unless there's an actual limit):
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 :free do
260
- price 0
261
-
261
+ plan :enterprise do
262
+ price 999
262
263
  allows :api_access
263
-
264
- unlimited :projects
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, allow the action
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.within("No limit configured for #{limit_key}")
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
- return :unlimited unless limit_config
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
- return :unlimited unless limit_config
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
- # Skip validation if the plan_owner doesn't have limits configured
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
- return unless limit_config
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
- return true if (pp.respond_to?(:subscribed?) && pp.subscribed?) ||
17
- (pp.respond_to?(:on_trial?) && pp.on_trial?) ||
18
- (pp.respond_to?(:on_grace_period?) && pp.on_grace_period?)
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
- if pp.respond_to?(:subscriptions) && (subs = pp.subscriptions)
21
- 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?) }
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
- if pp.respond_to?(:subscription)
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
- if pp.respond_to?(:subscriptions) && (subs = pp.subscriptions)
55
- found = subs.find do |sub|
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
- subs.find do |sub|
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
@@ -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
- if PaySupport.pay_available?
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
- return Registry.plan(assignment.plan_key) if assignment
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
- return nil unless plan_owner.respond_to?(:subscribed?) ||
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
- if PaySupport.subscription_active_for?(plan_owner)
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
- return plan_from_processor_plan(processor_plan) if processor_plan
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PricingPlans
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
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
- candidate || current_plan || Registry.default_plan
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)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pricing_plans
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez