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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 231aa688b5fb69cffde68df9bbab35af32634d8605930559378e15a841cb6aa0
4
- data.tar.gz: 2fee172963111d141b39d9ad9335f1b84d3f9b6f46df139e63684103eecb7750
3
+ metadata.gz: 476af1d2c2bd25790361bdee91c8419a257eb50dd4584bd5d0ab76d388e4a4ca
4
+ data.tar.gz: ca9216c59eb3f587bf7f30d104747fb2bb06b5e90dbccaa1b02e7a2e77ee324b
5
5
  SHA512:
6
- metadata.gz: 99277aae15c2a137b0824a199097420a60613e9c5c20b6569036f11b574186997b303fdfe42d5a2d147a66d0b28a32812e8ca1706903d94c88f6e61a5e09bf27
7
- data.tar.gz: 12aeee03c7911d19b7b4c04398946150534b0ef66d59aa50211992a52f3000908778e42e7eb51b2acc0bec85e23e51eff5996ae04870f948826485ebf80a1414
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
- 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
@@ -3,7 +3,7 @@
3
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
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 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.
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
- ### 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
218
234
 
219
- limits :projects, to: 3, after_limit: :grace_then_block, grace: 7.days, warn_at: [0.5, 0.8, 0.95]
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
- 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
+ ```
231
276
 
232
- # Also available:
233
- config.on_grace_start(:projects) do |plan_owner, grace_ends_at|
234
- # notify grace started; ends at `grace_ends_at`
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
- 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
@@ -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
+ ```
@@ -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 "minitest", "~> 5.0"
18
- gem "sqlite3", "~> 2.1"
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
@@ -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 "minitest", "~> 5.0"
18
- gem "sqlite3", "~> 2.1"
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
- # 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
@@ -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
- def on_warning(limit_key, &block)
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
- @event_handlers[:warning][limit_key] = block
127
+ key = limit_key || :_all
128
+ @event_handlers[:warning][key] = block
125
129
  end
126
130
 
127
- def on_grace_start(limit_key, &block)
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
- @event_handlers[:grace_start][limit_key] = block
136
+ key = limit_key || :_all
137
+ @event_handlers[:grace_start][key] = block
130
138
  end
131
139
 
132
- def on_block(limit_key, &block)
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
- @event_handlers[:block][limit_key] = block
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