usage_credits 0.2.1 → 0.4.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/CHANGELOG.md +10 -0
- data/README.md +98 -13
- data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +11 -3
- data/lib/generators/usage_credits/templates/initializer.rb +49 -9
- data/lib/usage_credits/callback_context.rb +28 -0
- data/lib/usage_credits/callbacks.rb +67 -0
- data/lib/usage_credits/configuration.rb +67 -1
- data/lib/usage_credits/models/concerns/pay_charge_extension.rb +57 -7
- data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +89 -48
- data/lib/usage_credits/models/fulfillment.rb +36 -0
- data/lib/usage_credits/models/transaction.rb +45 -0
- data/lib/usage_credits/models/wallet.rb +111 -21
- data/lib/usage_credits/version.rb +1 -1
- data/lib/usage_credits.rb +19 -2
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6947ef76d1f3674d94e3fbff6f4149daff82f0e90e55365c485f2e6eef88b319
|
|
4
|
+
data.tar.gz: 784a9c5fb5ca6b139bf04bab6f85ddff1d96a15e30cd57f7d8b2f9f167f0aac6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 54d40b947cda3d9da7932bf1da5c8f8bb97c68c359aa087c8913c2e18ca8bab9500d257c275d02070bcbf5122a4550c83b37205a76080a2db7c598f87366a9d9
|
|
7
|
+
data.tar.gz: 8d6e8689d9271526b768472fb8af5ee74caab0dfdb729750d38d0168eb6cf4e6f3f9a0daf034bfbae048a619c38cd8f19c5c2d3b821a797bcbb1db99d85267d2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## [0.4.0] - 2026-01-16
|
|
2
|
+
|
|
3
|
+
- Add `balance_before` and `balance_after` to transactions by @rameerez (h/t @yshmarov) in https://github.com/rameerez/usage_credits/pull/27
|
|
4
|
+
- Add MySQL support and multi-database CI testing by @rameerez in https://github.com/rameerez/usage_credits/pull/28
|
|
5
|
+
|
|
6
|
+
## [0.3.0] - 2026-01-15
|
|
7
|
+
|
|
8
|
+
- Add lifecycle callbacks by @rameerez in https://github.com/rameerez/usage_credits/pull/25
|
|
9
|
+
- Fix credit pack fulfillment not working with Pay 10+ (Stripe data in `object` vs `data` in `Pay::Charge`) by @rameerez in https://github.com/rameerez/usage_credits/pull/26
|
|
10
|
+
|
|
1
11
|
## [0.2.1] - 2026-01-15
|
|
2
12
|
|
|
3
13
|
- Add custom `create_checkout_session` options (like `success_url`) to credit pack purchases by @yshmarov in https://github.com/rameerez/usage_credits/pull/5
|
data/README.md
CHANGED
|
@@ -265,27 +265,83 @@ process_image(params) # If this fails, credits are already spent!
|
|
|
265
265
|
> If validation fails (e.g., file too large), both methods will raise `InvalidOperation`.
|
|
266
266
|
> Perform your operation inside the `spend_credits_on` block OR make the credit spend conditional to the actual operation, so users are not charged if the operation fails.
|
|
267
267
|
|
|
268
|
-
## Low balance alerts
|
|
268
|
+
## Low balance alerts & other lifecycle callbacks
|
|
269
269
|
|
|
270
|
-
|
|
270
|
+
`usage_credits` makes it easy to add callbacks for important events.
|
|
271
|
+
|
|
272
|
+
You can hook into credit events, for example, to upsell credit packs to users when they reach a low balance:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
config.on_low_balance_reached do |ctx|
|
|
276
|
+
# Sell your users credits here
|
|
277
|
+
# Example: LowCreditsMailer.buy_more(ctx.owner, remaining: ctx.new_balance).deliver_later
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
> [!TIP]
|
|
282
|
+
> For `on_low_balance_reached`, define what a low balance is by setting the `low_balance_threshold` like: `config.low_balance_threshold = 100.credits`
|
|
283
|
+
|
|
284
|
+
You configure callbacks in your `usage_credits.rb` initializer file.
|
|
285
|
+
|
|
286
|
+
Here are all the available callbacks you can hook into:
|
|
287
|
+
|
|
288
|
+
- `on_credits_added`: After credits are added to a wallet
|
|
289
|
+
- `on_credits_deducted`: After credits are deducted from a wallet
|
|
290
|
+
- `on_low_balance_reached`: When balance drops below threshold (fires once per crossing)
|
|
291
|
+
- `on_balance_depleted`: When balance reaches exactly zero
|
|
292
|
+
- `on_insufficient_credits`: When an operation fails due to insufficient credits
|
|
293
|
+
- `on_credit_pack_purchased`: After a credit pack purchase is fulfilled
|
|
294
|
+
- `on_subscription_credits_awarded`: After subscription credits are awarded
|
|
295
|
+
|
|
296
|
+
They're useful for analytics, audit logging, notifications, or custom business logic.
|
|
297
|
+
|
|
298
|
+
Callbacks are isolated, so errors in callbacks won't break credit operations.
|
|
299
|
+
|
|
300
|
+
> [!IMPORTANT]
|
|
301
|
+
> Callbacks get executed every single time the action happens (duh) so you don't want to do heavy operations there, or else your app could become extremely slow. Keep callbacks fast: one good option is using the callback to enqueue background jobs (`deliver_later`, `perform_later`) to avoid blocking credit operations.
|
|
302
|
+
|
|
303
|
+
### The `ctx` object in callbacks
|
|
304
|
+
|
|
305
|
+
All callbacks receive a context object (`ctx`). The `ctx` objects contains useful fields like `ctx.owner`, `ctx.amount`, etc. Some examples:
|
|
271
306
|
|
|
272
307
|
```ruby
|
|
273
308
|
UsageCredits.configure do |config|
|
|
274
|
-
#
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
309
|
+
# Prompt users to buy more credits when they run out
|
|
310
|
+
config.on_balance_depleted do |ctx|
|
|
311
|
+
# Send a mail here like "You've ran out of credits! Purchase more here!"
|
|
312
|
+
OutOfCreditsMailer.buy_more(ctx.owner).deliver_later
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Log failed operations (useful for debugging)
|
|
316
|
+
config.on_insufficient_credits do |ctx|
|
|
317
|
+
Rails.logger.info "[Credits] User #{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Track purchases in your analytics (Mixpanel, Amplitude, Segment, etc.)
|
|
321
|
+
config.on_credit_pack_purchased do |ctx|
|
|
322
|
+
# Replace with your analytics service
|
|
323
|
+
# Log something like: "User #{ctx.owner.id} purchased #{ctx.amount} credits"
|
|
285
324
|
end
|
|
286
325
|
end
|
|
287
326
|
```
|
|
288
327
|
|
|
328
|
+
Available `ctx` fields vary by event:
|
|
329
|
+
|
|
330
|
+
- `credits_added`: `ctx.owner`, `ctx.wallet`, `ctx.amount`, `ctx.previous_balance`, `ctx.new_balance`, `ctx.transaction`, `ctx.category`, `ctx.metadata`
|
|
331
|
+
- `credits_deducted`: `ctx.owner`, `ctx.wallet`, `ctx.amount`, `ctx.previous_balance`, `ctx.new_balance`, `ctx.transaction`, `ctx.category`, `ctx.metadata`
|
|
332
|
+
- `low_balance_reached`: `ctx.owner`, `ctx.wallet`, `ctx.previous_balance`, `ctx.new_balance`, `ctx.threshold`
|
|
333
|
+
- `balance_depleted`: `ctx.owner`, `ctx.wallet`, `ctx.previous_balance`, `ctx.new_balance`
|
|
334
|
+
- `insufficient_credits`: `ctx.owner`, `ctx.wallet`, `ctx.amount`, `ctx.operation_name`, `ctx.metadata`
|
|
335
|
+
- `credit_pack_purchased`: `ctx.owner`, `ctx.wallet`, `ctx.amount`, `ctx.transaction`, `ctx.metadata`
|
|
336
|
+
- `subscription_credits_awarded`: `ctx.owner`, `ctx.wallet`, `ctx.amount`, `ctx.transaction`, `ctx.metadata`
|
|
337
|
+
|
|
338
|
+
All contexts support `ctx.to_h` to convert to a hash (excludes nil values).
|
|
339
|
+
|
|
340
|
+
**Metadata contents:**
|
|
341
|
+
- `insufficient_credits`: `{ available:, required:, params: }`
|
|
342
|
+
- `credit_pack_purchased`: `{ credit_pack_name:, credit_pack:, pay_charge:, price_cents: }`
|
|
343
|
+
- `subscription_credits_awarded`: `{ subscription_plan_name:, subscription:, pay_subscription:, fulfillment_period: }`
|
|
344
|
+
|
|
289
345
|
## Award bonus credits
|
|
290
346
|
|
|
291
347
|
You might want to award bonus credits to your users for arbitrary actions at any point, like referring a friend, completing signup, or any other reason.
|
|
@@ -522,6 +578,35 @@ This makes it easy to:
|
|
|
522
578
|
- Generate detailed invoices
|
|
523
579
|
- Monitor usage patterns
|
|
524
580
|
|
|
581
|
+
### Running balance (balance after each transaction)
|
|
582
|
+
|
|
583
|
+
Every transaction automatically tracks the wallet balance before and after it was applied, like you would find in a bank statement:
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
user.credit_history.each do |tx|
|
|
587
|
+
puts "#{tx.created_at.strftime('%Y-%m-%d')}: #{tx.formatted_amount}"
|
|
588
|
+
puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Output:
|
|
592
|
+
# 2024-12-16: +1000 credits
|
|
593
|
+
# Balance: 0 → 1000
|
|
594
|
+
# 2024-12-26: +500 credits
|
|
595
|
+
# Balance: 1000 → 1500
|
|
596
|
+
# 2025-01-14: -50 credits
|
|
597
|
+
# Balance: 1500 → 1450
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
This is useful for building transaction history UIs, generating statements, or debugging balance issues. Each transaction provides:
|
|
601
|
+
|
|
602
|
+
```ruby
|
|
603
|
+
transaction.balance_before # Balance before this transaction
|
|
604
|
+
transaction.balance_after # Balance after this transaction
|
|
605
|
+
transaction.formatted_balance_after # Formatted (e.g., "1450 credits")
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
`balance_before` and `balance_after` return `nil` if no balance is found (for transactions created before this feature was added)
|
|
609
|
+
|
|
525
610
|
### Custom credit formatting
|
|
526
611
|
|
|
527
612
|
A minor thing, but if you want to use the `@transaction.formatted_amount` helper, you can specify the format:
|
|
@@ -7,7 +7,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
7
7
|
create_table :usage_credits_wallets, id: primary_key_type do |t|
|
|
8
8
|
t.references :owner, polymorphic: true, null: false, type: foreign_key_type
|
|
9
9
|
t.integer :balance, null: false, default: 0
|
|
10
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
10
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
11
11
|
|
|
12
12
|
t.timestamps
|
|
13
13
|
end
|
|
@@ -18,7 +18,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
18
18
|
t.string :category, null: false
|
|
19
19
|
t.datetime :expires_at
|
|
20
20
|
t.references :fulfillment, type: foreign_key_type
|
|
21
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
21
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
22
22
|
|
|
23
23
|
t.timestamps
|
|
24
24
|
end
|
|
@@ -32,7 +32,7 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
32
32
|
t.datetime :next_fulfillment_at # When to fulfill next (nil if stopped/completed)
|
|
33
33
|
t.string :fulfillment_period # "2.months", "15.days", etc. (nil for one-time)
|
|
34
34
|
t.datetime :stops_at # When to stop performing fulfillments
|
|
35
|
-
t.send(json_column_type, :metadata, null: false, default:
|
|
35
|
+
t.send(json_column_type, :metadata, null: false, default: json_column_default)
|
|
36
36
|
|
|
37
37
|
t.timestamps
|
|
38
38
|
end
|
|
@@ -85,4 +85,12 @@ class CreateUsageCreditsTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
85
85
|
return :jsonb if connection.adapter_name.downcase.include?('postgresql')
|
|
86
86
|
:json
|
|
87
87
|
end
|
|
88
|
+
|
|
89
|
+
# MySQL 8+ doesn't allow default values on JSON columns.
|
|
90
|
+
# Returns an empty hash default for SQLite/PostgreSQL, nil for MySQL.
|
|
91
|
+
# Models handle nil metadata gracefully by defaulting to {} in their accessors.
|
|
92
|
+
def json_column_default
|
|
93
|
+
return nil if connection.adapter_name.downcase.include?('mysql')
|
|
94
|
+
{}
|
|
95
|
+
end
|
|
88
96
|
end
|
|
@@ -82,18 +82,58 @@ UsageCredits.configure do |config|
|
|
|
82
82
|
# end
|
|
83
83
|
#
|
|
84
84
|
#
|
|
85
|
+
# === Lifecycle Callbacks ===
|
|
86
|
+
#
|
|
87
|
+
# Hook into credit events for analytics, notifications, and custom logic.
|
|
88
|
+
# All callbacks receive a context object with event-specific data.
|
|
89
|
+
#
|
|
90
|
+
# Available callbacks:
|
|
91
|
+
# on_credits_added - After credits are added to a wallet
|
|
92
|
+
# on_credits_deducted - After credits are deducted from a wallet
|
|
93
|
+
# on_low_balance_reached - When balance drops below threshold (fires once per crossing)
|
|
94
|
+
# on_balance_depleted - When balance reaches exactly zero
|
|
95
|
+
# on_insufficient_credits - When an operation fails due to insufficient credits
|
|
96
|
+
# on_credit_pack_purchased - After a credit pack purchase is fulfilled
|
|
97
|
+
# on_subscription_credits_awarded - After subscription credits are awarded
|
|
98
|
+
#
|
|
99
|
+
# Context object properties (available depending on event):
|
|
100
|
+
# ctx.event # Symbol - the event name
|
|
101
|
+
# ctx.owner # The wallet owner (User, Team, etc.)
|
|
102
|
+
# ctx.wallet # The UsageCredits::Wallet instance
|
|
103
|
+
# ctx.amount # Credits involved
|
|
104
|
+
# ctx.previous_balance # Balance before the operation
|
|
105
|
+
# ctx.new_balance # Balance after the operation
|
|
106
|
+
# ctx.transaction # The UsageCredits::Transaction record
|
|
107
|
+
# ctx.category # Transaction category (:manual_adjustment, :operation_charge, etc.)
|
|
108
|
+
# ctx.threshold # Low balance threshold (for low_balance_reached)
|
|
109
|
+
# ctx.operation_name # Operation name (for insufficient_credits)
|
|
110
|
+
# ctx.metadata # Additional context-specific data
|
|
111
|
+
# ctx.to_h # Convert to hash (excludes nil values)
|
|
112
|
+
#
|
|
113
|
+
# IMPORTANT: Keep callbacks fast! Use background jobs (deliver_later, perform_later) to avoid blocking credit operations.
|
|
114
|
+
#
|
|
115
|
+
# Example: Prompt user to buy more credits when running low
|
|
116
|
+
#
|
|
117
|
+
# config.low_balance_threshold = 100.credits # Set to nil to disable (default: 100)
|
|
118
|
+
#
|
|
119
|
+
# config.on_low_balance_reached do |ctx|
|
|
120
|
+
# LowCreditsMailer.buy_more(ctx.owner, remaining: ctx.new_balance).deliver_later
|
|
121
|
+
# end
|
|
85
122
|
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
123
|
+
# Example: Prompt user to buy credits when they run out:
|
|
124
|
+
# config.on_balance_depleted do |ctx|
|
|
125
|
+
# OutOfCreditsMailer.buy_more(ctx.owner).deliver_later
|
|
126
|
+
# end
|
|
91
127
|
#
|
|
92
|
-
#
|
|
128
|
+
# Example: Log when users hit credit limits (useful for debugging)
|
|
129
|
+
# config.on_insufficient_credits do |ctx|
|
|
130
|
+
# Rails.logger.info "[Credits] User #{ctx.owner.id} needs #{ctx.amount}, has #{ctx.metadata[:available]}"
|
|
131
|
+
# end
|
|
93
132
|
#
|
|
94
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
133
|
+
# Example: Track credit purchases (replace with your analytics service)
|
|
134
|
+
# config.on_credit_pack_purchased do |ctx|
|
|
135
|
+
# # e.g., Mixpanel, Amplitude, Segment, PostHog, etc.
|
|
136
|
+
# YourAnalyticsService.track(ctx.owner.id, "credits_purchased", amount: ctx.amount)
|
|
97
137
|
# end
|
|
98
138
|
#
|
|
99
139
|
#
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UsageCredits
|
|
4
|
+
# Immutable context object passed to all callbacks
|
|
5
|
+
# Provides consistent, typed access to event data
|
|
6
|
+
CallbackContext = Struct.new(
|
|
7
|
+
:event, # Symbol - the event type
|
|
8
|
+
:wallet, # UsageCredits::Wallet instance
|
|
9
|
+
:amount, # Integer - credits involved (if applicable)
|
|
10
|
+
:previous_balance, # Integer - balance before operation
|
|
11
|
+
:new_balance, # Integer - balance after operation
|
|
12
|
+
:threshold, # Integer - low balance threshold (for low_balance events)
|
|
13
|
+
:category, # Symbol - transaction category
|
|
14
|
+
:operation_name, # Symbol - name of the operation
|
|
15
|
+
:transaction, # UsageCredits::Transaction - the transaction created
|
|
16
|
+
:metadata, # Hash - additional contextual data
|
|
17
|
+
keyword_init: true
|
|
18
|
+
) do
|
|
19
|
+
def to_h
|
|
20
|
+
super.compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Convenience: get owner from wallet
|
|
24
|
+
def owner
|
|
25
|
+
wallet&.owner
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UsageCredits
|
|
4
|
+
# Centralized callback dispatch module
|
|
5
|
+
# Handles executing callbacks with error isolation
|
|
6
|
+
module Callbacks
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Dispatch a callback event with error isolation
|
|
10
|
+
# Callbacks should never break the main operation
|
|
11
|
+
#
|
|
12
|
+
# @param event [Symbol] The event type (e.g., :credits_added, :low_balance_reached)
|
|
13
|
+
# @param context_data [Hash] Data to pass to the callback via CallbackContext
|
|
14
|
+
def dispatch(event, **context_data)
|
|
15
|
+
config = UsageCredits.configuration
|
|
16
|
+
callback = config.public_send(:"on_#{event}_callback")
|
|
17
|
+
|
|
18
|
+
return unless callback.is_a?(Proc)
|
|
19
|
+
|
|
20
|
+
context = CallbackContext.new(event: event, **context_data)
|
|
21
|
+
|
|
22
|
+
execute_safely(callback, context)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Execute callback with error isolation and arity handling
|
|
26
|
+
#
|
|
27
|
+
# @param callback [Proc] The callback to execute
|
|
28
|
+
# @param context [CallbackContext] The context to pass
|
|
29
|
+
def execute_safely(callback, context)
|
|
30
|
+
case callback.arity
|
|
31
|
+
when 1, -1, -2 # Accepts one arg or variable args
|
|
32
|
+
callback.call(context)
|
|
33
|
+
when 0
|
|
34
|
+
callback.call
|
|
35
|
+
else
|
|
36
|
+
log_warn "[UsageCredits] Callback has unexpected arity (#{callback.arity}). Expected 0 or 1."
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
# Log but don't re-raise - callbacks should never break credit operations
|
|
40
|
+
log_error "[UsageCredits] Callback error for #{context.event}: #{e.class}: #{e.message}"
|
|
41
|
+
log_debug e.backtrace.join("\n")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Safe logging that works with or without Rails
|
|
45
|
+
def log_error(message)
|
|
46
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
47
|
+
Rails.logger.error(message)
|
|
48
|
+
else
|
|
49
|
+
warn message
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def log_warn(message)
|
|
54
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
55
|
+
Rails.logger.warn(message)
|
|
56
|
+
else
|
|
57
|
+
warn message
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def log_debug(message)
|
|
62
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger&.debug?
|
|
63
|
+
Rails.logger.debug(message)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -45,6 +45,18 @@ module UsageCredits
|
|
|
45
45
|
|
|
46
46
|
attr_reader :low_balance_callback
|
|
47
47
|
|
|
48
|
+
# =========================================
|
|
49
|
+
# Lifecycle Callbacks
|
|
50
|
+
# =========================================
|
|
51
|
+
|
|
52
|
+
attr_reader :on_credits_added_callback,
|
|
53
|
+
:on_credits_deducted_callback,
|
|
54
|
+
:on_low_balance_reached_callback,
|
|
55
|
+
:on_balance_depleted_callback,
|
|
56
|
+
:on_insufficient_credits_callback,
|
|
57
|
+
:on_subscription_credits_awarded_callback,
|
|
58
|
+
:on_credit_pack_purchased_callback
|
|
59
|
+
|
|
48
60
|
def initialize
|
|
49
61
|
# Initialize empty data stores
|
|
50
62
|
@operations = {} # Credit-consuming operations (e.g., "send_email: 1 credit")
|
|
@@ -71,6 +83,15 @@ module UsageCredits
|
|
|
71
83
|
@allow_negative_balance = false
|
|
72
84
|
@low_balance_threshold = nil
|
|
73
85
|
@low_balance_callback = nil # Called when user hits low_balance_threshold
|
|
86
|
+
|
|
87
|
+
# Lifecycle callbacks (all nil by default)
|
|
88
|
+
@on_credits_added_callback = nil
|
|
89
|
+
@on_credits_deducted_callback = nil
|
|
90
|
+
@on_low_balance_reached_callback = nil
|
|
91
|
+
@on_balance_depleted_callback = nil
|
|
92
|
+
@on_insufficient_credits_callback = nil
|
|
93
|
+
@on_subscription_credits_awarded_callback = nil
|
|
94
|
+
@on_credit_pack_purchased_callback = nil
|
|
74
95
|
end
|
|
75
96
|
|
|
76
97
|
# =========================================
|
|
@@ -189,10 +210,55 @@ module UsageCredits
|
|
|
189
210
|
@credit_formatter = block
|
|
190
211
|
end
|
|
191
212
|
|
|
192
|
-
#
|
|
213
|
+
# =========================================
|
|
214
|
+
# Lifecycle Callback DSL Methods
|
|
215
|
+
# =========================================
|
|
216
|
+
# All methods allow nil block to clear the callback (useful for testing)
|
|
217
|
+
|
|
218
|
+
# Called after credits are added to a wallet
|
|
219
|
+
def on_credits_added(&block)
|
|
220
|
+
@on_credits_added_callback = block
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Called after credits are deducted from a wallet
|
|
224
|
+
def on_credits_deducted(&block)
|
|
225
|
+
@on_credits_deducted_callback = block
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Called when balance crosses below the low_balance_threshold
|
|
229
|
+
# Receives CallbackContext with full event data
|
|
230
|
+
def on_low_balance_reached(&block)
|
|
231
|
+
@on_low_balance_reached_callback = block
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Called when balance reaches exactly zero
|
|
235
|
+
def on_balance_depleted(&block)
|
|
236
|
+
@on_balance_depleted_callback = block
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Called when an operation fails due to insufficient credits
|
|
240
|
+
def on_insufficient_credits(&block)
|
|
241
|
+
@on_insufficient_credits_callback = block
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Called after subscription credits are awarded
|
|
245
|
+
def on_subscription_credits_awarded(&block)
|
|
246
|
+
@on_subscription_credits_awarded_callback = block
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Called after a credit pack is purchased
|
|
250
|
+
def on_credit_pack_purchased(&block)
|
|
251
|
+
@on_credit_pack_purchased_callback = block
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# BACKWARD COMPATIBILITY: Legacy method that receives owner, not context
|
|
255
|
+
# Existing users' code: config.on_low_balance { |owner| ... }
|
|
193
256
|
def on_low_balance(&block)
|
|
194
257
|
raise ArgumentError, "Block is required for low balance callback" unless block_given?
|
|
258
|
+
# Store legacy callback as before (for backward compat with direct calls)
|
|
195
259
|
@low_balance_callback = block
|
|
260
|
+
# Also create a wrapper for new callback system that extracts owner from context
|
|
261
|
+
@on_low_balance_reached_callback = ->(ctx) { block.call(ctx.owner) }
|
|
196
262
|
end
|
|
197
263
|
|
|
198
264
|
# =========================================
|
|
@@ -23,14 +23,21 @@ module UsageCredits
|
|
|
23
23
|
def succeeded?
|
|
24
24
|
case type
|
|
25
25
|
when "Pay::Stripe::Charge"
|
|
26
|
-
|
|
26
|
+
# Pay 10+ stores the full Stripe charge object in `object` column
|
|
27
|
+
# Older Pay versions stored charge details in `data` column
|
|
28
|
+
# We check both for backward compatibility
|
|
29
|
+
stripe_object = charge_object_data
|
|
30
|
+
status = stripe_object["status"]
|
|
31
|
+
|
|
27
32
|
# Explicitly check for failure states
|
|
28
33
|
return false if status == "failed"
|
|
29
34
|
return false if status == "pending"
|
|
30
35
|
return false if status == "canceled"
|
|
31
36
|
return true if status == "succeeded"
|
|
37
|
+
|
|
32
38
|
# Fallback: check if amount was actually captured
|
|
33
|
-
|
|
39
|
+
amount_captured = stripe_object["amount_captured"].to_i
|
|
40
|
+
return amount_captured == amount.to_i && amount.to_i.positive?
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
# For non-Stripe charges, we assume Pay only creates charges after successful payment
|
|
@@ -39,6 +46,20 @@ module UsageCredits
|
|
|
39
46
|
true
|
|
40
47
|
end
|
|
41
48
|
|
|
49
|
+
# Returns the Stripe charge object data, checking both `object` (Pay 10+)
|
|
50
|
+
# and `data` (older Pay versions) for backward compatibility
|
|
51
|
+
def charge_object_data
|
|
52
|
+
# Pay 10+ stores full Stripe object in `object` column
|
|
53
|
+
if respond_to?(:object) && object.is_a?(Hash) && object.any?
|
|
54
|
+
object.with_indifferent_access
|
|
55
|
+
# Older Pay versions stored charge details in `data` column
|
|
56
|
+
elsif data.is_a?(Hash) && data.any?
|
|
57
|
+
data.with_indifferent_access
|
|
58
|
+
else
|
|
59
|
+
{}.with_indifferent_access
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
42
63
|
def refunded?
|
|
43
64
|
return false unless amount_refunded
|
|
44
65
|
amount_refunded > 0
|
|
@@ -89,9 +110,15 @@ module UsageCredits
|
|
|
89
110
|
if adapter.include?("postgres")
|
|
90
111
|
# PostgreSQL supports the @> JSON containment operator.
|
|
91
112
|
transactions.exists?(['metadata @> ?', { purchase_charge_id: id, credits_fulfilled: true }.to_json])
|
|
113
|
+
elsif adapter.include?("mysql")
|
|
114
|
+
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
115
|
+
transactions.exists?([
|
|
116
|
+
"JSON_EXTRACT(metadata, '$.purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_fulfilled') = CAST('true' AS JSON)",
|
|
117
|
+
id
|
|
118
|
+
])
|
|
92
119
|
else
|
|
93
|
-
#
|
|
94
|
-
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id,
|
|
120
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
121
|
+
transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id, 1])
|
|
95
122
|
end
|
|
96
123
|
rescue ActiveRecord::StatementInvalid
|
|
97
124
|
# If the SQL query fails (for example, if JSON_EXTRACT isn’t supported),
|
|
@@ -136,11 +163,13 @@ module UsageCredits
|
|
|
136
163
|
end
|
|
137
164
|
|
|
138
165
|
begin
|
|
166
|
+
credit_transaction = nil
|
|
167
|
+
|
|
139
168
|
# Wrap in transaction to ensure atomicity - if Fulfillment.create! fails,
|
|
140
169
|
# the credits should NOT be added. This is critical for money handling.
|
|
141
170
|
ActiveRecord::Base.transaction do
|
|
142
171
|
# Add credits to the user's wallet
|
|
143
|
-
credit_wallet.add_credits(
|
|
172
|
+
credit_transaction = credit_wallet.add_credits(
|
|
144
173
|
pack.total_credits,
|
|
145
174
|
category: "credit_pack_purchase",
|
|
146
175
|
metadata: {
|
|
@@ -169,6 +198,20 @@ module UsageCredits
|
|
|
169
198
|
)
|
|
170
199
|
end
|
|
171
200
|
|
|
201
|
+
# Dispatch credit_pack_purchased callback after successful fulfillment
|
|
202
|
+
# Note: credits_added callback was already fired by add_credits
|
|
203
|
+
UsageCredits::Callbacks.dispatch(:credit_pack_purchased,
|
|
204
|
+
wallet: credit_wallet,
|
|
205
|
+
amount: pack.total_credits,
|
|
206
|
+
transaction: credit_transaction,
|
|
207
|
+
metadata: {
|
|
208
|
+
credit_pack_name: pack_name,
|
|
209
|
+
credit_pack: pack,
|
|
210
|
+
pay_charge: self,
|
|
211
|
+
price_cents: pack.price_cents
|
|
212
|
+
}
|
|
213
|
+
)
|
|
214
|
+
|
|
172
215
|
Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}"
|
|
173
216
|
rescue StandardError => e
|
|
174
217
|
Rails.logger.error "Failed to fulfill credit pack #{pack_name} for charge #{id}: #{e.message}"
|
|
@@ -192,11 +235,18 @@ module UsageCredits
|
|
|
192
235
|
{ refunded_purchase_charge_id: id, credits_refunded: true }.to_json
|
|
193
236
|
)
|
|
194
237
|
return filtered.sum { |tx| -tx.amount }
|
|
238
|
+
elsif adapter.include?("mysql")
|
|
239
|
+
# MySQL: JSON_EXTRACT returns JSON values, use CAST for proper comparison
|
|
240
|
+
filtered = transactions.where(
|
|
241
|
+
"JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)",
|
|
242
|
+
id
|
|
243
|
+
)
|
|
244
|
+
return filtered.sum { |tx| -tx.amount }
|
|
195
245
|
else
|
|
196
|
-
# SQLite
|
|
246
|
+
# SQLite: json_extract returns SQL values (true becomes 1)
|
|
197
247
|
filtered = transactions.where(
|
|
198
248
|
"json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
|
|
199
|
-
id,
|
|
249
|
+
id, 1
|
|
200
250
|
)
|
|
201
251
|
return filtered.sum { |tx| -tx.amount }
|
|
202
252
|
end
|
|
@@ -162,17 +162,20 @@ module UsageCredits
|
|
|
162
162
|
Rails.logger.info " Status: #{status}"
|
|
163
163
|
Rails.logger.info " Plan: #{plan}"
|
|
164
164
|
|
|
165
|
+
# Variables to track for callback dispatch after transaction commits
|
|
166
|
+
total_credits_awarded = 0
|
|
167
|
+
last_credit_transaction = nil
|
|
168
|
+
|
|
165
169
|
# Transaction for atomic awarding + fulfillment creation/reactivation
|
|
170
|
+
# Callback is dispatched AFTER this block to ensure credits are persisted
|
|
166
171
|
ActiveRecord::Base.transaction do
|
|
167
|
-
|
|
168
|
-
total_credits_awarded = 0
|
|
169
172
|
transaction_ids = []
|
|
170
173
|
|
|
171
174
|
# 1) If this is a trial and not an active subscription: award trial credits, if any
|
|
172
175
|
if status == "trialing" && plan.trial_credits.positive?
|
|
173
176
|
|
|
174
177
|
# Immediate awarding of trial credits
|
|
175
|
-
|
|
178
|
+
last_credit_transaction = wallet.add_credits(plan.trial_credits,
|
|
176
179
|
category: "subscription_trial",
|
|
177
180
|
expires_at: trial_ends_at,
|
|
178
181
|
metadata: {
|
|
@@ -182,14 +185,14 @@ module UsageCredits
|
|
|
182
185
|
fulfilled_at: Time.current
|
|
183
186
|
}
|
|
184
187
|
)
|
|
185
|
-
transaction_ids <<
|
|
188
|
+
transaction_ids << last_credit_transaction.id
|
|
186
189
|
total_credits_awarded += plan.trial_credits
|
|
187
190
|
|
|
188
191
|
elsif status == "active"
|
|
189
192
|
|
|
190
193
|
# Awarding of signup bonus, if any (only on initial setup, not reactivation)
|
|
191
194
|
if plan.signup_bonus_credits.positive? && !is_reactivation
|
|
192
|
-
|
|
195
|
+
bonus_transaction = wallet.add_credits(plan.signup_bonus_credits,
|
|
193
196
|
category: "subscription_signup_bonus",
|
|
194
197
|
metadata: {
|
|
195
198
|
subscription_id: id,
|
|
@@ -198,13 +201,14 @@ module UsageCredits
|
|
|
198
201
|
fulfilled_at: Time.current
|
|
199
202
|
}
|
|
200
203
|
)
|
|
201
|
-
transaction_ids <<
|
|
204
|
+
transaction_ids << bonus_transaction.id
|
|
202
205
|
total_credits_awarded += plan.signup_bonus_credits
|
|
206
|
+
last_credit_transaction = bonus_transaction
|
|
203
207
|
end
|
|
204
208
|
|
|
205
209
|
# Actual awarding of the subscription credits
|
|
206
210
|
if plan.credits_per_period.positive?
|
|
207
|
-
|
|
211
|
+
credits_transaction = wallet.add_credits(plan.credits_per_period,
|
|
208
212
|
category: "subscription_credits",
|
|
209
213
|
expires_at: credits_expire_at, # This will be nil if credit rollover is enabled
|
|
210
214
|
metadata: {
|
|
@@ -214,8 +218,9 @@ module UsageCredits
|
|
|
214
218
|
fulfilled_at: Time.current
|
|
215
219
|
}
|
|
216
220
|
)
|
|
217
|
-
transaction_ids <<
|
|
221
|
+
transaction_ids << credits_transaction.id
|
|
218
222
|
total_credits_awarded += plan.credits_per_period
|
|
223
|
+
last_credit_transaction = credits_transaction
|
|
219
224
|
end
|
|
220
225
|
end
|
|
221
226
|
|
|
@@ -249,13 +254,12 @@ module UsageCredits
|
|
|
249
254
|
"reactivated_at" => Time.current
|
|
250
255
|
)
|
|
251
256
|
)
|
|
252
|
-
fulfillment = existing_reactivatable_fulfillment
|
|
253
257
|
|
|
254
|
-
Rails.logger.info "Reactivated fulfillment #{
|
|
258
|
+
Rails.logger.info "Reactivated fulfillment #{existing_reactivatable_fulfillment.id} for subscription #{id}"
|
|
255
259
|
else
|
|
256
260
|
# Create new fulfillment
|
|
257
261
|
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
258
|
-
|
|
262
|
+
UsageCredits::Fulfillment.create!(
|
|
259
263
|
wallet: wallet,
|
|
260
264
|
source: self,
|
|
261
265
|
fulfillment_type: "subscription",
|
|
@@ -270,16 +274,34 @@ module UsageCredits
|
|
|
270
274
|
}
|
|
271
275
|
)
|
|
272
276
|
|
|
273
|
-
Rails.logger.info "Initial fulfillment for subscription #{id} finished
|
|
277
|
+
Rails.logger.info "Initial fulfillment for subscription #{id} finished"
|
|
274
278
|
end
|
|
275
279
|
|
|
276
280
|
# Link created transactions to the fulfillment object for traceability
|
|
277
|
-
UsageCredits::
|
|
281
|
+
fulfillment_record = UsageCredits::Fulfillment.find_by(source: self)
|
|
282
|
+
UsageCredits::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment_record&.id) if transaction_ids.any?
|
|
283
|
+
end
|
|
278
284
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
285
|
+
# Dispatch callback AFTER transaction commits - ensures credits are persisted
|
|
286
|
+
if total_credits_awarded > 0
|
|
287
|
+
UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
|
|
288
|
+
wallet: wallet,
|
|
289
|
+
amount: total_credits_awarded,
|
|
290
|
+
transaction: last_credit_transaction,
|
|
291
|
+
metadata: {
|
|
292
|
+
subscription_plan_name: plan.name,
|
|
293
|
+
subscription: plan,
|
|
294
|
+
pay_subscription: self,
|
|
295
|
+
fulfillment_period: plan.fulfillment_period_display,
|
|
296
|
+
is_reactivation: is_reactivation,
|
|
297
|
+
status: status
|
|
298
|
+
}
|
|
299
|
+
)
|
|
282
300
|
end
|
|
301
|
+
|
|
302
|
+
rescue => e
|
|
303
|
+
Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}"
|
|
304
|
+
raise
|
|
283
305
|
end
|
|
284
306
|
|
|
285
307
|
# Handle subscription renewal (we received a new payment for another billing period)
|
|
@@ -433,45 +455,64 @@ module UsageCredits
|
|
|
433
455
|
|
|
434
456
|
Rails.logger.info " [UPGRADE] Credits expire at: #{credits_expire_at || 'never (rollover enabled)'}"
|
|
435
457
|
|
|
436
|
-
# Grant full new plan credits immediately
|
|
437
|
-
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
438
|
-
wallet.add_credits(
|
|
439
|
-
new_plan.credits_per_period,
|
|
440
|
-
category: "subscription_upgrade",
|
|
441
|
-
expires_at: credits_expire_at,
|
|
442
|
-
metadata: {
|
|
443
|
-
"subscription_id" => id,
|
|
444
|
-
"plan" => processor_plan,
|
|
445
|
-
"reason" => "plan_upgrade",
|
|
446
|
-
"fulfilled_at" => Time.current
|
|
447
|
-
}
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
Rails.logger.info " [UPGRADE] Credits awarded successfully"
|
|
451
|
-
Rails.logger.info " [UPGRADE] New balance: #{wallet.reload.balance}"
|
|
452
|
-
|
|
453
458
|
# Calculate next fulfillment time based on the NEW plan's period
|
|
454
459
|
# This ensures the fulfillment schedule matches the new plan's cadence
|
|
455
460
|
next_fulfillment_at = Time.current + new_plan.parsed_fulfillment_period
|
|
456
461
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
Rails.logger.info " [UPGRADE] Old next_fulfillment_at: #{fulfillment.next_fulfillment_at}"
|
|
461
|
-
Rails.logger.info " [UPGRADE] New next_fulfillment_at: #{next_fulfillment_at}"
|
|
462
|
+
# Wrap all database operations in a transaction to ensure atomicity
|
|
463
|
+
# The callback should only fire after ALL operations succeed
|
|
464
|
+
upgrade_transaction = nil
|
|
462
465
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
466
|
+
ActiveRecord::Base.transaction do
|
|
467
|
+
# Grant full new plan credits immediately
|
|
468
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
469
|
+
upgrade_transaction = wallet.add_credits(
|
|
470
|
+
new_plan.credits_per_period,
|
|
471
|
+
category: "subscription_upgrade",
|
|
472
|
+
expires_at: credits_expire_at,
|
|
473
|
+
metadata: {
|
|
474
|
+
"subscription_id" => id,
|
|
475
|
+
"plan" => processor_plan,
|
|
476
|
+
"reason" => "plan_upgrade",
|
|
477
|
+
"fulfilled_at" => Time.current
|
|
478
|
+
}
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
Rails.logger.info " [UPGRADE] Updating fulfillment record"
|
|
482
|
+
Rails.logger.info " [UPGRADE] Old fulfillment_period: #{fulfillment.fulfillment_period}"
|
|
483
|
+
Rails.logger.info " [UPGRADE] New fulfillment_period: #{new_plan.fulfillment_period_display}"
|
|
484
|
+
Rails.logger.info " [UPGRADE] Old next_fulfillment_at: #{fulfillment.next_fulfillment_at}"
|
|
485
|
+
Rails.logger.info " [UPGRADE] New next_fulfillment_at: #{next_fulfillment_at}"
|
|
486
|
+
|
|
487
|
+
# Update fulfillment with ALL new plan properties
|
|
488
|
+
# This includes the period display string and the next fulfillment time
|
|
489
|
+
# to ensure future fulfillments happen on the correct schedule
|
|
490
|
+
# Use string keys consistently to avoid duplicates after JSON serialization
|
|
491
|
+
fulfillment.update!(
|
|
492
|
+
fulfillment_period: new_plan.fulfillment_period_display,
|
|
493
|
+
next_fulfillment_at: next_fulfillment_at,
|
|
494
|
+
metadata: fulfillment.metadata
|
|
495
|
+
.except("pending_plan_change", "plan_change_at")
|
|
496
|
+
.merge("plan" => processor_plan)
|
|
497
|
+
)
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
# Dispatch callback AFTER transaction commits - ensures credits are persisted
|
|
501
|
+
UsageCredits::Callbacks.dispatch(:subscription_credits_awarded,
|
|
502
|
+
wallet: wallet,
|
|
503
|
+
amount: new_plan.credits_per_period,
|
|
504
|
+
transaction: upgrade_transaction,
|
|
505
|
+
metadata: {
|
|
506
|
+
subscription_plan_name: new_plan.name,
|
|
507
|
+
subscription: new_plan,
|
|
508
|
+
pay_subscription: self,
|
|
509
|
+
fulfillment_period: new_plan.fulfillment_period_display,
|
|
510
|
+
reason: "plan_upgrade"
|
|
511
|
+
}
|
|
473
512
|
)
|
|
474
513
|
|
|
514
|
+
Rails.logger.info " [UPGRADE] Credits awarded successfully"
|
|
515
|
+
Rails.logger.info " [UPGRADE] New balance: #{wallet.reload.balance}"
|
|
475
516
|
Rails.logger.info " [UPGRADE] Fulfillment updated successfully"
|
|
476
517
|
Rails.logger.info "Subscription #{id} upgraded to #{processor_plan}, granted #{new_plan.credits_per_period} credits"
|
|
477
518
|
Rails.logger.info " Fulfillment period updated to: #{new_plan.fulfillment_period_display}"
|
|
@@ -18,6 +18,31 @@ module UsageCredits
|
|
|
18
18
|
validates :next_fulfillment_at, comparison: { greater_than: :last_fulfilled_at },
|
|
19
19
|
if: -> { recurring? && last_fulfilled_at.present? && next_fulfillment_at.present? }
|
|
20
20
|
|
|
21
|
+
# =========================================
|
|
22
|
+
# Metadata Handling
|
|
23
|
+
# =========================================
|
|
24
|
+
|
|
25
|
+
# Sync in-place modifications to metadata before saving
|
|
26
|
+
before_save :sync_metadata_cache
|
|
27
|
+
|
|
28
|
+
# Get metadata with indifferent access (string/symbol keys)
|
|
29
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
30
|
+
def metadata
|
|
31
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Set metadata, ensuring consistent storage format
|
|
35
|
+
def metadata=(hash)
|
|
36
|
+
@indifferent_metadata = nil # Clear cache
|
|
37
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
41
|
+
def reload(*)
|
|
42
|
+
@indifferent_metadata = nil
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
21
46
|
# Only get fulfillments that are due AND not stopped
|
|
22
47
|
scope :due_for_fulfillment, -> {
|
|
23
48
|
where("next_fulfillment_at <= ?", Time.current)
|
|
@@ -65,6 +90,17 @@ module UsageCredits
|
|
|
65
90
|
|
|
66
91
|
private
|
|
67
92
|
|
|
93
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
94
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
95
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
96
|
+
def sync_metadata_cache
|
|
97
|
+
if @indifferent_metadata
|
|
98
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
99
|
+
elsif read_attribute(:metadata).nil?
|
|
100
|
+
write_attribute(:metadata, {})
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
68
104
|
def valid_fulfillment_period_format
|
|
69
105
|
unless UsageCredits::PeriodParser.valid_period_format?(fulfillment_period)
|
|
70
106
|
errors.add(:fulfillment_period, "must be in format like '2.months' or '15.days' and use supported units")
|
|
@@ -116,6 +116,23 @@ module UsageCredits
|
|
|
116
116
|
amount - allocated_amount
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# =========================================
|
|
120
|
+
# Balance After Transaction
|
|
121
|
+
# =========================================
|
|
122
|
+
|
|
123
|
+
# Get the balance after this transaction was applied
|
|
124
|
+
# Returns nil for transactions created before this feature was added
|
|
125
|
+
def balance_after
|
|
126
|
+
metadata[:balance_after]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Get the balance before this transaction was applied
|
|
130
|
+
# Returns the stored value if available, otherwise nil
|
|
131
|
+
# Note: For transactions created before this feature, returns nil
|
|
132
|
+
def balance_before
|
|
133
|
+
metadata[:balance_before]
|
|
134
|
+
end
|
|
135
|
+
|
|
119
136
|
# =========================================
|
|
120
137
|
# Display Formatting
|
|
121
138
|
# =========================================
|
|
@@ -126,6 +143,13 @@ module UsageCredits
|
|
|
126
143
|
"#{prefix}#{UsageCredits.configuration.credit_formatter.call(amount)}"
|
|
127
144
|
end
|
|
128
145
|
|
|
146
|
+
# Format the balance after for display (e.g., "500 credits")
|
|
147
|
+
# Returns nil if balance_after is not stored
|
|
148
|
+
def formatted_balance_after
|
|
149
|
+
return nil unless balance_after
|
|
150
|
+
UsageCredits.configuration.credit_formatter.call(balance_after)
|
|
151
|
+
end
|
|
152
|
+
|
|
129
153
|
# Get a human-readable description of what this transaction represents
|
|
130
154
|
def description
|
|
131
155
|
# Custom description takes precedence
|
|
@@ -142,7 +166,11 @@ module UsageCredits
|
|
|
142
166
|
# Metadata Handling
|
|
143
167
|
# =========================================
|
|
144
168
|
|
|
169
|
+
# Sync in-place modifications to metadata before saving
|
|
170
|
+
before_save :sync_metadata_cache
|
|
171
|
+
|
|
145
172
|
# Get metadata with indifferent access (string/symbol keys)
|
|
173
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
146
174
|
def metadata
|
|
147
175
|
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
148
176
|
end
|
|
@@ -153,8 +181,25 @@ module UsageCredits
|
|
|
153
181
|
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
154
182
|
end
|
|
155
183
|
|
|
184
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
185
|
+
def reload(*)
|
|
186
|
+
@indifferent_metadata = nil
|
|
187
|
+
super
|
|
188
|
+
end
|
|
189
|
+
|
|
156
190
|
private
|
|
157
191
|
|
|
192
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
193
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
194
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
195
|
+
def sync_metadata_cache
|
|
196
|
+
if @indifferent_metadata
|
|
197
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
198
|
+
elsif read_attribute(:metadata).nil?
|
|
199
|
+
write_attribute(:metadata, {})
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
158
203
|
# Format operation charge descriptions (e.g., "Process Video (-10 credits)")
|
|
159
204
|
def operation_description
|
|
160
205
|
operation = metadata["operation"]&.to_s&.titleize
|
|
@@ -25,6 +25,31 @@ module UsageCredits
|
|
|
25
25
|
|
|
26
26
|
validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
|
|
27
27
|
|
|
28
|
+
# =========================================
|
|
29
|
+
# Metadata Handling
|
|
30
|
+
# =========================================
|
|
31
|
+
|
|
32
|
+
# Sync in-place modifications to metadata before saving
|
|
33
|
+
before_save :sync_metadata_cache
|
|
34
|
+
|
|
35
|
+
# Get metadata with indifferent access (string/symbol keys)
|
|
36
|
+
# Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)
|
|
37
|
+
def metadata
|
|
38
|
+
@indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Set metadata, ensuring consistent storage format
|
|
42
|
+
def metadata=(hash)
|
|
43
|
+
@indifferent_metadata = nil # Clear cache
|
|
44
|
+
super(hash.is_a?(Hash) ? hash.to_h : {})
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Clear metadata cache on reload to ensure fresh data from database
|
|
48
|
+
def reload(*)
|
|
49
|
+
@indifferent_metadata = nil
|
|
50
|
+
super
|
|
51
|
+
end
|
|
52
|
+
|
|
28
53
|
# =========================================
|
|
29
54
|
# Credit Balance & History
|
|
30
55
|
# =========================================
|
|
@@ -91,7 +116,20 @@ module UsageCredits
|
|
|
91
116
|
cost = operation.calculate_cost(params)
|
|
92
117
|
|
|
93
118
|
# Check if user has enough credits
|
|
94
|
-
|
|
119
|
+
unless has_enough_credits_to?(operation_name, **params)
|
|
120
|
+
# Fire insufficient_credits callback before raising
|
|
121
|
+
UsageCredits::Callbacks.dispatch(:insufficient_credits,
|
|
122
|
+
wallet: self,
|
|
123
|
+
amount: cost,
|
|
124
|
+
operation_name: operation_name,
|
|
125
|
+
metadata: {
|
|
126
|
+
available: credits,
|
|
127
|
+
required: cost,
|
|
128
|
+
params: params
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})"
|
|
132
|
+
end
|
|
95
133
|
|
|
96
134
|
# Create audit trail
|
|
97
135
|
# Stringify keys from audit_data to avoid duplicate key warnings in JSON
|
|
@@ -156,6 +194,8 @@ module UsageCredits
|
|
|
156
194
|
amount = amount.to_i
|
|
157
195
|
raise ArgumentError, "Cannot add non-positive credits" if amount <= 0
|
|
158
196
|
|
|
197
|
+
previous_balance = credits # Capture BEFORE creating transaction
|
|
198
|
+
|
|
159
199
|
transaction = transactions.create!(
|
|
160
200
|
amount: amount,
|
|
161
201
|
category: category,
|
|
@@ -168,7 +208,26 @@ module UsageCredits
|
|
|
168
208
|
self.balance = credits
|
|
169
209
|
save!
|
|
170
210
|
|
|
171
|
-
|
|
211
|
+
# Store balance information in transaction metadata for audit trail.
|
|
212
|
+
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
213
|
+
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
214
|
+
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
215
|
+
# to ensure system-set values are authoritative.
|
|
216
|
+
transaction.update!(metadata: transaction.metadata.merge(
|
|
217
|
+
balance_before: previous_balance,
|
|
218
|
+
balance_after: balance
|
|
219
|
+
))
|
|
220
|
+
|
|
221
|
+
# Dispatch callback with full context
|
|
222
|
+
UsageCredits::Callbacks.dispatch(:credits_added,
|
|
223
|
+
wallet: self,
|
|
224
|
+
amount: amount,
|
|
225
|
+
category: category,
|
|
226
|
+
transaction: transaction,
|
|
227
|
+
previous_balance: previous_balance,
|
|
228
|
+
new_balance: balance,
|
|
229
|
+
metadata: metadata
|
|
230
|
+
)
|
|
172
231
|
|
|
173
232
|
# To finish, let's return the transaction that has been just created so we can reference it in parts of the code
|
|
174
233
|
# Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
|
|
@@ -253,11 +312,45 @@ module UsageCredits
|
|
|
253
312
|
self.balance = credits
|
|
254
313
|
save!
|
|
255
314
|
|
|
256
|
-
#
|
|
257
|
-
|
|
315
|
+
# Store balance information in transaction metadata for audit trail.
|
|
316
|
+
# Note: This update! is in the same DB transaction as the create! above (via with_lock),
|
|
317
|
+
# so if this fails, the entire transaction rolls back - no orphaned records possible.
|
|
318
|
+
# We intentionally overwrite any user-supplied balance_before/balance_after keys
|
|
319
|
+
# to ensure system-set values are authoritative.
|
|
320
|
+
spend_tx.update!(metadata: spend_tx.metadata.merge(
|
|
321
|
+
balance_before: previous_balance,
|
|
322
|
+
balance_after: balance
|
|
323
|
+
))
|
|
324
|
+
|
|
325
|
+
# Dispatch credits_deducted callback
|
|
326
|
+
UsageCredits::Callbacks.dispatch(:credits_deducted,
|
|
327
|
+
wallet: self,
|
|
328
|
+
amount: amount,
|
|
329
|
+
category: category,
|
|
330
|
+
transaction: spend_tx,
|
|
331
|
+
previous_balance: previous_balance,
|
|
332
|
+
new_balance: balance,
|
|
333
|
+
metadata: metadata
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
# Check for low balance threshold crossing
|
|
337
|
+
if !was_low_balance?(previous_balance) && low_balance?
|
|
338
|
+
UsageCredits::Callbacks.dispatch(:low_balance_reached,
|
|
339
|
+
wallet: self,
|
|
340
|
+
threshold: UsageCredits.configuration.low_balance_threshold,
|
|
341
|
+
previous_balance: previous_balance,
|
|
342
|
+
new_balance: balance
|
|
343
|
+
)
|
|
344
|
+
end
|
|
258
345
|
|
|
259
|
-
# Check
|
|
260
|
-
|
|
346
|
+
# Check for balance depletion (balance reaches exactly zero)
|
|
347
|
+
if previous_balance > 0 && balance == 0
|
|
348
|
+
UsageCredits::Callbacks.dispatch(:balance_depleted,
|
|
349
|
+
wallet: self,
|
|
350
|
+
previous_balance: previous_balance,
|
|
351
|
+
new_balance: 0
|
|
352
|
+
)
|
|
353
|
+
end
|
|
261
354
|
|
|
262
355
|
spend_tx
|
|
263
356
|
end
|
|
@@ -266,6 +359,17 @@ module UsageCredits
|
|
|
266
359
|
|
|
267
360
|
private
|
|
268
361
|
|
|
362
|
+
# Sync in-place modifications to the cached metadata back to the attribute
|
|
363
|
+
# This ensures changes like `metadata["key"] = "value"` are persisted on save
|
|
364
|
+
# Also ensures metadata is never null for MySQL compatibility (JSON columns can't have defaults)
|
|
365
|
+
def sync_metadata_cache
|
|
366
|
+
if @indifferent_metadata
|
|
367
|
+
write_attribute(:metadata, @indifferent_metadata.to_h)
|
|
368
|
+
elsif read_attribute(:metadata).nil?
|
|
369
|
+
write_attribute(:metadata, {})
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
269
373
|
# =========================================
|
|
270
374
|
# Helper Methods
|
|
271
375
|
# =========================================
|
|
@@ -291,23 +395,9 @@ module UsageCredits
|
|
|
291
395
|
end
|
|
292
396
|
|
|
293
397
|
# =========================================
|
|
294
|
-
# Balance
|
|
398
|
+
# Balance Threshold Helpers
|
|
295
399
|
# =========================================
|
|
296
400
|
|
|
297
|
-
def notify_balance_change(event, amount)
|
|
298
|
-
UsageCredits.handle_event(
|
|
299
|
-
event,
|
|
300
|
-
wallet: self,
|
|
301
|
-
amount: amount,
|
|
302
|
-
balance: credits
|
|
303
|
-
)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
def check_low_balance
|
|
307
|
-
return unless low_balance?
|
|
308
|
-
UsageCredits.handle_event(:low_balance_reached, wallet: self)
|
|
309
|
-
end
|
|
310
|
-
|
|
311
401
|
def low_balance?
|
|
312
402
|
threshold = UsageCredits.configuration.low_balance_threshold
|
|
313
403
|
return false if threshold.nil? || threshold.negative?
|
data/lib/usage_credits.rb
CHANGED
|
@@ -29,6 +29,8 @@ require "usage_credits/models/concerns/pay_charge_extension"
|
|
|
29
29
|
# 4. Core functionality
|
|
30
30
|
require "usage_credits/version"
|
|
31
31
|
require "usage_credits/configuration" # Single source of truth for all configuration in this gem
|
|
32
|
+
require "usage_credits/callback_context" # Struct for callback event data
|
|
33
|
+
require "usage_credits/callbacks" # Callback dispatch module
|
|
32
34
|
|
|
33
35
|
# 5. Shim Rails classes so requires don't break
|
|
34
36
|
module UsageCredits
|
|
@@ -76,6 +78,7 @@ module UsageCredits
|
|
|
76
78
|
# Reset configuration to defaults (mainly for testing)
|
|
77
79
|
def reset!
|
|
78
80
|
@configuration = nil
|
|
81
|
+
@deprecation_warnings = {}
|
|
79
82
|
end
|
|
80
83
|
|
|
81
84
|
# DSL methods - all delegate to configuration
|
|
@@ -128,16 +131,30 @@ module UsageCredits
|
|
|
128
131
|
end
|
|
129
132
|
alias_method :find_plan_by_id, :find_subscription_plan_by_processor_id
|
|
130
133
|
|
|
131
|
-
# Event handling for low balance notifications
|
|
134
|
+
# DEPRECATED: Event handling for low balance notifications
|
|
135
|
+
# Use on_low_balance_reached callback instead
|
|
136
|
+
# This method shows a deprecation warning only once to avoid log spam
|
|
132
137
|
def notify_low_balance(owner)
|
|
138
|
+
@deprecation_warnings ||= {}
|
|
139
|
+
unless @deprecation_warnings[:notify_low_balance]
|
|
140
|
+
warn "[DEPRECATION] UsageCredits.notify_low_balance is deprecated. Use on_low_balance_reached callback instead."
|
|
141
|
+
@deprecation_warnings[:notify_low_balance] = true
|
|
142
|
+
end
|
|
133
143
|
return unless configuration.low_balance_callback
|
|
134
144
|
configuration.low_balance_callback.call(owner)
|
|
135
145
|
end
|
|
136
146
|
|
|
147
|
+
# DEPRECATED: Events are now dispatched through Callbacks module
|
|
148
|
+
# This method shows a deprecation warning only once to avoid log spam
|
|
137
149
|
def handle_event(event, **params)
|
|
150
|
+
@deprecation_warnings ||= {}
|
|
151
|
+
unless @deprecation_warnings[:handle_event]
|
|
152
|
+
warn "[DEPRECATION] UsageCredits.handle_event is deprecated. Events are now dispatched through the Callbacks module."
|
|
153
|
+
@deprecation_warnings[:handle_event] = true
|
|
154
|
+
end
|
|
138
155
|
case event
|
|
139
156
|
when :low_balance_reached
|
|
140
|
-
notify_low_balance(params[:wallet]
|
|
157
|
+
notify_low_balance(params[:wallet]&.owner)
|
|
141
158
|
end
|
|
142
159
|
end
|
|
143
160
|
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: usage_credits
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-01-
|
|
10
|
+
date: 2026-01-16 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: pay
|
|
@@ -74,6 +74,8 @@ files:
|
|
|
74
74
|
- lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb
|
|
75
75
|
- lib/generators/usage_credits/templates/initializer.rb
|
|
76
76
|
- lib/usage_credits.rb
|
|
77
|
+
- lib/usage_credits/callback_context.rb
|
|
78
|
+
- lib/usage_credits/callbacks.rb
|
|
77
79
|
- lib/usage_credits/configuration.rb
|
|
78
80
|
- lib/usage_credits/core_ext/numeric.rb
|
|
79
81
|
- lib/usage_credits/cost/base.rb
|