pricing_plans 0.2.1 → 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/CHANGELOG.md +9 -1
- data/CLAUDE.md +5 -1
- data/README.md +2 -2
- data/docs/01-define-pricing-plans.md +104 -15
- data/docs/03-model-helpers.md +59 -0
- data/gemfiles/rails_7.2.gemfile +6 -4
- data/gemfiles/rails_8.1.gemfile +6 -4
- 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 +18 -6
- 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_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 +50 -10
- metadata +6 -3
- data/.claude/settings.local.json +0 -19
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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## [0.3.0] - 2026-02-15
|
|
2
|
+
|
|
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
|
|
8
|
+
|
|
1
9
|
## [0.2.1] - 2026-01-15
|
|
2
10
|
|
|
3
11
|
- Added a `metadata` alias to plans, and documented its usage
|
|
@@ -15,4 +23,4 @@
|
|
|
15
23
|
|
|
16
24
|
## [0.1.0] - 2025-08-19
|
|
17
25
|
|
|
18
|
-
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
|
@@ -3,7 +3,7 @@
|
|
|
3
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)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks.
|
|
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
7
|
|
|
8
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.
|
|
9
9
|
|
|
@@ -164,7 +164,7 @@ The `pricing_plans` gem needs three new models in the schema in order to work: `
|
|
|
164
164
|
|
|
165
165
|
- `PricingPlans::Assignment` allow manual plan overrides independent of billing system (or before you wire up Stripe/Pay). Great for admin toggles, trials, demos.
|
|
166
166
|
- What: The arbitrary `plan_key` and a `source` label (default "manual"). Unique per plan_owner.
|
|
167
|
-
- 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.
|
|
168
168
|
|
|
169
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.
|
|
170
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
|
|
218
234
|
|
|
219
|
-
|
|
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
|
|
240
|
+
|
|
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
|
+
```
|
|
231
276
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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:
|
|
278
|
+
|
|
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
|
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/gemfiles/rails_7.2.gemfile
CHANGED
|
@@ -6,16 +6,18 @@ gem "rake", "~> 13.0"
|
|
|
6
6
|
gem "rails", "~> 7.2.3"
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
|
-
gem "appraisal"
|
|
10
9
|
gem "irb"
|
|
11
10
|
gem "rubocop", "~> 1.0"
|
|
12
11
|
gem "rubocop-minitest", "~> 0.35"
|
|
13
12
|
gem "rubocop-performance", "~> 1.0"
|
|
14
13
|
end
|
|
15
14
|
|
|
16
|
-
group :test do
|
|
17
|
-
gem "
|
|
18
|
-
gem "
|
|
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"
|
|
19
21
|
gem "ostruct"
|
|
20
22
|
gem "simplecov", require: false
|
|
21
23
|
end
|
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -6,16 +6,18 @@ gem "rake", "~> 13.0"
|
|
|
6
6
|
gem "rails", "~> 8.1.2"
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
|
-
gem "appraisal"
|
|
10
9
|
gem "irb"
|
|
11
10
|
gem "rubocop", "~> 1.0"
|
|
12
11
|
gem "rubocop-minitest", "~> 0.35"
|
|
13
12
|
gem "rubocop-performance", "~> 1.0"
|
|
14
13
|
end
|
|
15
14
|
|
|
16
|
-
group :test do
|
|
17
|
-
gem "
|
|
18
|
-
gem "
|
|
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"
|
|
19
21
|
gem "ostruct"
|
|
20
22
|
gem "simplecov", require: false
|
|
21
23
|
end
|
|
@@ -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
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PricingPlans
|
|
4
|
+
# Centralized callback dispatch module with error isolation.
|
|
5
|
+
# Callbacks should never break the main operation - errors are logged but not raised.
|
|
6
|
+
module Callbacks
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Dispatch a callback event with error isolation.
|
|
10
|
+
# Fires specific handler first (if exists), then wildcard handler (if exists).
|
|
11
|
+
# Callbacks should never break the main operation.
|
|
12
|
+
#
|
|
13
|
+
# @param event_type [Symbol] The event type (:warning, :grace_start, :block)
|
|
14
|
+
# @param limit_key [Symbol] The limit key (e.g., :projects, :licenses)
|
|
15
|
+
# @param args [Array] Arguments to pass to the callback (plan_owner, plus event-specific args)
|
|
16
|
+
def dispatch(event_type, limit_key, *args)
|
|
17
|
+
handlers = Registry.event_handlers[event_type] || {}
|
|
18
|
+
|
|
19
|
+
# Build full args with limit_key injected after plan_owner
|
|
20
|
+
# Input args: [plan_owner, ...event_specific_args]
|
|
21
|
+
# Output: [plan_owner, limit_key, ...event_specific_args]
|
|
22
|
+
plan_owner = args.first
|
|
23
|
+
event_args = args.drop(1)
|
|
24
|
+
full_args = [plan_owner, limit_key, *event_args]
|
|
25
|
+
|
|
26
|
+
# Fire specific handler first
|
|
27
|
+
specific_handler = handlers[limit_key]
|
|
28
|
+
execute_safely(specific_handler, event_type, limit_key, full_args) if specific_handler.is_a?(Proc)
|
|
29
|
+
|
|
30
|
+
# Fire wildcard handler second
|
|
31
|
+
wildcard_handler = handlers[:_all]
|
|
32
|
+
execute_safely(wildcard_handler, event_type, limit_key, full_args) if wildcard_handler.is_a?(Proc)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Execute callback with error isolation and arity handling.
|
|
36
|
+
# Supports callbacks with varying argument counts for backwards compatibility.
|
|
37
|
+
#
|
|
38
|
+
# Backward compatibility:
|
|
39
|
+
# - Arity 2 (old style): receives (plan_owner, last_arg) - skips limit_key
|
|
40
|
+
# - Arity 3+ (new style): receives (plan_owner, limit_key, ...rest)
|
|
41
|
+
#
|
|
42
|
+
# @param handler [Proc] The callback to execute
|
|
43
|
+
# @param event_type [Symbol] For logging purposes
|
|
44
|
+
# @param limit_key [Symbol] For logging purposes
|
|
45
|
+
# @param args [Array] Full arguments array [plan_owner, limit_key, ...event_specific_args]
|
|
46
|
+
def execute_safely(handler, event_type, limit_key, args)
|
|
47
|
+
case handler.arity
|
|
48
|
+
when 0
|
|
49
|
+
handler.call
|
|
50
|
+
when 1
|
|
51
|
+
handler.call(args[0])
|
|
52
|
+
when 2
|
|
53
|
+
# Backward compatibility: old callbacks expect (plan_owner, event_arg)
|
|
54
|
+
# where event_arg is threshold for warnings, grace_ends_at for grace_start.
|
|
55
|
+
# Skip limit_key (args[1]) and pass plan_owner + last arg.
|
|
56
|
+
# For on_block (args = [plan_owner, limit_key]), this passes (plan_owner, limit_key).
|
|
57
|
+
handler.call(args[0], args.last)
|
|
58
|
+
when 3
|
|
59
|
+
handler.call(args[0], args[1], args[2])
|
|
60
|
+
when -1, -2, -3 # Variable arity (splat args)
|
|
61
|
+
handler.call(*args)
|
|
62
|
+
else
|
|
63
|
+
handler.call(*args.first(handler.arity.abs))
|
|
64
|
+
end
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
# Log but don't re-raise - callbacks should never break model creation
|
|
67
|
+
log_error("[PricingPlans] Callback error for #{event_type}:#{limit_key}: #{e.class}: #{e.message}")
|
|
68
|
+
log_debug(e.backtrace&.join("\n"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check warning thresholds and emit warning event if a new threshold is crossed.
|
|
72
|
+
# This is the main entry point for automatic warning detection.
|
|
73
|
+
#
|
|
74
|
+
# @param plan_owner [Object] The plan owner (e.g., Organization)
|
|
75
|
+
# @param limit_key [Symbol] The limit key
|
|
76
|
+
# @param current_usage [Integer] Current usage count (after the action)
|
|
77
|
+
# @param limit_amount [Integer] The configured limit
|
|
78
|
+
def check_and_emit_warnings!(plan_owner, limit_key, current_usage, limit_amount)
|
|
79
|
+
return if limit_amount == :unlimited || limit_amount.to_i.zero?
|
|
80
|
+
|
|
81
|
+
percent_used = (current_usage.to_f / limit_amount) * 100
|
|
82
|
+
thresholds = LimitChecker.warning_thresholds(plan_owner, limit_key)
|
|
83
|
+
|
|
84
|
+
# Find the highest threshold that has been crossed
|
|
85
|
+
crossed_threshold = thresholds.select { |t| percent_used >= (t * 100) }.max
|
|
86
|
+
return unless crossed_threshold
|
|
87
|
+
|
|
88
|
+
# Emit warning if this is a new higher threshold
|
|
89
|
+
GraceManager.maybe_emit_warning!(plan_owner, limit_key, crossed_threshold)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if limit is exceeded and handle grace state (for grace_then_block policy).
|
|
93
|
+
# This is called after a successful model creation, so it only handles:
|
|
94
|
+
# - :just_warn - emit additional warning
|
|
95
|
+
# - :grace_then_block - start grace period if exceeded and not already in grace
|
|
96
|
+
#
|
|
97
|
+
# NOTE: Block events are NOT emitted here because this runs after successful creation.
|
|
98
|
+
# Block events are emitted in Limitable validation when creation is actually blocked.
|
|
99
|
+
#
|
|
100
|
+
# @param plan_owner [Object] The plan owner
|
|
101
|
+
# @param limit_key [Symbol] The limit key
|
|
102
|
+
# @param current_usage [Integer] Current usage count
|
|
103
|
+
# @param limit_config [Hash] The limit configuration from the plan
|
|
104
|
+
def check_and_emit_limit_exceeded!(plan_owner, limit_key, current_usage, limit_config)
|
|
105
|
+
return unless limit_config
|
|
106
|
+
return if limit_config[:to] == :unlimited
|
|
107
|
+
|
|
108
|
+
limit_amount = limit_config[:to].to_i
|
|
109
|
+
return unless current_usage >= limit_amount
|
|
110
|
+
|
|
111
|
+
case limit_config[:after_limit]
|
|
112
|
+
when :just_warn
|
|
113
|
+
# Just emit warning, don't track grace/block
|
|
114
|
+
check_and_emit_warnings!(plan_owner, limit_key, current_usage, limit_amount)
|
|
115
|
+
when :block_usage
|
|
116
|
+
# Do NOT mark as blocked here - this callback runs after SUCCESSFUL creation.
|
|
117
|
+
# Block events are emitted from validation when creation is actually blocked.
|
|
118
|
+
nil
|
|
119
|
+
when :grace_then_block
|
|
120
|
+
# Start grace period if not already in grace/blocked
|
|
121
|
+
unless GraceManager.grace_active?(plan_owner, limit_key) || GraceManager.should_block?(plan_owner, limit_key)
|
|
122
|
+
GraceManager.mark_exceeded!(plan_owner, limit_key, grace_period: limit_config[:grace])
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Safe logging that works with or without Rails
|
|
128
|
+
def log_error(message)
|
|
129
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
130
|
+
Rails.logger.error(message)
|
|
131
|
+
elsif PricingPlans.configuration&.debug
|
|
132
|
+
warn message
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def log_warn(message)
|
|
137
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
138
|
+
Rails.logger.warn(message)
|
|
139
|
+
elsif PricingPlans.configuration&.debug
|
|
140
|
+
warn message
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def log_debug(message)
|
|
145
|
+
return unless message
|
|
146
|
+
|
|
147
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
|
|
148
|
+
Rails.logger.debug(message)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -119,19 +119,31 @@ module PricingPlans
|
|
|
119
119
|
end
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
# Register a callback for warning events.
|
|
123
|
+
# @param limit_key [Symbol, nil] The specific limit key, or omit for wildcard (all limits)
|
|
124
|
+
# @yield [plan_owner, limit_key, threshold] Block to execute when warning fires
|
|
125
|
+
def on_warning(limit_key = nil, &block)
|
|
123
126
|
raise PricingPlans::ConfigurationError, "Block required for on_warning" unless block_given?
|
|
124
|
-
|
|
127
|
+
key = limit_key || :_all
|
|
128
|
+
@event_handlers[:warning][key] = block
|
|
125
129
|
end
|
|
126
130
|
|
|
127
|
-
|
|
131
|
+
# Register a callback for grace period start events.
|
|
132
|
+
# @param limit_key [Symbol, nil] The specific limit key, or omit for wildcard (all limits)
|
|
133
|
+
# @yield [plan_owner, limit_key, grace_ends_at] Block to execute when grace starts
|
|
134
|
+
def on_grace_start(limit_key = nil, &block)
|
|
128
135
|
raise PricingPlans::ConfigurationError, "Block required for on_grace_start" unless block_given?
|
|
129
|
-
|
|
136
|
+
key = limit_key || :_all
|
|
137
|
+
@event_handlers[:grace_start][key] = block
|
|
130
138
|
end
|
|
131
139
|
|
|
132
|
-
|
|
140
|
+
# Register a callback for block events.
|
|
141
|
+
# @param limit_key [Symbol, nil] The specific limit key, or omit for wildcard (all limits)
|
|
142
|
+
# @yield [plan_owner, limit_key] Block to execute when user is blocked
|
|
143
|
+
def on_block(limit_key = nil, &block)
|
|
133
144
|
raise PricingPlans::ConfigurationError, "Block required for on_block" unless block_given?
|
|
134
|
-
|
|
145
|
+
key = limit_key || :_all
|
|
146
|
+
@event_handlers[:block][key] = block
|
|
135
147
|
end
|
|
136
148
|
|
|
137
149
|
def validate!
|
|
@@ -8,8 +8,9 @@ module PricingPlans
|
|
|
8
8
|
# Track all limited_by configurations for this model
|
|
9
9
|
class_attribute :pricing_plans_limits, default: {}
|
|
10
10
|
|
|
11
|
-
# Callbacks for automatic tracking
|
|
11
|
+
# Callbacks for automatic tracking and event emission
|
|
12
12
|
after_create :increment_per_period_counters
|
|
13
|
+
after_commit :check_and_emit_limit_events, on: :create
|
|
13
14
|
after_destroy :decrement_persistent_counters
|
|
14
15
|
# Add plan_owner-centric convenience methods to instances of the plan_owner class
|
|
15
16
|
# when possible. These are no-ops if the model isn't the plan_owner itself.
|
|
@@ -213,6 +214,8 @@ module PricingPlans
|
|
|
213
214
|
return
|
|
214
215
|
when :block_usage, :grace_then_block
|
|
215
216
|
if limit_config[:after_limit] == :block_usage || GraceManager.should_block?(plan_owner_instance, limit_key)
|
|
217
|
+
# Emit block event before failing validation
|
|
218
|
+
GraceManager.mark_blocked!(plan_owner_instance, limit_key)
|
|
216
219
|
message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key} limit exceeded"
|
|
217
220
|
errors.add(:base, message)
|
|
218
221
|
end
|
|
@@ -227,6 +230,8 @@ module PricingPlans
|
|
|
227
230
|
return
|
|
228
231
|
when :block_usage, :grace_then_block
|
|
229
232
|
if limit_config[:after_limit] == :block_usage || GraceManager.should_block?(plan_owner_instance, limit_key)
|
|
233
|
+
# Emit block event before failing validation
|
|
234
|
+
GraceManager.mark_blocked!(plan_owner_instance, limit_key)
|
|
230
235
|
message = error_after_limit || "Cannot create #{self.class.name.downcase}: #{limit_key} limit exceeded for this period"
|
|
231
236
|
errors.add(:base, message)
|
|
232
237
|
end
|
|
@@ -289,5 +294,37 @@ module PricingPlans
|
|
|
289
294
|
# since the counter is computed live from the database
|
|
290
295
|
# The record being destroyed will automatically reduce the count
|
|
291
296
|
end
|
|
297
|
+
|
|
298
|
+
# Automatically check warning thresholds and emit events after model creation.
|
|
299
|
+
# This ensures callbacks fire without requiring explicit controller guard calls.
|
|
300
|
+
def check_and_emit_limit_events
|
|
301
|
+
self.class.pricing_plans_limits.each do |limit_key, config|
|
|
302
|
+
plan_owner_instance = if config[:plan_owner_method] == :self
|
|
303
|
+
self
|
|
304
|
+
else
|
|
305
|
+
send(config[:plan_owner_method])
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
unless plan_owner_instance
|
|
309
|
+
Callbacks.log_debug("[PricingPlans] Skipping callback for #{self.class.name}##{id}: plan_owner is nil (#{config[:plan_owner_method]})")
|
|
310
|
+
next
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
plan = PlanResolver.effective_plan_for(plan_owner_instance)
|
|
314
|
+
limit_config = plan&.limit_for(limit_key)
|
|
315
|
+
|
|
316
|
+
next unless limit_config
|
|
317
|
+
next if limit_config[:to] == :unlimited
|
|
318
|
+
|
|
319
|
+
limit_amount = limit_config[:to].to_i
|
|
320
|
+
current_usage = LimitChecker.current_usage_for(plan_owner_instance, limit_key, limit_config)
|
|
321
|
+
|
|
322
|
+
# Check and emit warning events for thresholds crossed
|
|
323
|
+
Callbacks.check_and_emit_warnings!(plan_owner_instance, limit_key, current_usage, limit_amount)
|
|
324
|
+
|
|
325
|
+
# Check and emit grace/block events if limit is exceeded
|
|
326
|
+
Callbacks.check_and_emit_limit_exceeded!(plan_owner_instance, limit_key, current_usage, limit_config)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
292
329
|
end
|
|
293
330
|
end
|