pricing_plans 0.2.0 → 0.3.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/.simplecov +35 -0
- data/AGENTS.md +5 -0
- data/Appraisals +9 -0
- data/CHANGELOG.md +10 -5
- data/CLAUDE.md +5 -1
- data/README.md +16 -3
- data/docs/01-define-pricing-plans.md +120 -16
- data/docs/03-model-helpers.md +59 -0
- data/docs/04-views.md +2 -1
- data/docs/06-gem-compatibility.md +31 -4
- data/gemfiles/rails_7.2.gemfile +25 -0
- data/gemfiles/rails_8.1.gemfile +25 -0
- data/lib/generators/pricing_plans/install/templates/initializer.rb +38 -4
- data/lib/pricing_plans/callbacks.rb +152 -0
- data/lib/pricing_plans/configuration.rb +23 -11
- data/lib/pricing_plans/limitable.rb +38 -1
- data/lib/pricing_plans/models/enforcement_state.rb +1 -1
- data/lib/pricing_plans/period_calculator.rb +6 -0
- data/lib/pricing_plans/plan.rb +5 -1
- data/lib/pricing_plans/plan_owner.rb +70 -0
- data/lib/pricing_plans/plan_resolver.rb +13 -13
- data/lib/pricing_plans/registry.rb +2 -2
- data/lib/pricing_plans/status_context.rb +343 -0
- data/lib/pricing_plans/version.rb +1 -1
- data/lib/pricing_plans.rb +52 -10
- metadata +9 -101
- data/.claude/settings.local.json +0 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 476af1d2c2bd25790361bdee91c8419a257eb50dd4584bd5d0ab76d388e4a4ca
|
|
4
|
+
data.tar.gz: ca9216c59eb3f587bf7f30d104747fb2bb06b5e90dbccaa1b02e7a2e77ee324b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 531d11fc0ad0d0e11de9e64f19c7ffd9a735fe9d08178f7d7661807c6fc97fd1c153c7c0f9c4bac781332ef48eb9e22eecac87b54a95931ab9dde8065bce40be
|
|
7
|
+
data.tar.gz: 2566b65c8ccbfc98dabf7e974dd93ae23a295105f960dae14a250a531b10edf5c2e73d442e9707d5e152bafacc5028734dc8a60b00d48b6386434b29691fef20
|
data/.simplecov
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# SimpleCov configuration file (auto-loaded before test suite)
|
|
4
|
+
# This keeps test_helper.rb clean and follows best practices
|
|
5
|
+
|
|
6
|
+
SimpleCov.start do
|
|
7
|
+
# Use SimpleFormatter for terminal-only output (no HTML generation)
|
|
8
|
+
formatter SimpleCov::Formatter::SimpleFormatter
|
|
9
|
+
|
|
10
|
+
# Track coverage for the lib directory (gem source code)
|
|
11
|
+
add_filter "/test/"
|
|
12
|
+
|
|
13
|
+
# Track the lib and app directories
|
|
14
|
+
track_files "{lib,app}/**/*.rb"
|
|
15
|
+
|
|
16
|
+
# Enable branch coverage for more detailed metrics
|
|
17
|
+
enable_coverage :branch
|
|
18
|
+
|
|
19
|
+
# Set minimum coverage threshold to prevent coverage regression
|
|
20
|
+
minimum_coverage line: 80, branch: 65
|
|
21
|
+
|
|
22
|
+
# Disambiguate parallel test runs
|
|
23
|
+
command_name "Job #{ENV['TEST_ENV_NUMBER']}" if ENV['TEST_ENV_NUMBER']
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Print coverage summary to terminal after tests complete
|
|
27
|
+
SimpleCov.at_exit do
|
|
28
|
+
SimpleCov.result.format!
|
|
29
|
+
puts "\n" + "=" * 60
|
|
30
|
+
puts "COVERAGE SUMMARY"
|
|
31
|
+
puts "=" * 60
|
|
32
|
+
puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
|
|
33
|
+
puts "Branch Coverage: #{SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || 'N/A'}%"
|
|
34
|
+
puts "=" * 60
|
|
35
|
+
end
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/Appraisals
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
## [0.3.0] - 2026-02-15
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
- **Manual assignments now override subscriptions**: Admin overrides take precedence over Pay/Stripe plans (was incorrectly reversed) -- current plan resolution order: manual assignment → Pay subscription → default plan
|
|
4
|
+
- **Fix N+1 queries when checking status**: Request-scoped caching eliminates N+1 queries in `status()` calls (~85% query reduction)
|
|
5
|
+
- **Add automatic callbacks**: `on_limit_warning`, `on_limit_exceeded`, `on_grace_start`, `on_block` now fire automatically when limits change
|
|
6
|
+
- **Add useful admin scopes**: `within_all_limits`, `exceeding_any_limit`, `in_grace_period`, `blocked` for dashboard queries
|
|
7
|
+
- **EnforcementState uniqueness**: Fixed overly strict validation that blocked multi-limit scenarios
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
## [0.2.1] - 2026-01-15
|
|
10
|
+
|
|
11
|
+
- Added a `metadata` alias to plans, and documented its usage
|
|
7
12
|
|
|
8
13
|
## [0.2.0] - 2025-12-26
|
|
9
14
|
|
|
@@ -18,4 +23,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
18
23
|
|
|
19
24
|
## [0.1.0] - 2025-08-19
|
|
20
25
|
|
|
21
|
-
Initial release
|
|
26
|
+
Initial release
|
data/CLAUDE.md
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
Please go ahead and read the full context for this project at `.cursor/rules/0-overview.mdc` and `.cursor/rules/1-quality.mdc` now. Also read the README for a good overview of the project.
|
data/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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) [](https://github.com/rameerez/pricing_plans/actions)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
> [!TIP]
|
|
6
|
+
> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=pricing_plans)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=pricing_plans)!
|
|
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
|
|
@@ -99,6 +102,16 @@ You can also display upgrade alerts to prompt users into upgrading to the next p
|
|
|
99
102
|
|
|
100
103
|

|
|
101
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
|
+
|
|
102
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!
|
|
103
116
|
|
|
104
117
|
## 🤓 Read the docs!
|
|
@@ -151,7 +164,7 @@ The `pricing_plans` gem needs three new models in the schema in order to work: `
|
|
|
151
164
|
|
|
152
165
|
- `PricingPlans::Assignment` allow manual plan overrides independent of billing system (or before you wire up Stripe/Pay). Great for admin toggles, trials, demos.
|
|
153
166
|
- What: The arbitrary `plan_key` and a `source` label (default "manual"). Unique per plan_owner.
|
|
154
|
-
- How it
|
|
167
|
+
- How it's used: `PlanResolver` checks manual assignment → Pay → default plan. Manual assignments (admin overrides) take precedence over subscription-based plans. You can call `assign_pricing_plan!` and `remove_pricing_plan!` on the plan_owner.
|
|
155
168
|
|
|
156
169
|
- `PricingPlans::EnforcementState` tracks per-plan_owner per-limit enforcement state for persistent caps and per-period allowances (grace/warnings/block state) in a race-safe way.
|
|
157
170
|
- What: `exceeded_at`, `blocked_at`, last warning info, and a small JSON `data` column where we persist plan-derived parameters like grace period seconds.
|
|
@@ -205,39 +205,128 @@ travel_to(Time.parse("2025-02-01 12:00:00 UTC")) do
|
|
|
205
205
|
end
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
-
###
|
|
208
|
+
### Callbacks
|
|
209
209
|
|
|
210
|
-
|
|
210
|
+
`pricing_plans` provides you with a few useful callbacks. Callbacks allow you to set thresholds to warn our users when important things happen. For example, when they're halfway through their limit, approaching the limit, etc.
|
|
211
|
+
|
|
212
|
+
You can use callbacks for:
|
|
213
|
+
|
|
214
|
+
- **Upsell emails**: "You've used 80% of your Pro plan - upgrade for more!"
|
|
215
|
+
- **Usage alerts**: "You're approaching your API request limit"
|
|
216
|
+
- **Grace period warnings**: "You've exceeded your limit. Upgrade within 7 days."
|
|
217
|
+
- **Churn prevention**: Proactively reach out before users hit walls
|
|
218
|
+
|
|
219
|
+
Callbacks fire **automatically** when limited models are created, you just need to define them like this:
|
|
211
220
|
|
|
212
221
|
```ruby
|
|
222
|
+
# config/initializers/pricing_plans.rb
|
|
213
223
|
PricingPlans.configure do |config|
|
|
214
|
-
plan :
|
|
215
|
-
|
|
224
|
+
plan :pro do
|
|
225
|
+
limits :licenses, to: 100, warn_at: [0.8, 0.95], after_limit: :grace_then_block, grace: 7.days
|
|
226
|
+
limits :activations, to: 300, warn_at: [0.8, 0.95], after_limit: :grace_then_block, grace: 7.days
|
|
227
|
+
end
|
|
216
228
|
|
|
217
|
-
|
|
229
|
+
# Fires when usage crosses a warning threshold (80%, 95%, etc.)
|
|
230
|
+
config.on_warning(:licenses) do |plan_owner, limit_key, threshold|
|
|
231
|
+
# Example: "You've used 80% of your licenses"
|
|
232
|
+
UsageWarningMailer.approaching_limit(plan_owner, limit_key, threshold).deliver_later
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Fires when limit is exceeded and grace period begins
|
|
236
|
+
config.on_grace_start(:licenses) do |plan_owner, limit_key, grace_ends_at|
|
|
237
|
+
# Example: "You've hit your license limit. Upgrade within 7 days or service will be interrupted."
|
|
238
|
+
GracePeriodMailer.limit_exceeded(plan_owner, limit_key, grace_ends_at).deliver_later
|
|
239
|
+
end
|
|
218
240
|
|
|
219
|
-
|
|
241
|
+
# Fires when grace period expires and user is blocked
|
|
242
|
+
config.on_block(:licenses) do |plan_owner, limit_key|
|
|
243
|
+
# Example: "Your grace period has ended. Please upgrade to continue creating licenses."
|
|
244
|
+
BlockedMailer.access_blocked(plan_owner, limit_key).deliver_later
|
|
220
245
|
end
|
|
221
246
|
end
|
|
222
247
|
```
|
|
223
248
|
|
|
224
|
-
|
|
249
|
+
### Available callbacks
|
|
250
|
+
|
|
251
|
+
| Callback | When it fires | Arguments |
|
|
252
|
+
|----------|---------------|-----------|
|
|
253
|
+
| `on_warning(limit_key)` | When usage crosses a `warn_at` threshold | `plan_owner`, `limit_key`, `threshold` (e.g., 0.8) |
|
|
254
|
+
| `on_grace_start(limit_key)` | When limit is exceeded with `grace_then_block` | `plan_owner`, `limit_key`, `grace_ends_at` (Time) |
|
|
255
|
+
| `on_block(limit_key)` | When grace expires or with `:block_usage` policy | `plan_owner`, `limit_key` |
|
|
256
|
+
|
|
257
|
+
> **Note:** Callbacks now receive `limit_key` as the second argument. Both old and new signatures are supported for backward compatibility:
|
|
258
|
+
> ```ruby
|
|
259
|
+
> # Old signature (still works)
|
|
260
|
+
> config.on_warning(:projects) { |plan_owner, threshold| ... }
|
|
261
|
+
>
|
|
262
|
+
> # New signature (recommended)
|
|
263
|
+
> config.on_warning(:projects) { |plan_owner, limit_key, threshold| ... }
|
|
264
|
+
> ```
|
|
265
|
+
|
|
266
|
+
### Wildcard callbacks
|
|
267
|
+
|
|
268
|
+
You can also register a single callback that fires for **all** limits by omitting the `limit_key` argument:
|
|
225
269
|
|
|
226
270
|
```ruby
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
271
|
+
# Fires for ANY limit warning (projects, licenses, api_calls, etc.)
|
|
272
|
+
config.on_warning do |plan_owner, limit_key, threshold|
|
|
273
|
+
Analytics.track(plan_owner, "limit_warning", limit: limit_key, threshold: threshold)
|
|
230
274
|
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
When both specific and wildcard handlers are registered, **both fire** (specific first, then wildcard). This is useful for combining per-limit emails with universal analytics:
|
|
231
278
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
279
|
+
```ruby
|
|
280
|
+
# Specific: send targeted email for projects
|
|
281
|
+
config.on_warning(:projects) do |plan_owner, limit_key, threshold|
|
|
282
|
+
ProjectLimitMailer.warning(plan_owner, threshold).deliver_later
|
|
235
283
|
end
|
|
236
|
-
|
|
237
|
-
|
|
284
|
+
|
|
285
|
+
# Wildcard: log all warnings to analytics
|
|
286
|
+
config.on_warning do |plan_owner, limit_key, threshold|
|
|
287
|
+
Analytics.track(plan_owner, "limit_warning", limit: limit_key, threshold: threshold)
|
|
238
288
|
end
|
|
239
289
|
```
|
|
240
290
|
|
|
291
|
+
### Warning thresholds
|
|
292
|
+
|
|
293
|
+
Configure warning thresholds to get notified before users hit their limits:
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
limits :projects, to: 100, warn_at: [0.6, 0.8, 0.95]
|
|
297
|
+
# Fires at 60%, 80%, and 95% usage
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Each threshold fires only once per limit window (e.g., once per billing cycle for per-period limits).
|
|
301
|
+
|
|
302
|
+
### Error isolation
|
|
303
|
+
|
|
304
|
+
Callbacks are error-isolated, meaning that if your callback raises an exception, it won't break the model creation or any other operation. Errors are logged but don't propagate. This ensures your app keeps working even if your mailer or analytics service is down.
|
|
305
|
+
|
|
306
|
+
### Transaction safety
|
|
307
|
+
|
|
308
|
+
**Warning and grace callbacks** use `after_commit` hooks, so they only fire after the database transaction successfully commits. If a transaction rolls back, these callbacks won't fire.
|
|
309
|
+
|
|
310
|
+
**Block callbacks** fire during validation (before commit) because they indicate a failed creation attempt. This means block emails may be sent even if the surrounding transaction rolls back - which is correct behavior since the user did experience being blocked from creating the record.
|
|
311
|
+
|
|
312
|
+
### Performance considerations
|
|
313
|
+
|
|
314
|
+
Automatic callbacks run on every model creation for limited models. For each limit key configured on a model, the callback:
|
|
315
|
+
|
|
316
|
+
1. Resolves the plan owner
|
|
317
|
+
2. Fetches the effective plan
|
|
318
|
+
3. Calculates current usage (may involve a COUNT query)
|
|
319
|
+
4. Checks warning thresholds
|
|
320
|
+
5. Updates EnforcementState if needed
|
|
321
|
+
|
|
322
|
+
For most applications, this overhead is negligible. However, if you're doing high-volume batch inserts of limited models, consider:
|
|
323
|
+
|
|
324
|
+
- Using `insert_all` or raw SQL for bulk operations (bypasses callbacks)
|
|
325
|
+
- Temporarily disabling callbacks with `Model.skip_callback` during batch jobs
|
|
326
|
+
- Adding indexes on foreign keys used for counting (e.g., `add_index :projects, :organization_id`)
|
|
327
|
+
|
|
328
|
+
**That's it!** When a Pro user creates their 20th project (80% of 25), they get an upsell email. At 25, grace starts. When grace expires, they're blocked. Per-month limits like file uploads reset each billing cycle. All completely automatic with zero maintenance overhead.
|
|
329
|
+
|
|
241
330
|
If you only want a scope, like active projects, to count towards plan limits, you can do:
|
|
242
331
|
|
|
243
332
|
```ruby
|
|
@@ -300,6 +389,7 @@ PricingPlans.configure do |config|
|
|
|
300
389
|
name "Free Plan" # optional, would default to "Free" as inferred from the :free key
|
|
301
390
|
description "A plan to get you started"
|
|
302
391
|
bullets "Basic features", "Community support"
|
|
392
|
+
metadata icon: "rocket", color: "bg-red-500"
|
|
303
393
|
|
|
304
394
|
cta_text "Subscribe"
|
|
305
395
|
# In initializers, prefer a string path/URL or set a global default CTA in config.
|
|
@@ -315,6 +405,18 @@ end
|
|
|
315
405
|
|
|
316
406
|
You can also make a plan `default!`; and you can make a plan `highlighted!` to help you when building a pricing table.
|
|
317
407
|
|
|
408
|
+
### Plan metadata for UI and presentation
|
|
409
|
+
|
|
410
|
+
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:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
plan :hobby do
|
|
414
|
+
metadata icon: "rocket", color: "bg-red-500"
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
plan.metadata[:icon] # => "rocket"
|
|
418
|
+
```
|
|
419
|
+
|
|
318
420
|
### Hide plans from public lists
|
|
319
421
|
|
|
320
422
|
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.
|
|
@@ -328,6 +430,8 @@ You can mark a plan as `hidden!` to exclude it from public-facing plan lists (`P
|
|
|
328
430
|
```ruby
|
|
329
431
|
PricingPlans.configure do |config|
|
|
330
432
|
# Hidden default plan for users who haven't subscribed
|
|
433
|
+
# It won't appear on pricing page
|
|
434
|
+
# This is what users are on before they subscribe to any plan
|
|
331
435
|
plan :unsubscribed do
|
|
332
436
|
price 0
|
|
333
437
|
hidden! # Won't appear on pricing page
|
|
@@ -411,4 +515,4 @@ plan :enterprise do
|
|
|
411
515
|
unlimited :products
|
|
412
516
|
allows :api_access, :premium_features
|
|
413
517
|
end
|
|
414
|
-
```
|
|
518
|
+
```
|
data/docs/03-model-helpers.md
CHANGED
|
@@ -316,3 +316,62 @@ user.pay_subscription_active? # => true/false
|
|
|
316
316
|
user.pay_on_trial? # => true/false
|
|
317
317
|
user.pay_on_grace_period? # => true/false
|
|
318
318
|
```
|
|
319
|
+
|
|
320
|
+
## Admin dashboard scopes
|
|
321
|
+
|
|
322
|
+
The `PlanOwner` mixin provides class-level scopes for querying plan owners by their limits status. These are useful for building admin dashboards to find organizations that need attention.
|
|
323
|
+
|
|
324
|
+
### Available scopes
|
|
325
|
+
|
|
326
|
+
```ruby
|
|
327
|
+
# Find plan owners with any exceeded limit (includes grace period and blocked)
|
|
328
|
+
Organization.with_exceeded_limits
|
|
329
|
+
|
|
330
|
+
# Find plan owners that are blocked (grace period expired)
|
|
331
|
+
Organization.with_blocked_limits
|
|
332
|
+
|
|
333
|
+
# Find plan owners in grace period (exceeded but not yet blocked)
|
|
334
|
+
Organization.in_grace_period
|
|
335
|
+
|
|
336
|
+
# Find plan owners with no exceeded limits
|
|
337
|
+
Organization.within_all_limits
|
|
338
|
+
|
|
339
|
+
# Alias for with_exceeded_limits - plan owners needing attention
|
|
340
|
+
Organization.needing_attention
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### Chainable with ActiveRecord
|
|
344
|
+
|
|
345
|
+
These scopes are fully chainable with other ActiveRecord methods:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
# Find exceeded organizations created this month
|
|
349
|
+
Organization.with_exceeded_limits.where(created_at: 1.month.ago..)
|
|
350
|
+
|
|
351
|
+
# Paginate blocked organizations
|
|
352
|
+
Organization.with_blocked_limits.order(:created_at).limit(10)
|
|
353
|
+
|
|
354
|
+
# Count organizations in grace period
|
|
355
|
+
Organization.in_grace_period.count
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
### Example: Admin dashboard
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
# app/controllers/admin/dashboard_controller.rb
|
|
362
|
+
def show
|
|
363
|
+
@orgs_needing_attention = Organization.needing_attention.count
|
|
364
|
+
@orgs_in_grace = Organization.in_grace_period.count
|
|
365
|
+
@orgs_blocked = Organization.with_blocked_limits.count
|
|
366
|
+
@orgs_healthy = Organization.within_all_limits.count
|
|
367
|
+
end
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Performance note
|
|
371
|
+
|
|
372
|
+
For large tables, ensure you have the composite index on `enforcement_states`:
|
|
373
|
+
```ruby
|
|
374
|
+
add_index :pricing_plans_enforcement_states,
|
|
375
|
+
[:plan_owner_type, :plan_owner_id, :exceeded_at],
|
|
376
|
+
name: 'index_enforcement_states_on_owner_and_exceeded'
|
|
377
|
+
```
|
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
|
|
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
|
-
|
|
9
|
+
## `pay` gem
|
|
8
10
|
|
|
9
|
-
|
|
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,25 @@
|
|
|
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 "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "sqlite3", ">= 2.1"
|
|
21
|
+
gem "ostruct"
|
|
22
|
+
gem "simplecov", require: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,25 @@
|
|
|
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 "irb"
|
|
10
|
+
gem "rubocop", "~> 1.0"
|
|
11
|
+
gem "rubocop-minitest", "~> 0.35"
|
|
12
|
+
gem "rubocop-performance", "~> 1.0"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
group :development, :test do
|
|
16
|
+
gem "appraisal"
|
|
17
|
+
gem "minitest", "~> 6.0"
|
|
18
|
+
gem "minitest-mock"
|
|
19
|
+
gem "rack-test"
|
|
20
|
+
gem "sqlite3", ">= 2.1"
|
|
21
|
+
gem "ostruct"
|
|
22
|
+
gem "simplecov", require: false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
gemspec path: "../"
|
|
@@ -72,10 +72,44 @@ PricingPlans.configure do |config|
|
|
|
72
72
|
#`config.message_builder` lets apps override human copy for `:over_limit`, `:grace`, `:feature_denied`, and overage report; used broadly across guards/UX.
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
75
|
+
# ==========================================================================
|
|
76
|
+
# Automatic Callbacks (for upsell emails, analytics, etc.)
|
|
77
|
+
# ==========================================================================
|
|
78
|
+
#
|
|
79
|
+
# Callbacks fire AUTOMATICALLY when limited models are created - no manual
|
|
80
|
+
# intervention needed. Configure them to send emails when users approach
|
|
81
|
+
# or exceed their limits.
|
|
82
|
+
#
|
|
83
|
+
# Available callbacks:
|
|
84
|
+
# - on_warning(limit_key) - fires when usage crosses a warn_at threshold
|
|
85
|
+
# - on_grace_start(limit_key) - fires when limit is exceeded (grace period starts)
|
|
86
|
+
# - on_block(limit_key) - fires when grace expires or with :block_usage policy
|
|
87
|
+
#
|
|
88
|
+
# Example: Send upsell emails at 80% and 95% usage, then notify on grace/block
|
|
89
|
+
#
|
|
90
|
+
# config.on_warning(:projects) do |plan_owner, limit_key, threshold|
|
|
91
|
+
# # threshold is the crossed value, e.g., 0.8 for 80%
|
|
92
|
+
# percentage = (threshold * 100).to_i
|
|
93
|
+
# UsageMailer.approaching_limit(plan_owner, limit_key, percentage: percentage).deliver_later
|
|
94
|
+
# end
|
|
95
|
+
#
|
|
96
|
+
# config.on_grace_start(:projects) do |plan_owner, limit_key, grace_ends_at|
|
|
97
|
+
# # grace_ends_at is when the grace period expires
|
|
98
|
+
# GraceMailer.limit_exceeded(plan_owner, limit_key, grace_ends_at: grace_ends_at).deliver_later
|
|
99
|
+
# end
|
|
100
|
+
#
|
|
101
|
+
# config.on_block(:projects) do |plan_owner, limit_key|
|
|
102
|
+
# BlockedMailer.service_blocked(plan_owner, limit_key).deliver_later
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
# Wildcard callbacks - omit limit_key to catch all limits:
|
|
106
|
+
#
|
|
107
|
+
# config.on_warning do |plan_owner, limit_key, threshold|
|
|
108
|
+
# Analytics.track(plan_owner, "limit_warning", limit: limit_key, threshold: threshold)
|
|
109
|
+
# end
|
|
110
|
+
#
|
|
111
|
+
# Note: Callbacks are error-isolated - if your callback raises an exception,
|
|
112
|
+
# it won't break model creation. Errors are logged but don't propagate.
|
|
79
113
|
|
|
80
114
|
# --- Pricing semantics (UI-agnostic) ---
|
|
81
115
|
# Currency symbol to use when Stripe is absent
|