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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42e2dcff2633bf14d2390d436ab14f724fb1142bc6de5adf2b410ab36e13b785
4
- data.tar.gz: fef29f9815fa2cb241ed30c342083f98003a607b9b6c95dfd1d86ed1df7a5753
3
+ metadata.gz: 6947ef76d1f3674d94e3fbff6f4149daff82f0e90e55365c485f2e6eef88b319
4
+ data.tar.gz: 784a9c5fb5ca6b139bf04bab6f85ddff1d96a15e30cd57f7d8b2f9f167f0aac6
5
5
  SHA512:
6
- metadata.gz: 59be8e233c0a004cc6ab205a5f80aa2c17ce38d64df2b7bd99f2521ab9a185779c309ca376b5ceee684420c27d1fe3111907aa5c26c52f3c96c2dba50187895f
7
- data.tar.gz: 22cdd9089c33e9d50ecf598442d6aff6acd4924662542951e81eebbc3657d6300b8c66a746290aae6339b922d2ef7ef83caa097113aa3876a19cb3265c4ee0c4
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
- You can hook on to our low balance event to notify users when they are running low on credits (useful to upsell them a credit pack):
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
- # Alert when balance drops below 100 credits
275
- # Set to nil to disable low balance alerts
276
- config.low_balance_threshold = 100.credits
277
-
278
- # Handle low credit balance alerts
279
- config.on_low_balance do |user|
280
- # Send notification to user
281
- UserMailer.low_credits_alert(user).deliver_later
282
-
283
- # Or trigger any other business logic
284
- SlackNotifier.notify("User #{user.id} is running low on credits!")
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
- # Alert when balance drops below this threshold (default: 100 credits)
87
- # Set to nil to disable low balance alerts
88
- #
89
- # config.low_balance_threshold = 100.credits
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
- # Handle low credit balance alerts Useful to sell booster credit packs, for example
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
- # config.on_low_balance do |user|
95
- # Send notification to user when their balance drops below the threshold
96
- # UserMailer.low_credits_alert(user).deliver_later
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
- # Set what happens when credits are low
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
- status = data["status"] || data[:status]
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
- return data["amount_captured"].to_i == amount.to_i && amount.to_i.positive?
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
- # For other adapters (e.g. SQLite, MySQL), try using JSON_EXTRACT.
94
- transactions.exists?(["json_extract(metadata, '$.purchase_charge_id') = ? AND json_extract(metadata, '$.credits_fulfilled') = ?", id, true])
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/MySQL with JSON_EXTRACT
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, true
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
- transaction = wallet.add_credits(plan.trial_credits,
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 << transaction.id
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
- transaction = wallet.add_credits(plan.signup_bonus_credits,
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 << transaction.id
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
- transaction = wallet.add_credits(plan.credits_per_period,
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 << transaction.id
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 #{fulfillment.id} for subscription #{id}"
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
- fulfillment = UsageCredits::Fulfillment.create!(
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. Created fulfillment #{fulfillment.id}"
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::Transaction.where(id: transaction_ids).update_all(fulfillment_id: fulfillment.id)
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
- rescue => e
280
- Rails.logger.error "Failed to fulfill initial credits for subscription #{id}: #{e.message}"
281
- raise ActiveRecord::Rollback
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
- Rails.logger.info " [UPGRADE] Updating fulfillment record"
458
- Rails.logger.info " [UPGRADE] Old fulfillment_period: #{fulfillment.fulfillment_period}"
459
- Rails.logger.info " [UPGRADE] New fulfillment_period: #{new_plan.fulfillment_period_display}"
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
- # Update fulfillment with ALL new plan properties
464
- # This includes the period display string and the next fulfillment time
465
- # to ensure future fulfillments happen on the correct schedule
466
- # Use string keys consistently to avoid duplicates after JSON serialization
467
- fulfillment.update!(
468
- fulfillment_period: new_plan.fulfillment_period_display,
469
- next_fulfillment_at: next_fulfillment_at,
470
- metadata: fulfillment.metadata
471
- .except("pending_plan_change", "plan_change_at")
472
- .merge("plan" => processor_plan)
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
- raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)
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
- notify_balance_change(:credits_added, amount)
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
- # Fire your existing notifications
257
- notify_balance_change(:credits_deducted, amount)
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 if we crossed the low balance threshold
260
- check_low_balance if !was_low_balance?(previous_balance) && low_balance?
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 Change Notifications
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?
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module UsageCredits
4
- VERSION = "0.2.1"
4
+ VERSION = "0.4.0"
5
5
  end
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].owner)
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.2.1
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-15 00:00:00.000000000 Z
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