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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a94b5bd0d3211b4eb17838d133adbfcef2df64f1c36f5378c952d49e78a035d4
4
- data.tar.gz: f549e3f70a0ae506489204ba59c2da0996b62b4f514cf6f68276a9e8a1278d91
3
+ metadata.gz: 476af1d2c2bd25790361bdee91c8419a257eb50dd4584bd5d0ab76d388e4a4ca
4
+ data.tar.gz: ca9216c59eb3f587bf7f30d104747fb2bb06b5e90dbccaa1b02e7a2e77ee324b
5
5
  SHA512:
6
- metadata.gz: 997d0a1039830243d8f5515197570fa84d07b3450482b448f7ef9acfb724accec12dad6fa0f60216162fc414afb26ba4f93169dd376ddb3590dec223d58c5eba
7
- data.tar.gz: 59da0f6040c935251fe2c47a635ce2738b373b8d91ae6349be6441ca04d31deb1c349cf1bf4161ed658f7abfa96cc202eedde1aa4b079ddd236545f1fa468f5e
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
@@ -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,14 @@
1
- # Changelog
1
+ ## [0.3.0] - 2026-02-15
2
2
 
3
- All notable changes to this project will be documented in this file.
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
- 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).
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
- 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.
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
- [![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/?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
  ![pricing_plans Ruby on Rails gem - pricing plan upgrade prompt](/docs/images/pricing_plans_ruby_rails_gem_usage_alert_upgrade.jpg)
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 its used: `PlanResolver` checks Pay → manual assignment → default plan. You can call `assign_pricing_plan!` and `remove_pricing_plan!` on the plan_owner.
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
- ### Warn users when they cross a limit threshold
208
+ ### Callbacks
209
209
 
210
- We can also set thresholds to warn our users when they're halfway through their limit, approaching the limit, etc. To do that, we first set up trigger thresholds with `warn_at:`
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 :free do
215
- price 0
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
- allows :api_access
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
- limits :projects, to: 3, after_limit: :grace_then_block, grace: 7.days, warn_at: [0.5, 0.8, 0.95]
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
- And then, for each threshold and for each limit, an event gets triggered, and we can configure its callback in the `pricing_plans.rb` initializer:
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
- config.on_warning(:projects) do |plan_owner, threshold|
228
- # send a mail or a notification
229
- # this fires when :projects crosses 50%, 80% and 95% of its limit
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
- # Also available:
233
- config.on_grace_start(:projects) do |plan_owner, grace_ends_at|
234
- # notify grace started; ends at `grace_ends_at`
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
- config.on_block(:projects) do |plan_owner|
237
- # notify usage is now blocked for :projects
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
+ ```
@@ -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 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,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
- # Optional event callbacks -- enqueue jobs here to send notifications or emails when certain events happen
76
- # config.on_warning(:products) { |org, threshold| PlanMailer.quota_warning(org, :products, threshold).deliver_later }
77
- # config.on_grace_start(:products) { |org, ends_at| PlanMailer.grace_started(org, :products, ends_at).deliver_later }
78
- # config.on_block(:products) { |org| PlanMailer.blocked(org, :products).deliver_later }
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