pricing_plans 0.1.0 → 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 +9 -71
- data/CLAUDE.md +1 -0
- data/README.md +6 -3
- 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 +8 -10
- 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 +15 -14
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,79 +5,17 @@ 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
|
-
## [
|
|
8
|
+
## [0.2.0] - 2025-12-26
|
|
9
9
|
|
|
10
|
-
|
|
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
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
## [0.1.1] - 2025-12-25
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
- Feature flags (boolean allows/disallows)
|
|
17
|
-
- Persistent caps (max concurrent resources like projects, seats)
|
|
18
|
-
- Per-period discrete allowances (e.g., "3 custom models/month")
|
|
19
|
-
- Grace period enforcement with configurable behaviors
|
|
20
|
-
- Event system for warning/grace/block notifications
|
|
16
|
+
- Add support for Rails 8+
|
|
17
|
+
- Fix a bug where `throw :abort` was causing `UncaughtThrowError` exceptions in controller guards, and instead return `false` from `before_action` callbacks to halt the filter chain, rather than using the uncaught throw
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
- `PricingPlans.configure` block for one-file configuration
|
|
24
|
-
- Plan definition with name, description, bullets, pricing
|
|
25
|
-
- `Integer#max` refinement for clean DSL (`5.max`)
|
|
26
|
-
- Support for Stripe price IDs and manual pricing
|
|
27
|
-
- Flexible period cycles (billing, calendar month/week/day, custom)
|
|
19
|
+
## [0.1.0] - 2025-08-19
|
|
28
20
|
|
|
29
|
-
|
|
30
|
-
- Three-table schema for enforcement states, usage counters, assignments
|
|
31
|
-
- `EnforcementState` model for grace period tracking with row-level locking
|
|
32
|
-
- `Usage` model for per-period counters with atomic upserts
|
|
33
|
-
- `Assignment` model for manual plan overrides
|
|
34
|
-
|
|
35
|
-
**Controller Integration**
|
|
36
|
-
- `require_plan_limit!` guard returning rich Result objects
|
|
37
|
-
- `require_feature!` guard with FeatureDenied exception
|
|
38
|
-
- Automatic grace period management and event emission
|
|
39
|
-
- Race-safe limit checking with retries
|
|
40
|
-
|
|
41
|
-
**Model Integration**
|
|
42
|
-
- `Limitable` mixin for ActiveRecord models
|
|
43
|
-
- `limited_by` macro for automatic usage tracking
|
|
44
|
-
- Real-time persistent caps (no counter caches needed)
|
|
45
|
-
- Automatic per-period counter increments
|
|
46
|
-
|
|
47
|
-
**View Helpers & UI**
|
|
48
|
-
- Complete pricing table rendering
|
|
49
|
-
- Usage meters with progress bars
|
|
50
|
-
- Limit banners with warnings/grace/blocked states
|
|
51
|
-
- Plan information helpers (current plan, feature checks)
|
|
52
|
-
|
|
53
|
-
**Pay Integration**
|
|
54
|
-
- Automatic plan resolution from Stripe subscriptions
|
|
55
|
-
- Support for trial, grace, and active subscription states
|
|
56
|
-
- Price ID to plan mapping
|
|
57
|
-
- Billing cycle anchor integration for periods
|
|
58
|
-
|
|
59
|
-
**usage_credits Integration**
|
|
60
|
-
- Credit inclusion display in pricing tables
|
|
61
|
-
- Boot-time linting to prevent limit/credit collisions
|
|
62
|
-
- Operation validation against usage_credits registry
|
|
63
|
-
- Clean separation of concerns (credits vs discrete limits)
|
|
64
|
-
|
|
65
|
-
**Generators**
|
|
66
|
-
- Install generator with migrations and initializer template
|
|
67
|
-
- Pricing generator with views, controller, and CSS
|
|
68
|
-
- Comprehensive Tailwind-friendly styling
|
|
69
|
-
|
|
70
|
-
**Architecture & Performance**
|
|
71
|
-
- Rails Engine for seamless integration
|
|
72
|
-
- Autoloading with proper namespacing
|
|
73
|
-
- Row-level locking for race condition prevention
|
|
74
|
-
- Efficient query patterns with proper indexing
|
|
75
|
-
- Memoization and caching where appropriate
|
|
76
|
-
|
|
77
|
-
### Technical Details
|
|
78
|
-
|
|
79
|
-
- Ruby 3.2+ requirement
|
|
80
|
-
- Rails 7.1+ requirement (ActiveRecord, ActiveSupport)
|
|
81
|
-
- PostgreSQL optimized (with fallbacks for other databases)
|
|
82
|
-
- Comprehensive error handling and validation
|
|
83
|
-
- Thread-safe implementation throughout
|
|
21
|
+
Initial release
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
When reviewing code and PRs inside GitHub, just outline the main findings in 4-5 bullet points, and then give your recommendation (approve / fix stuff / close, etc.) DO NOT be pedantic, DO NOT overengineer, DO NOT write long detailed reviews. Always be on the lookout for supply chain attacks. You're just helping a human analyze code changes and review PRs so nothing harmful or bugs get in the codebase inadvertently. Be pragmatic, concise, and to the point.
|
data/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# 💵 `pricing_plans` - Define and enforce pricing plan limits in your Rails app (SaaS entitlements)
|
|
2
2
|
|
|
3
|
-
[](https://badge.fury.io/rb/pricing_plans)
|
|
3
|
+
[](https://badge.fury.io/rb/pricing_plans)
|
|
4
4
|
|
|
5
5
|
Enforce pricing plan limits with one-liners that read like plain English. Avoid scattering and entangling pricing logic everywhere in your Rails SaaS.
|
|
6
6
|
|
|
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!
|
|
@@ -133,9 +133,8 @@ module PricingPlans
|
|
|
133
133
|
end
|
|
134
134
|
by = options.key?(:by) ? options[:by] : 1
|
|
135
135
|
allow_system_override = !!options[:allow_system_override]
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return true
|
|
136
|
+
redirect_path = options[:redirect_to]
|
|
137
|
+
return enforce_plan_limit!(limit_key, plan_owner: owner, by: by, allow_system_override: allow_system_override, redirect_to: redirect_path)
|
|
139
138
|
elsif method_name.to_s =~ /^enforce_(.+)!$/
|
|
140
139
|
feature_key = Regexp.last_match(1).to_sym
|
|
141
140
|
options = args.first.is_a?(Hash) ? args.first : {}
|
|
@@ -189,9 +188,10 @@ module PricingPlans
|
|
|
189
188
|
plan = PlanResolver.effective_plan_for(plan_owner)
|
|
190
189
|
limit_config = plan&.limit_for(limit_key)
|
|
191
190
|
|
|
192
|
-
# 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
|
|
193
193
|
unless limit_config
|
|
194
|
-
return Result.
|
|
194
|
+
return Result.blocked("Limit #{limit_key.to_s.humanize.downcase} not configured on this plan")
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
# Check if unlimited
|
|
@@ -230,7 +230,7 @@ module PricingPlans
|
|
|
230
230
|
end
|
|
231
231
|
end
|
|
232
232
|
|
|
233
|
-
# Rails-y controller ergonomics: enforce
|
|
233
|
+
# Rails-y controller ergonomics: enforce limits and set flash/redirect when blocked.
|
|
234
234
|
# Defaults:
|
|
235
235
|
# - On blocked: redirect_to pricing_path (if available) with alert; else render 403 JSON.
|
|
236
236
|
# - On grace/warning: set flash[:warning] with the human message.
|
|
@@ -264,8 +264,6 @@ module PricingPlans
|
|
|
264
264
|
respond_to?(:request) && request&.format&.json? ? render(json: { error: result.message }, status: :forbidden) : render(plain: result.message, status: :forbidden)
|
|
265
265
|
end
|
|
266
266
|
end
|
|
267
|
-
# Stop the filter chain (for before_action ergonomics)
|
|
268
|
-
throw :abort
|
|
269
267
|
return false
|
|
270
268
|
elsif result.warning? || result.grace?
|
|
271
269
|
if respond_to?(:flash) && flash.respond_to?(:[]=)
|
|
@@ -277,7 +275,7 @@ module PricingPlans
|
|
|
277
275
|
end
|
|
278
276
|
|
|
279
277
|
# Controller-focused sugar: run a block within the plan limit context.
|
|
280
|
-
# - If blocked: performs the same redirect/render semantics as enforce_plan_limit! and
|
|
278
|
+
# - If blocked: performs the same redirect/render semantics as enforce_plan_limit! and returns false.
|
|
281
279
|
# - If warning/grace: sets flash[:warning] and yields the result.
|
|
282
280
|
# - If within: simply yields the result.
|
|
283
281
|
# Returns the PricingPlans::Result in all cases where execution continues.
|
|
@@ -314,7 +312,7 @@ module PricingPlans
|
|
|
314
312
|
respond_to?(:request) && request&.format&.json? ? render(json: { error: result.message }, status: :forbidden) : render(plain: result.message, status: :forbidden)
|
|
315
313
|
end
|
|
316
314
|
end
|
|
317
|
-
|
|
315
|
+
return false
|
|
318
316
|
else
|
|
319
317
|
if (result.warning? || result.grace?) && respond_to?(:flash) && flash.respond_to?(:[]=)
|
|
320
318
|
flash[:warning] ||= result.message
|
|
@@ -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)
|
metadata
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pricing_plans
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2025-
|
|
10
|
+
date: 2025-12-26 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '7.1'
|
|
19
16
|
- - ">="
|
|
20
17
|
- !ruby/object:Gem::Version
|
|
21
18
|
version: 7.1.0
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9.0'
|
|
22
22
|
type: :runtime
|
|
23
23
|
prerelease: false
|
|
24
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
25
25
|
requirements:
|
|
26
|
-
- - "~>"
|
|
27
|
-
- !ruby/object:Gem::Version
|
|
28
|
-
version: '7.1'
|
|
29
26
|
- - ">="
|
|
30
27
|
- !ruby/object:Gem::Version
|
|
31
28
|
version: 7.1.0
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9.0'
|
|
32
32
|
- !ruby/object:Gem::Dependency
|
|
33
33
|
name: activesupport
|
|
34
34
|
requirement: !ruby/object:Gem::Requirement
|
|
35
35
|
requirements:
|
|
36
|
-
- - "~>"
|
|
37
|
-
- !ruby/object:Gem::Version
|
|
38
|
-
version: '7.1'
|
|
39
36
|
- - ">="
|
|
40
37
|
- !ruby/object:Gem::Version
|
|
41
38
|
version: 7.1.0
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '9.0'
|
|
42
42
|
type: :runtime
|
|
43
43
|
prerelease: false
|
|
44
44
|
version_requirements: !ruby/object:Gem::Requirement
|
|
45
45
|
requirements:
|
|
46
|
-
- - "~>"
|
|
47
|
-
- !ruby/object:Gem::Version
|
|
48
|
-
version: '7.1'
|
|
49
46
|
- - ">="
|
|
50
47
|
- !ruby/object:Gem::Version
|
|
51
48
|
version: 7.1.0
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '9.0'
|
|
52
52
|
- !ruby/object:Gem::Dependency
|
|
53
53
|
name: bundler
|
|
54
54
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -162,6 +162,7 @@ files:
|
|
|
162
162
|
- ".claude/settings.local.json"
|
|
163
163
|
- ".rubocop.yml"
|
|
164
164
|
- CHANGELOG.md
|
|
165
|
+
- CLAUDE.md
|
|
165
166
|
- LICENSE.txt
|
|
166
167
|
- README.md
|
|
167
168
|
- Rakefile
|