pricing_plans 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c1822c0e4c0d410596c9640baec0d6546256db8ea402744fbb4d47917e39fd88
4
- data.tar.gz: b0ae7556f2181fd2a99df2ddb9f4a9cc7f9ab3b7298346fa260d6e3b7c059d70
3
+ metadata.gz: 231aa688b5fb69cffde68df9bbab35af32634d8605930559378e15a841cb6aa0
4
+ data.tar.gz: 2fee172963111d141b39d9ad9335f1b84d3f9b6f46df139e63684103eecb7750
5
5
  SHA512:
6
- metadata.gz: 121f9fd26e721c59dbf0e7a8ec67647792df985343224f4087538b9607571bfa1f1cc74e23532caad7ae60db2049436540b490fedef660458dbd85beda795365
7
- data.tar.gz: e42d52966e3f7ae4c32b90e766fbb58e50d1c7c3489b3a5f0176ffac7b34613f3907d89d050d68ca9d2cd9a8bfcf71779d614537314d9ac28faf698eef79af2a
6
+ metadata.gz: 99277aae15c2a137b0824a199097420a60613e9c5c20b6569036f11b574186997b303fdfe42d5a2d147a66d0b28a32812e8ca1706903d94c88f6e61a5e09bf27
7
+ data.tar.gz: 12aeee03c7911d19b7b4c04398946150534b0ef66d59aa50211992a52f3000908778e42e7eb51b2acc0bec85e23e51eff5996ae04870f948826485ebf80a1414
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(mkdir:*)",
5
- "Bash(bundle install:*)",
6
- "Bash(bundle exec rake:*)",
7
- "Bash(bundle update:*)",
8
- "Bash(bundle exec ruby:*)",
9
4
  "Bash(sed:*)",
10
5
  "Bash(grep:*)",
11
- "Bash(ruby:*)",
12
- "Bash(find:*)"
6
+ "Bash(find:*)",
7
+ "mcp__context7__resolve-library-id",
8
+ "mcp__context7__get-library-docs",
9
+ "WebFetch(domain:github.com)",
10
+ "WebSearch",
11
+ "Bash(bundle exec rake test:*)",
12
+ "Bash(bundle install:*)",
13
+ "Bash(bundle exec appraisal:*)",
14
+ "Bash(ls:*)",
15
+ "Bash(done)"
13
16
  ],
14
17
  "deny": []
15
18
  }
16
- }
19
+ }
data/Appraisals ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ appraise "rails-7.2" do
4
+ gem "rails", "~> 7.2.3"
5
+ end
6
+
7
+ appraise "rails-8.1" do
8
+ gem "rails", "~> 8.1.2"
9
+ end
data/CHANGELOG.md CHANGED
@@ -1,9 +1,12 @@
1
- # Changelog
1
+ ## [0.2.1] - 2026-01-15
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ - Added a `metadata` alias to plans, and documented its usage
4
4
 
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+ ## [0.2.0] - 2025-12-26
6
+
7
+ - Fix a bug in the pay gem integration that would always return the default pricing plan regardless of the actual Pay subscription
8
+ - Add hidden plans, enabling grandfathering, no-free-users use cases, etc.
9
+ - Prevent unlimited limits for limits that were undefined
7
10
 
8
11
  ## [0.1.1] - 2025-12-25
9
12
 
data/README.md CHANGED
@@ -1,17 +1,22 @@
1
1
  # 💵 `pricing_plans` - Define and enforce pricing plan limits in your Rails app (SaaS entitlements)
2
2
 
3
- [![Gem Version](https://badge.fury.io/rb/pricing_plans.svg?x=1)](https://badge.fury.io/rb/pricing_plans)
3
+ [![Gem Version](https://badge.fury.io/rb/pricing_plans.svg)](https://badge.fury.io/rb/pricing_plans) [![Build Status](https://github.com/rameerez/pricing_plans/workflows/Tests/badge.svg)](https://github.com/rameerez/pricing_plans/actions)
4
4
 
5
- Enforce pricing plan limits with one-liners that read like plain English. Avoid scattering and entangling pricing logic everywhere in your Rails SaaS.
5
+ > [!TIP]
6
+ > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks.
7
+
8
+ `pricing_plans` allows you to enforce pricing plan limits with one-liners that read like plain English. Avoid scattering and entangling pricing logic everywhere in your Rails SaaS.
6
9
 
7
10
  For example, this is how you define pricing plans and their entitlements:
8
11
  ```ruby
9
12
  plan :pro do
10
- allows :api_access
11
- limits :projects, to: 5
13
+ allows :api_access # Features: blocked by default unless explicitly allowed
14
+ limits :projects, to: 5 # Limits: 0 by default unless a limit is set explicitly
12
15
  end
13
16
  ```
14
17
 
18
+ Plans are **secure by default**: features are disabled and limits are set to 0 unless explicitly configured.
19
+
15
20
  You can then gate features in your controllers:
16
21
  ```ruby
17
22
  before_action :enforce_api_access!, only: [:create]
@@ -97,6 +102,17 @@ You can also display upgrade alerts to prompt users into upgrading to the next p
97
102
 
98
103
  ![pricing_plans Ruby on Rails gem - pricing plan upgrade prompt](/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg)
99
104
 
105
+ You can attach arbitrary plan `metadata` for UI/presentation needs (icons, colors, badges) directly in the initializer:
106
+
107
+ ```ruby
108
+ plan :hobby do
109
+ metadata icon: "rocket", color: "bg-red-500"
110
+ end
111
+
112
+ plan.metadata[:icon] # => "rocket"
113
+ ```
114
+
115
+ 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
116
 
101
117
  ## 🤓 Read the docs!
102
118
 
@@ -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
  ```
@@ -299,6 +300,7 @@ PricingPlans.configure do |config|
299
300
  name "Free Plan" # optional, would default to "Free" as inferred from the :free key
300
301
  description "A plan to get you started"
301
302
  bullets "Basic features", "Community support"
303
+ metadata icon: "rocket", color: "bg-red-500"
302
304
 
303
305
  cta_text "Subscribe"
304
306
  # In initializers, prefer a string path/URL or set a global default CTA in config.
@@ -314,6 +316,61 @@ end
314
316
 
315
317
  You can also make a plan `default!`; and you can make a plan `highlighted!` to help you when building a pricing table.
316
318
 
319
+ ### Plan metadata for UI and presentation
320
+
321
+ You can attach arbitrary `metadata` to a plan for presentation needs (for example, per-card icons or colors on a pricing page). This keeps plan UI details co-located in the same DSL rather than scattered elsewhere:
322
+
323
+ ```ruby
324
+ plan :hobby do
325
+ metadata icon: "rocket", color: "bg-red-500"
326
+ end
327
+
328
+ plan.metadata[:icon] # => "rocket"
329
+ ```
330
+
331
+ ### Hide plans from public lists
332
+
333
+ 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.
334
+
335
+ **Use cases for hidden plans:**
336
+ - **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
337
+ - **Grandfathered plans**: Old plans you no longer offer to new customers, but existing users still have
338
+ - **Internal/testing plans**: Plans for employees, beta testers, or special partnerships
339
+ - **Deprecated plans**: Plans being phased out but still active for some users
340
+
341
+ ```ruby
342
+ PricingPlans.configure do |config|
343
+ # Hidden default plan for users who haven't subscribed
344
+ # It won't appear on pricing page
345
+ # This is what users are on before they subscribe to any plan
346
+ plan :unsubscribed do
347
+ price 0
348
+ hidden! # Won't appear on pricing page
349
+ default!
350
+ # No limits defined - everything defaults to 0 (blocked)
351
+ end
352
+
353
+ # Visible plans for your pricing page
354
+ plan :starter do
355
+ price 10
356
+ limit :projects, to: 5
357
+ end
358
+
359
+ # Grandfathered plan (hidden from new customers)
360
+ plan :legacy_2020 do
361
+ price 15
362
+ hidden! # Existing customers keep it, but won't show on pricing page
363
+ limit :projects, to: 100
364
+ end
365
+ end
366
+ ```
367
+
368
+ **Important notes:**
369
+ - Hidden plans can be the `default!` plan (common pattern for "unsubscribed" users)
370
+ - Hidden plans **cannot** be the `highlighted!` plan (validation error - highlighted plans must be visible)
371
+ - Users can still be on hidden plans (via Pay subscription, manual assignment, or default)
372
+ - Internal APIs (`Registry.plans`, `PlanResolver`) can still access hidden plans
373
+ - Pay gem can still resolve subscriptions to hidden plans (useful for grandfathered customers)
317
374
 
318
375
  ## Link paid plans to Stripe prices (requires `pay`)
319
376
 
@@ -369,4 +426,4 @@ plan :enterprise do
369
426
  unlimited :products
370
427
  allows :api_access, :premium_features
371
428
  end
372
- ```
429
+ ```
data/docs/04-views.md CHANGED
@@ -18,6 +18,7 @@ Each `PricingPlans::Plan` responds to:
18
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.
19
19
  - `plan.cta_text`
20
20
  - `plan.cta_url`
21
+ - `plan.metadata` → Optional hash for UI/presentation attributes (icons, colors, badges)
21
22
 
22
23
  ### Example: build a pricing page
23
24
 
@@ -118,4 +119,4 @@ Tip: you could also use `plan_limit_remaining(:projects)` and `plan_limit_percen
118
119
 
119
120
  ## Message customization
120
121
 
121
- - You can override copy globally via `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md), which is used across limit checks and features. Suggested signature: `(context:, **kwargs) -> string` with contexts `:over_limit`, `:grace`, `:feature_denied`, and `:overage_report`.
122
+ - You can override copy globally via `config.message_builder` in [`pricing_plans.rb`](/docs/01-define-pricing-plans.md), which is used across limit checks and features. Suggested signature: `(context:, **kwargs) -> string` with contexts `:over_limit`, `:grace`, `:feature_denied`, and `:overage_report`.
@@ -1,12 +1,39 @@
1
1
  # Using `pricing_plans` with `pay` and/or `usage_credits`
2
2
 
3
- `pricing_plans` is designed to work seamlessly with other complementary popular gems like `pay` (to handle actual subscriptions and payments), and `usage_credits` (to handle credit-like spending and refills)
3
+ `pricing_plans` is designed to work seamlessly with other complementary popular gems like [`pay`](https://github.com/pay-rails/pay) (to handle actual subscriptions and payments), and `usage_credits` (to handle credit-like spending and refills)
4
4
 
5
- These gems are related but not overlapping. They're complementary. The boundaries are clear: billing is handled in Pay; metering (ledger-like) in usage_credits.
5
+ These gems are related but not overlapping. They're complementary. The boundaries are:
6
+ - [`pay`](https://github.com/pay-rails/pay) handles billing
7
+ - [`usage_credits`](https://github.com/rameerez/usage_credits/) handles user credits (metered usage through credits, ledger-like)
6
8
 
7
- The integration with `pay` should be seamless and is documented throughout the entire docs; however, here's a brief note about using `usage_credits` alongside `pricing_plans`.
9
+ ## `pay` gem
8
10
 
9
- ## Using `pricing_plans` with the `usage_credits` gem
11
+ The integration with the `pay` gem should be seamless and is documented throughout the entire docs; however, to make it explicit:
12
+
13
+ There's nothing to do on your end to make `pricing_plans` work with `pay`!
14
+
15
+ As long as your `pricing_plans` config (`config/initializers/pricing_plans.rb`) contains a plan with the correct `stripe_price` ID, whenever a subscription to that Stripe price ID is found through the `pay` gem, `pricing_plans` will understand the user is subscribed to that plan automatically, and will start enforcing the corresponding limits.
16
+
17
+ The way `pricing_plans` works doesn't require any data migration, or callback setup, or any manual action. You don't need to call `assign_pricing_plan!` at all at any point, unless you're trying to something like overriding a plan, gifting users access to plans without any payment, or things like that.
18
+
19
+ As long as a matching `stripe_price` is found in the `pricing_plans.rb` initializer, the gem will know a user subscribed to that Stripe price ID is under the corresponding plan. Essentially, the gem just looks at the current `pay` subscriptions of your user. If a matching price ID is found in the `pricing_plans` configuration file, it enforces the corresponding limits.
20
+
21
+ > [!TIP]
22
+ > To make your `pricing_plans` gem config work across environments (production, development, etc.) instead of defining price IDs statically like this in the config:
23
+ >
24
+ > ```ruby
25
+ > stripe_price month: "price_123", year: "price_456"
26
+ > ```
27
+ >
28
+ > Try instead defining them dynamically using `Rails.env`, so the corresponding plan for each environment gets loaded automatically. A simple solution would be to define your plans in the credentials file, and then doing something like this in the `pricing_plans` config:
29
+ >
30
+ > ```ruby
31
+ > stripe_price month: Rails.application.credentials.dig(Rails.env.to_sym, :stripe_plans, :plan_name, :monthly), year: Rails.application.credentials.dig(Rails.env.to_sym, :stripe_plans, :plan_name, :yearly)
32
+ > ```
33
+ >
34
+ > You can come up with similar solutions, like adding that config to a plaintext `.yml` file if you don't want to store this info in the credentials file, but this is the overall idea.
35
+
36
+ ## `usage_credits` gem
10
37
 
11
38
  In the SaaS world, pricing plans and usage credits are related in so far credits are usually a part of a pricing plan. A plan would give you, say, 100 credits a month along other features, and users would find that information usually documented in the pricing table itself.
12
39
 
@@ -0,0 +1,23 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.3"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "irb"
11
+ gem "rubocop", "~> 1.0"
12
+ gem "rubocop-minitest", "~> 0.35"
13
+ gem "rubocop-performance", "~> 1.0"
14
+ end
15
+
16
+ group :test do
17
+ gem "minitest", "~> 5.0"
18
+ gem "sqlite3", "~> 2.1"
19
+ gem "ostruct"
20
+ gem "simplecov", require: false
21
+ end
22
+
23
+ gemspec path: "../"
@@ -0,0 +1,23 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.2"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "irb"
11
+ gem "rubocop", "~> 1.0"
12
+ gem "rubocop-minitest", "~> 0.35"
13
+ gem "rubocop-performance", "~> 1.0"
14
+ end
15
+
16
+ group :test do
17
+ gem "minitest", "~> 5.0"
18
+ gem "sqlite3", "~> 2.1"
19
+ gem "ostruct"
20
+ gem "simplecov", require: false
21
+ end
22
+
23
+ gemspec path: "../"
@@ -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,12 +9,14 @@ 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
- # Optional global resolver for controller plan owner. Per-controller settings still win.
14
- # Accepts:
15
- # - Symbol: a controller helper to call (e.g., :current_organization)
16
- # - Proc: instance-exec'd in the controller (self is the controller)
17
- attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
15
+ # Optional global resolver for controller plan owner. Per-controller settings still win.
16
+ # Accepts:
17
+ # - Symbol: a controller helper to call (e.g., :current_organization)
18
+ # - Proc: instance-exec'd in the controller (self is the controller)
19
+ attr_reader :controller_plan_owner_method, :controller_plan_owner_proc
18
20
  # When a limit check blocks, controllers can redirect to a global default target.
19
21
  # Accepts:
20
22
  # - Symbol: a controller helper to call (e.g., :pricing_path)
@@ -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
@@ -138,6 +139,9 @@ module PricingPlans
138
139
  end
139
140
  end
140
141
 
142
+ alias_method :set_metadata, :set_meta
143
+ alias_method :metadata, :meta
144
+
141
145
  # CTA helpers for pricing UI
142
146
  def set_cta_text(value)
143
147
  @cta_text = value&.to_s
@@ -168,7 +172,7 @@ module PricingPlans
168
172
  default = PricingPlans.configuration.default_cta_url
169
173
  return default if default
170
174
  # New default: if host app defines subscribe_path, prefer that
171
- if defined?(Rails) && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
175
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application && Rails.application.routes.url_helpers.respond_to?(:subscribe_path)
172
176
  return Rails.application.routes.url_helpers.subscribe_path(plan: key, interval: :month)
173
177
  end
174
178
  nil
@@ -275,6 +279,14 @@ module PricingPlans
275
279
  false
276
280
  end
277
281
 
282
+ def hidden!(value = true)
283
+ @hidden = !!value
284
+ end
285
+
286
+ def hidden?
287
+ !!@hidden
288
+ end
289
+
278
290
  # Syntactic sugar for popular/highlighted
279
291
  def popular?
280
292
  highlighted?
@@ -471,6 +483,7 @@ module PricingPlans
471
483
  name: name,
472
484
  description: description,
473
485
  features: bullets, # alias in this gem
486
+ metadata: metadata.dup,
474
487
  highlighted: highlighted?,
475
488
  default: default?,
476
489
  free: free?,
@@ -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.1"
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)
@@ -130,11 +135,13 @@ module PricingPlans
130
135
  is_current = plan_owner ? (PlanResolver.effective_plan_for(plan_owner)&.key == plan.key) : false
131
136
  is_popular = Registry.highlighted_plan&.key == plan.key
132
137
  price_label = plan_price_label_for(plan)
138
+ # Duplicate metadata to avoid mutating plan internals from view-layer code.
133
139
  {
134
140
  key: plan.key,
135
141
  name: plan.name,
136
142
  description: plan.description,
137
143
  bullets: plan.bullets,
144
+ metadata: plan.metadata.dup,
138
145
  price_label: price_label,
139
146
  is_current: is_current,
140
147
  is_popular: is_popular,
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.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-12-26 00:00:00.000000000 Z
10
+ date: 2026-01-15 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -49,104 +49,6 @@ dependencies:
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
52
- - !ruby/object:Gem::Dependency
53
- name: bundler
54
- requirement: !ruby/object:Gem::Requirement
55
- requirements:
56
- - - "~>"
57
- - !ruby/object:Gem::Version
58
- version: '2.0'
59
- type: :development
60
- prerelease: false
61
- version_requirements: !ruby/object:Gem::Requirement
62
- requirements:
63
- - - "~>"
64
- - !ruby/object:Gem::Version
65
- version: '2.0'
66
- - !ruby/object:Gem::Dependency
67
- name: rake
68
- requirement: !ruby/object:Gem::Requirement
69
- requirements:
70
- - - "~>"
71
- - !ruby/object:Gem::Version
72
- version: '13.0'
73
- type: :development
74
- prerelease: false
75
- version_requirements: !ruby/object:Gem::Requirement
76
- requirements:
77
- - - "~>"
78
- - !ruby/object:Gem::Version
79
- version: '13.0'
80
- - !ruby/object:Gem::Dependency
81
- name: minitest
82
- requirement: !ruby/object:Gem::Requirement
83
- requirements:
84
- - - "~>"
85
- - !ruby/object:Gem::Version
86
- version: '5.0'
87
- type: :development
88
- prerelease: false
89
- version_requirements: !ruby/object:Gem::Requirement
90
- requirements:
91
- - - "~>"
92
- - !ruby/object:Gem::Version
93
- version: '5.0'
94
- - !ruby/object:Gem::Dependency
95
- name: sqlite3
96
- requirement: !ruby/object:Gem::Requirement
97
- requirements:
98
- - - "~>"
99
- - !ruby/object:Gem::Version
100
- version: '2.1'
101
- type: :development
102
- prerelease: false
103
- version_requirements: !ruby/object:Gem::Requirement
104
- requirements:
105
- - - "~>"
106
- - !ruby/object:Gem::Version
107
- version: '2.1'
108
- - !ruby/object:Gem::Dependency
109
- name: rubocop
110
- requirement: !ruby/object:Gem::Requirement
111
- requirements:
112
- - - "~>"
113
- - !ruby/object:Gem::Version
114
- version: '1.0'
115
- type: :development
116
- prerelease: false
117
- version_requirements: !ruby/object:Gem::Requirement
118
- requirements:
119
- - - "~>"
120
- - !ruby/object:Gem::Version
121
- version: '1.0'
122
- - !ruby/object:Gem::Dependency
123
- name: rubocop-minitest
124
- requirement: !ruby/object:Gem::Requirement
125
- requirements:
126
- - - "~>"
127
- - !ruby/object:Gem::Version
128
- version: '0.35'
129
- type: :development
130
- prerelease: false
131
- version_requirements: !ruby/object:Gem::Requirement
132
- requirements:
133
- - - "~>"
134
- - !ruby/object:Gem::Version
135
- version: '0.35'
136
- - !ruby/object:Gem::Dependency
137
- name: rubocop-performance
138
- requirement: !ruby/object:Gem::Requirement
139
- requirements:
140
- - - "~>"
141
- - !ruby/object:Gem::Version
142
- version: '1.0'
143
- type: :development
144
- prerelease: false
145
- version_requirements: !ruby/object:Gem::Requirement
146
- requirements:
147
- - - "~>"
148
- - !ruby/object:Gem::Version
149
- version: '1.0'
150
52
  description: Define and enforce pricing plan limits in your Rails SaaS (entitlements,
151
53
  quotas, feature gating). pricing_plans acts as your single source of truth for pricing
152
54
  plans. Define a pricing catalog with feature gating, persistent caps, per‑period
@@ -161,6 +63,7 @@ extra_rdoc_files: []
161
63
  files:
162
64
  - ".claude/settings.local.json"
163
65
  - ".rubocop.yml"
66
+ - Appraisals
164
67
  - CHANGELOG.md
165
68
  - CLAUDE.md
166
69
  - LICENSE.txt
@@ -176,6 +79,8 @@ files:
176
79
  - docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg
177
80
  - docs/images/pricing_plans_ruby_rails_gem_usage_meter.jpg
178
81
  - docs/images/product_creation_blocked.jpg
82
+ - gemfiles/rails_7.2.gemfile
83
+ - gemfiles/rails_8.1.gemfile
179
84
  - lib/generators/pricing_plans/install/install_generator.rb
180
85
  - lib/generators/pricing_plans/install/templates/create_pricing_plans_tables.rb.erb
181
86
  - lib/generators/pricing_plans/install/templates/initializer.rb