usage_credits 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42e2dcff2633bf14d2390d436ab14f724fb1142bc6de5adf2b410ab36e13b785
4
- data.tar.gz: fef29f9815fa2cb241ed30c342083f98003a607b9b6c95dfd1d86ed1df7a5753
3
+ metadata.gz: 8e1f36f964eea89e835d789f34b6b2ce8994940896d21652af2400282c0e24a0
4
+ data.tar.gz: ad7c6be299aee7f89929841bfe18a6f44e2dd91361afe30ff238980bdc75544b
5
5
  SHA512:
6
- metadata.gz: 59be8e233c0a004cc6ab205a5f80aa2c17ce38d64df2b7bd99f2521ab9a185779c309ca376b5ceee684420c27d1fe3111907aa5c26c52f3c96c2dba50187895f
7
- data.tar.gz: 22cdd9089c33e9d50ecf598442d6aff6acd4924662542951e81eebbc3657d6300b8c66a746290aae6339b922d2ef7ef83caa097113aa3876a19cb3265c4ee0c4
6
+ metadata.gz: 4047f5a333b5e1359468468927100ea71a5c5b7117d8eec9e73a81ad8b40c243d796dd3d0e37604c9fd46b9b44239612d5bc1b30176e4322364312cc1c900017
7
+ data.tar.gz: b21b55c0b524b18fcf47339b926a425f53fcca5a3148f221bc8e86e4976f1281425041edd1d9cc903b68ab6cf82dd38a1a97df84373d1658d021cc43b3308de0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## [0.3.0] - 2026-01-15
2
+
3
+ - Add lifecycle callbacks by @rameerez in https://github.com/rameerez/usage_credits/pull/25
4
+ - 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
5
+
1
6
  ## [0.2.1] - 2026-01-15
2
7
 
3
8
  - 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.
@@ -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
@@ -136,11 +157,13 @@ module UsageCredits
136
157
  end
137
158
 
138
159
  begin
160
+ credit_transaction = nil
161
+
139
162
  # Wrap in transaction to ensure atomicity - if Fulfillment.create! fails,
140
163
  # the credits should NOT be added. This is critical for money handling.
141
164
  ActiveRecord::Base.transaction do
142
165
  # Add credits to the user's wallet
143
- credit_wallet.add_credits(
166
+ credit_transaction = credit_wallet.add_credits(
144
167
  pack.total_credits,
145
168
  category: "credit_pack_purchase",
146
169
  metadata: {
@@ -169,6 +192,20 @@ module UsageCredits
169
192
  )
170
193
  end
171
194
 
195
+ # Dispatch credit_pack_purchased callback after successful fulfillment
196
+ # Note: credits_added callback was already fired by add_credits
197
+ UsageCredits::Callbacks.dispatch(:credit_pack_purchased,
198
+ wallet: credit_wallet,
199
+ amount: pack.total_credits,
200
+ transaction: credit_transaction,
201
+ metadata: {
202
+ credit_pack_name: pack_name,
203
+ credit_pack: pack,
204
+ pay_charge: self,
205
+ price_cents: pack.price_cents
206
+ }
207
+ )
208
+
172
209
  Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}"
173
210
  rescue StandardError => e
174
211
  Rails.logger.error "Failed to fulfill credit pack #{pack_name} for charge #{id}: #{e.message}"
@@ -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}"
@@ -91,7 +91,20 @@ module UsageCredits
91
91
  cost = operation.calculate_cost(params)
92
92
 
93
93
  # Check if user has enough credits
94
- raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)
94
+ unless has_enough_credits_to?(operation_name, **params)
95
+ # Fire insufficient_credits callback before raising
96
+ UsageCredits::Callbacks.dispatch(:insufficient_credits,
97
+ wallet: self,
98
+ amount: cost,
99
+ operation_name: operation_name,
100
+ metadata: {
101
+ available: credits,
102
+ required: cost,
103
+ params: params
104
+ }
105
+ )
106
+ raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})"
107
+ end
95
108
 
96
109
  # Create audit trail
97
110
  # Stringify keys from audit_data to avoid duplicate key warnings in JSON
@@ -156,6 +169,8 @@ module UsageCredits
156
169
  amount = amount.to_i
157
170
  raise ArgumentError, "Cannot add non-positive credits" if amount <= 0
158
171
 
172
+ previous_balance = credits # Capture BEFORE creating transaction
173
+
159
174
  transaction = transactions.create!(
160
175
  amount: amount,
161
176
  category: category,
@@ -168,7 +183,16 @@ module UsageCredits
168
183
  self.balance = credits
169
184
  save!
170
185
 
171
- notify_balance_change(:credits_added, amount)
186
+ # Dispatch callback with full context
187
+ UsageCredits::Callbacks.dispatch(:credits_added,
188
+ wallet: self,
189
+ amount: amount,
190
+ category: category,
191
+ transaction: transaction,
192
+ previous_balance: previous_balance,
193
+ new_balance: balance,
194
+ metadata: metadata
195
+ )
172
196
 
173
197
  # To finish, let's return the transaction that has been just created so we can reference it in parts of the code
174
198
  # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
@@ -253,11 +277,35 @@ module UsageCredits
253
277
  self.balance = credits
254
278
  save!
255
279
 
256
- # Fire your existing notifications
257
- notify_balance_change(:credits_deducted, amount)
280
+ # Dispatch credits_deducted callback
281
+ UsageCredits::Callbacks.dispatch(:credits_deducted,
282
+ wallet: self,
283
+ amount: amount,
284
+ category: category,
285
+ transaction: spend_tx,
286
+ previous_balance: previous_balance,
287
+ new_balance: balance,
288
+ metadata: metadata
289
+ )
258
290
 
259
- # Check if we crossed the low balance threshold
260
- check_low_balance if !was_low_balance?(previous_balance) && low_balance?
291
+ # Check for low balance threshold crossing
292
+ if !was_low_balance?(previous_balance) && low_balance?
293
+ UsageCredits::Callbacks.dispatch(:low_balance_reached,
294
+ wallet: self,
295
+ threshold: UsageCredits.configuration.low_balance_threshold,
296
+ previous_balance: previous_balance,
297
+ new_balance: balance
298
+ )
299
+ end
300
+
301
+ # Check for balance depletion (balance reaches exactly zero)
302
+ if previous_balance > 0 && balance == 0
303
+ UsageCredits::Callbacks.dispatch(:balance_depleted,
304
+ wallet: self,
305
+ previous_balance: previous_balance,
306
+ new_balance: 0
307
+ )
308
+ end
261
309
 
262
310
  spend_tx
263
311
  end
@@ -291,23 +339,9 @@ module UsageCredits
291
339
  end
292
340
 
293
341
  # =========================================
294
- # Balance Change Notifications
342
+ # Balance Threshold Helpers
295
343
  # =========================================
296
344
 
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
345
  def low_balance?
312
346
  threshold = UsageCredits.configuration.low_balance_threshold
313
347
  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.3.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,7 +1,7 @@
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.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
@@ -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