usage_credits 0.4.0 → 1.0.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +246 -13
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +65 -21
  6. data/lib/generators/usage_credits/templates/upgrade_usage_credits_to_wallets_core.rb.erb +602 -0
  7. data/lib/generators/usage_credits/upgrade_generator.rb +47 -0
  8. data/lib/usage_credits/callbacks.rb +9 -54
  9. data/lib/usage_credits/configuration.rb +43 -13
  10. data/lib/usage_credits/core_ext/numeric.rb +12 -16
  11. data/lib/usage_credits/cost/base.rb +1 -6
  12. data/lib/usage_credits/cost/compound.rb +3 -2
  13. data/lib/usage_credits/cost/fixed.rb +1 -2
  14. data/lib/usage_credits/cost/variable.rb +23 -8
  15. data/lib/usage_credits/engine.rb +8 -12
  16. data/lib/usage_credits/helpers/credit_calculator.rb +18 -0
  17. data/lib/usage_credits/helpers/credits_helper.rb +9 -11
  18. data/lib/usage_credits/helpers/period_parser.rb +31 -9
  19. data/lib/usage_credits/helpers/processor_metadata.rb +46 -0
  20. data/lib/usage_credits/jobs/fulfillment_job.rb +1 -1
  21. data/lib/usage_credits/models/allocation.rb +15 -20
  22. data/lib/usage_credits/models/concerns/has_wallet.rb +50 -27
  23. data/lib/usage_credits/models/concerns/pay_charge_extension.rb +184 -139
  24. data/lib/usage_credits/models/concerns/pay_subscription_extension.rb +490 -174
  25. data/lib/usage_credits/models/credit_pack.rb +34 -13
  26. data/lib/usage_credits/models/credit_subscription_plan.rb +36 -15
  27. data/lib/usage_credits/models/fulfillment.rb +23 -48
  28. data/lib/usage_credits/models/operation.rb +36 -28
  29. data/lib/usage_credits/models/transaction.rb +55 -152
  30. data/lib/usage_credits/models/transfer.rb +37 -0
  31. data/lib/usage_credits/models/wallet.rb +219 -314
  32. data/lib/usage_credits/railtie.rb +4 -13
  33. data/lib/usage_credits/services/fulfillment_service.rb +146 -22
  34. data/lib/usage_credits/subscription_terms.rb +131 -0
  35. data/lib/usage_credits/version.rb +1 -1
  36. data/lib/usage_credits.rb +16 -3
  37. metadata +33 -20
  38. data/.rubocop.yml +0 -8
  39. data/.simplecov +0 -48
  40. data/AGENTS.md +0 -5
  41. data/Appraisals +0 -43
  42. data/CLAUDE.md +0 -5
  43. data/Rakefile +0 -32
  44. data/gemfiles/pay_10.0.gemfile +0 -41
  45. data/gemfiles/pay_11.0.gemfile +0 -41
  46. data/gemfiles/pay_8.3.gemfile +0 -41
  47. data/gemfiles/pay_9.0.gemfile +0 -41
  48. data/gemfiles/rails_7.2.gemfile +0 -41
  49. data/gemfiles/rails_8.1.gemfile +0 -41
@@ -6,11 +6,19 @@ module UsageCredits
6
6
  extend ActiveSupport::Concern
7
7
 
8
8
  included do
9
+ # Filter to the default "credits" asset_code for backwards compatibility
9
10
  has_one :credit_wallet,
10
- class_name: "UsageCredits::Wallet",
11
- as: :owner,
12
- dependent: :destroy
13
-
11
+ -> { where(asset_code: UsageCredits::DEFAULT_ASSET_CODE) },
12
+ class_name: "UsageCredits::Wallet",
13
+ as: :owner,
14
+ dependent: :destroy
15
+
16
+ # NOTE on the alias dance below: `credits_wallet` and `wallet` are aliased
17
+ # to the raw has_one reader *before* `credit_wallet` is redefined to
18
+ # auto-create missing wallets (see `define_method(:credit_wallet)` further
19
+ # down). So `user.credit_wallet` auto-creates, while `user.wallet` /
20
+ # `user.credits_wallet` just read. This asymmetry is the pre-1.0 contract,
21
+ # kept as-is for backwards compatibility.
14
22
  alias_method :credits_wallet, :credit_wallet
15
23
  alias_method :wallet, :credit_wallet
16
24
 
@@ -18,13 +26,13 @@ module UsageCredits
18
26
 
19
27
  # More intuitive delegations
20
28
  delegate :credits,
21
- :credit_history,
22
- :has_enough_credits_to?,
23
- :estimate_credits_to,
24
- :spend_credits_on,
25
- :give_credits,
26
- to: :ensure_credit_wallet,
27
- allow_nil: false # Never return nil for these methods
29
+ :credit_history,
30
+ :has_enough_credits_to?,
31
+ :estimate_credits_to,
32
+ :spend_credits_on,
33
+ :give_credits,
34
+ to: :ensure_credit_wallet,
35
+ allow_nil: false # Never return nil for these methods
28
36
 
29
37
  # Fix recursion by properly aliasing the original method
30
38
  alias_method :original_credit_wallet, :credit_wallet
@@ -44,7 +52,6 @@ module UsageCredits
44
52
  .map { |f| UsageCredits.find_subscription_plan_by_processor_id(f.metadata["plan"]) }
45
53
  .compact
46
54
  end
47
-
48
55
  end
49
56
 
50
57
  # Class methods added to the model
@@ -52,15 +59,17 @@ module UsageCredits
52
59
  def has_credits(**options)
53
60
  include UsageCredits::HasWallet unless included_modules.include?(UsageCredits::HasWallet)
54
61
 
55
- # Initialize class instance variable instead of class variable
56
- @credit_options = options
57
-
58
- # Ensure wallet is created by default unless explicitly disabled
59
- @credit_options[:auto_create] = true if @credit_options[:auto_create].nil?
62
+ # Each class owns an immutable options snapshot. Subclasses inherit it
63
+ # until they explicitly call `has_credits`, without sharing a mutable
64
+ # hash with their parent.
65
+ @credit_options = {auto_create: true}.merge(options.symbolize_keys).freeze
60
66
  end
61
67
 
62
68
  def credit_options
63
- @credit_options ||= { auto_create: true }
69
+ return @credit_options if instance_variable_defined?(:@credit_options)
70
+ return superclass.credit_options if superclass.respond_to?(:credit_options)
71
+
72
+ {auto_create: true}
64
73
  end
65
74
  end
66
75
 
@@ -75,16 +84,30 @@ module UsageCredits
75
84
  end
76
85
 
77
86
  def ensure_credit_wallet
78
- return original_credit_wallet if original_credit_wallet.present?
79
- return unless should_create_wallet?
80
-
81
- if persisted?
82
- build_credit_wallet(
83
- balance: credit_options[:initial_balance] || 0
84
- ).tap(&:save!)
85
- else
86
- raise "Cannot create wallet for unsaved owner"
87
+ credit_wallet_association = association(:credit_wallet)
88
+ cached_wallet = credit_wallet_association.target if credit_wallet_association.loaded?
89
+ return cached_wallet if cached_wallet&.persisted?
90
+
91
+ # With auto-creation disabled, retain the ordinary has_one reader and
92
+ # refresh a cached miss so a concurrently-created wallet is still seen.
93
+ # With it enabled, create_for_owner! is the one lookup/creation primitive:
94
+ # it returns an existing row or creates it race-safely. This avoids
95
+ # querying once through the association and then repeating the same
96
+ # owner/asset lookup in create_for_owner! on a cold cache.
97
+ unless should_create_wallet?
98
+ return original_credit_wallet unless credit_wallet_association.loaded?
99
+ return credit_wallet_association.reload.target
87
100
  end
101
+ raise "Cannot create wallet for unsaved owner" unless persisted?
102
+
103
+ wallet = UsageCredits::Wallet.create_for_owner!(
104
+ owner: self,
105
+ asset_code: UsageCredits::DEFAULT_ASSET_CODE,
106
+ initial_balance: credit_options[:initial_balance]
107
+ )
108
+
109
+ self.credit_wallet = wallet
110
+ wallet
88
111
  end
89
112
 
90
113
  def create_credit_wallet
@@ -11,7 +11,7 @@ module UsageCredits
11
11
 
12
12
  included do
13
13
  after_initialize :init_metadata
14
- after_commit :fulfill_credit_pack!
14
+ after_commit :fulfill_credit_pack!, on: [:create, :update]
15
15
  after_commit :handle_refund!, on: :update, if: :refund_needed?
16
16
  end
17
17
 
@@ -40,9 +40,9 @@ module UsageCredits
40
40
  return amount_captured == amount.to_i && amount.to_i.positive?
41
41
  end
42
42
 
43
- # For non-Stripe charges, we assume Pay only creates charges after successful payment
44
- # This is a reasonable assumption based on Pay gem's behavior
45
- # TODO: Implement for more payment processors if needed
43
+ # Pay's non-Stripe adapters persist Pay::Charge rows only after their
44
+ # processor-specific success event (for example, Paddle Billing ignores
45
+ # every transaction whose status is not "completed").
46
46
  true
47
47
  end
48
48
 
@@ -95,6 +95,45 @@ module UsageCredits
95
95
  metadata["pack_name"]
96
96
  end
97
97
 
98
+ # Checkout metadata is the immutable commercial snapshot: it records what
99
+ # the customer bought at payment time. Runtime configuration may be edited
100
+ # or the pack may be removed before a delayed webhook/refund arrives, so it
101
+ # must only be a legacy fallback, never the source of truth for quantities.
102
+ def credit_pack_snapshot
103
+ pack_name = pack_identifier.to_s
104
+ pack = UsageCredits.find_pack(pack_name.to_sym)
105
+
106
+ credits = strict_metadata_integer("credits", fallback: pack&.credits)
107
+ bonus_credits = strict_metadata_integer("bonus_credits", fallback: pack&.bonus_credits || 0)
108
+ return if credits.nil? || bonus_credits.nil? || !credits.positive? || bonus_credits.negative?
109
+
110
+ snapshot = {
111
+ pack_name: pack_name,
112
+ credits: credits,
113
+ bonus_credits: bonus_credits,
114
+ total_credits: credits + bonus_credits,
115
+ price_cents: strict_metadata_integer("price_cents", fallback: pack&.price_cents),
116
+ price_currency: (metadata["price_currency"].presence || pack&.price_currency || currency).to_s.upcase
117
+ }
118
+
119
+ if pack && (pack.credits != credits || pack.bonus_credits != bonus_credits)
120
+ Rails.logger.warn "Credit pack #{pack_name} changed after charge #{id}; honoring checkout snapshot (#{credits} + #{bonus_credits} credits)"
121
+ end
122
+
123
+ snapshot
124
+ rescue ArgumentError => e
125
+ Rails.logger.error "Invalid credit pack metadata for charge #{id}: #{e.message}"
126
+ nil
127
+ end
128
+
129
+ def strict_metadata_integer(key, fallback: nil)
130
+ value = metadata[key]
131
+ value = fallback if value.nil? || (value.respond_to?(:empty?) && value.empty?)
132
+ return nil if value.nil?
133
+
134
+ Wallets::WholeNumber.parse(value, name: key, allow_string: true)
135
+ end
136
+
98
137
  def credits_already_fulfilled?
99
138
  # First check if there's a fulfillment record for this charge
100
139
  return true if UsageCredits::Fulfillment.exists?(source: self)
@@ -106,12 +145,12 @@ module UsageCredits
106
145
  return false unless transactions.present?
107
146
 
108
147
  begin
109
- adapter = ActiveRecord::Base.connection.adapter_name.downcase
148
+ adapter = transactions.connection.adapter_name.downcase
110
149
  if adapter.include?("postgres")
111
150
  # PostgreSQL supports the @> JSON containment operator.
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
151
+ transactions.exists?(["metadata @> ?", {purchase_charge_id: id, credits_fulfilled: true}.to_json])
152
+ elsif adapter.match?(/mysql|trilogy/)
153
+ # MySQL (Trilogy is MySQL under another name): JSON_EXTRACT returns JSON values, use CAST for proper comparison
115
154
  transactions.exists?([
116
155
  "JSON_EXTRACT(metadata, '$.purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_fulfilled') = CAST('true' AS JSON)",
117
156
  id
@@ -128,7 +167,11 @@ module UsageCredits
128
167
  if tx.metadata.is_a?(Hash)
129
168
  tx.metadata
130
169
  else
131
- JSON.parse(tx.metadata) rescue {}
170
+ begin
171
+ JSON.parse(tx.metadata)
172
+ rescue
173
+ {}
174
+ end
132
175
  end
133
176
  data["purchase_charge_id"].to_i == id.to_i && data["credits_fulfilled"].to_s == "true"
134
177
  end
@@ -137,7 +180,7 @@ module UsageCredits
137
180
 
138
181
  def fulfill_credit_pack!
139
182
  return unless is_credit_pack_purchase?
140
- return unless pack_identifier
183
+ return unless pack_identifier.present?
141
184
  return unless has_valid_wallet?
142
185
  return unless succeeded?
143
186
  return if refunded?
@@ -145,111 +188,116 @@ module UsageCredits
145
188
 
146
189
  Rails.logger.info "Starting to process charge #{id} to fulfill credits"
147
190
 
148
- pack_name = pack_identifier.to_sym
149
- pack = UsageCredits.find_pack(pack_name)
150
-
151
- unless pack
152
- Rails.logger.error "Credit pack not found: #{pack_name} for charge #{id}"
191
+ snapshot = credit_pack_snapshot
192
+ unless snapshot
193
+ Rails.logger.error "Credit pack snapshot is missing or invalid for charge #{id}"
153
194
  return
154
195
  end
155
196
 
156
- # Validate that the pack details match if they're provided in metadata
157
- if metadata["credits"].present?
158
- expected_credits = metadata["credits"].to_i
159
- if expected_credits != pack.credits
160
- Rails.logger.error "Credit pack mismatch: expected #{expected_credits} credits but pack #{pack_name} provides #{pack.credits}"
161
- return
162
- end
163
- end
164
-
165
197
  begin
198
+ wallet = credit_wallet
166
199
  credit_transaction = nil
167
-
168
- # Wrap in transaction to ensure atomicity - if Fulfillment.create! fails,
169
- # the credits should NOT be added. This is critical for money handling.
170
- ActiveRecord::Base.transaction do
171
- # Add credits to the user's wallet
172
- credit_transaction = credit_wallet.add_credits(
173
- pack.total_credits,
174
- category: "credit_pack_purchase",
175
- metadata: {
200
+ fulfillment = nil
201
+ fulfilled = false
202
+
203
+ # The wallet lock serializes duplicate webhook deliveries for the same
204
+ # owner. Re-check idempotency inside that lock; the database's unique
205
+ # source index is the final guard against duplicate fulfillment rows.
206
+ wallet.with_lock do
207
+ next if UsageCredits::Fulfillment.exists?(source: self)
208
+
209
+ fulfilled_at = Time.current
210
+ fulfillment = Fulfillment.create!(
211
+ wallet: wallet,
212
+ source: self,
213
+ fulfillment_type: "credit_pack",
214
+ credits_last_fulfillment: snapshot.fetch(:total_credits),
215
+ last_fulfilled_at: fulfilled_at,
216
+ next_fulfillment_at: nil,
217
+ metadata: snapshot.merge(
176
218
  purchase_charge_id: id,
177
- purchased_at: created_at,
178
- credits_fulfilled: true,
179
- fulfilled_at: Time.current,
180
- **pack.base_metadata
181
- }
219
+ purchased_at: created_at
220
+ )
182
221
  )
183
222
 
184
- # Also create a one-time fulfillment record for audit and consistency
185
- # This Fulfillment record won't get picked up by the fulfillment job because `next_fulfillment_at` is nil
186
- Fulfillment.create!(
187
- wallet: credit_wallet,
188
- source: self, # the Pay::Charge
189
- fulfillment_type: "credit_pack",
190
- credits_last_fulfillment: pack.total_credits,
191
- last_fulfilled_at: Time.current,
192
- next_fulfillment_at: nil, # so it doesn't get re-processed
223
+ credit_transaction = wallet.add_credits(
224
+ snapshot.fetch(:total_credits),
225
+ category: "credit_pack_purchase",
226
+ fulfillment: fulfillment,
193
227
  metadata: {
194
228
  purchase_charge_id: id,
195
229
  purchased_at: created_at,
196
- **pack.base_metadata
230
+ credits_fulfilled: true,
231
+ fulfilled_at: fulfilled_at,
232
+ **snapshot
197
233
  }
198
234
  )
235
+ fulfilled = true
199
236
  end
200
237
 
238
+ return unless fulfilled
239
+
201
240
  # Dispatch credit_pack_purchased callback after successful fulfillment
202
241
  # 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
- )
242
+ ActiveRecord.after_all_transactions_commit do
243
+ UsageCredits::Callbacks.dispatch(:credit_pack_purchased,
244
+ wallet: wallet,
245
+ amount: snapshot.fetch(:total_credits),
246
+ transaction: credit_transaction,
247
+ metadata: {
248
+ credit_pack_name: snapshot.fetch(:pack_name).to_sym,
249
+ credit_pack: UsageCredits.find_pack(snapshot.fetch(:pack_name).to_sym),
250
+ pay_charge: self,
251
+ fulfillment: fulfillment,
252
+ price_cents: snapshot[:price_cents]
253
+ })
254
+ end
214
255
 
215
- Rails.logger.info "Successfully fulfilled credit pack #{pack_name} for charge #{id}"
216
- rescue StandardError => e
217
- Rails.logger.error "Failed to fulfill credit pack #{pack_name} for charge #{id}: #{e.message}"
256
+ Rails.logger.info "Successfully fulfilled credit pack #{snapshot.fetch(:pack_name)} for charge #{id}"
257
+ rescue ActiveRecord::RecordNotUnique
258
+ # A concurrent delivery committed the unique source row first. Treat
259
+ # that committed fulfillment as the idempotent winner.
260
+ return if UsageCredits::Fulfillment.exists?(source: self)
261
+ raise
262
+ rescue => e
263
+ Rails.logger.error "Failed to fulfill credit pack #{pack_identifier} for charge #{id}: #{e.message}"
218
264
  raise
219
265
  end
220
266
  end
221
267
 
222
268
  # Returns the total credits already refunded for this charge
223
269
  # Note: NOT memoized because refunds can happen incrementally within the same request
224
- def credits_previously_refunded
225
- transactions = credit_wallet&.transactions&.where(category: "credit_pack_refund")
270
+ # What earlier refunds of this charge already took, summed from
271
+ # `transactions`: the wallet's refund debits, which the wallet's refund
272
+ # command passes in read as the ledger is now.
273
+ def credits_previously_refunded(transactions = credit_wallet&.transactions&.where(category: "credit_pack_refund"))
226
274
  return 0 unless transactions.present?
227
275
 
228
276
  # Try database-level filtering first (more efficient)
229
277
  begin
230
- adapter = ActiveRecord::Base.connection.adapter_name.downcase
231
- if adapter.include?("postgres")
232
- # PostgreSQL supports the @> JSON containment operator
233
- filtered = transactions.where(
234
- "metadata @> ?",
235
- { refunded_purchase_charge_id: id, credits_refunded: true }.to_json
236
- )
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 }
245
- else
246
- # SQLite: json_extract returns SQL values (true becomes 1)
247
- filtered = transactions.where(
248
- "json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
249
- id, 1
250
- )
251
- return filtered.sum { |tx| -tx.amount }
252
- end
278
+ adapter = transactions.connection.adapter_name.downcase
279
+ filtered =
280
+ if adapter.include?("postgres")
281
+ # PostgreSQL supports the @> JSON containment operator
282
+ transactions.where(
283
+ "metadata @> ?",
284
+ {refunded_purchase_charge_id: id, credits_refunded: true}.to_json
285
+ )
286
+ elsif adapter.match?(/mysql|trilogy/)
287
+ # MySQL (Trilogy is MySQL under another name): JSON_EXTRACT returns JSON values, use CAST for proper comparison
288
+ transactions.where(
289
+ "JSON_EXTRACT(metadata, '$.refunded_purchase_charge_id') = CAST(? AS JSON) AND JSON_EXTRACT(metadata, '$.credits_refunded') = CAST('true' AS JSON)",
290
+ id
291
+ )
292
+ else
293
+ # SQLite: json_extract returns SQL values (true becomes 1)
294
+ transactions.where(
295
+ "json_extract(metadata, '$.refunded_purchase_charge_id') = ? AND json_extract(metadata, '$.credits_refunded') = ?",
296
+ id, 1
297
+ )
298
+ end
299
+
300
+ return filtered.sum { |tx| -tx.amount }
253
301
  rescue ActiveRecord::StatementInvalid => e
254
302
  Rails.logger.warn "JSON query failed, falling back to Ruby filtering: #{e.message}"
255
303
  end
@@ -257,7 +305,11 @@ module UsageCredits
257
305
  # Fallback: filter in Ruby (for databases without JSON support)
258
306
  # Sum in a single pass to avoid multiple iterations
259
307
  transactions.sum do |tx|
260
- data = tx.metadata.is_a?(Hash) ? tx.metadata : (JSON.parse(tx.metadata) rescue {})
308
+ data = tx.metadata.is_a?(Hash) ? tx.metadata : begin
309
+ JSON.parse(tx.metadata)
310
+ rescue
311
+ {}
312
+ end
261
313
  if data["refunded_purchase_charge_id"].to_i == id.to_i && data["credits_refunded"].to_s == "true"
262
314
  -tx.amount
263
315
  else
@@ -266,30 +318,29 @@ module UsageCredits
266
318
  end
267
319
  end
268
320
 
269
- def credits_already_refunded?
270
- # Check if any refund was already processed for this charge
271
- credits_previously_refunded > 0
272
- end
273
-
274
- def fully_refunded?
275
- # Check if a full refund (100%) has already been processed
276
- pack = UsageCredits.find_pack(pack_identifier&.to_sym)
277
- return false unless pack
278
- credits_previously_refunded >= pack.total_credits
279
- end
280
-
281
321
  def handle_refund!
282
322
  # Guard clauses for required data and state
283
323
  return unless refunded?
284
- return unless pack_identifier
324
+ return unless pack_identifier.present?
285
325
  return unless has_valid_wallet?
286
326
  return unless amount.is_a?(Numeric) && amount.positive?
287
327
 
288
- pack_name = pack_identifier.to_sym
289
- pack = UsageCredits.find_pack(pack_name)
328
+ fulfillment = UsageCredits::Fulfillment.find_by(source: self)
329
+ # Processor metadata proves what a charge was intended to buy, not that
330
+ # the corresponding credits were ever issued. A refund can arrive for a
331
+ # charge whose fulfillment failed (or whose create webhook was never
332
+ # delivered); clawing that metadata snapshot back would manufacture
333
+ # credit debt for value the customer never received. Keep the legacy
334
+ # transaction lookup for pre-1.0 purchases that predate Fulfillment rows.
335
+ unless fulfillment || credits_already_fulfilled?
336
+ Rails.logger.error "Cannot refund credits for charge #{id}: no completed credit fulfillment exists"
337
+ return
338
+ end
290
339
 
291
- unless pack
292
- Rails.logger.error "Credit pack not found for refund: #{pack_name} for charge #{id}"
340
+ snapshot = fulfillment&.metadata&.symbolize_keys || credit_pack_snapshot
341
+ total_purchased_credits = fulfillment&.credits_last_fulfillment || snapshot&.fetch(:total_credits, nil)
342
+ unless snapshot && total_purchased_credits&.positive?
343
+ Rails.logger.error "Original credit pack fulfillment is missing for refund on charge #{id}"
293
344
  return
294
345
  end
295
346
 
@@ -299,51 +350,45 @@ module UsageCredits
299
350
  return
300
351
  end
301
352
 
302
- # Calculate total credits that SHOULD be refunded based on current refund amount
303
- refund_ratio = amount_refunded.to_f / amount.to_f
304
- total_credits_to_refund = (pack.total_credits * refund_ratio).ceil
305
-
306
- # Calculate credits already refunded (for incremental/partial refunds)
307
- already_refunded = credits_previously_refunded
308
-
309
- # Only deduct the INCREMENTAL amount (difference between what should be refunded and what's already refunded)
310
- credits_to_remove = total_credits_to_refund - already_refunded
311
-
312
- # Skip if nothing new to refund
313
- if credits_to_remove <= 0
314
- Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)"
315
- return
316
- end
317
-
318
353
  begin
319
- Rails.logger.info "Processing refund for charge #{id}: #{credits_to_remove} credits (incremental from #{already_refunded} to #{total_credits_to_refund})"
354
+ wallet = credit_wallet
355
+ already_refunded = nil
356
+ total_credits_to_refund = divide_rounding_up(
357
+ total_purchased_credits * amount_refunded.to_i,
358
+ amount.to_i
359
+ )
320
360
 
321
- credit_wallet.deduct_credits(
322
- credits_to_remove,
323
- category: "credit_pack_refund",
324
- metadata: {
361
+ # The cumulative lookup and the clawback are one wallet command: both
362
+ # read the ledger as it is now, under the wallet lock, so concurrent or
363
+ # repeated refund webhooks for this charge apply only the outstanding
364
+ # delta, never the same clawback twice, even when this callback runs
365
+ # inside a transaction that read the ledger first.
366
+ refund_transaction = wallet.deduct_refunded_credits(
367
+ total_credits_to_refund,
368
+ fulfillment: fulfillment,
369
+ metadata: snapshot.merge(
325
370
  refunded_purchase_charge_id: id,
326
371
  credits_refunded: true,
327
372
  refunded_at: Time.current,
328
- refund_percentage: refund_ratio,
373
+ refund_percentage: amount_refunded.to_f / amount.to_f,
329
374
  refund_amount_cents: amount_refunded,
330
- incremental_credits: credits_to_remove,
331
- total_credits_refunded: total_credits_to_refund,
332
- **pack.base_metadata
333
- }
334
- )
375
+ total_credits_refunded: total_credits_to_refund
376
+ )
377
+ ) { |refunds| already_refunded = credits_previously_refunded(refunds) }
335
378
 
336
- Rails.logger.info "Successfully processed refund for charge #{id}"
337
- rescue UsageCredits::InsufficientCredits => e
338
- Rails.logger.error "Insufficient credits for refund on charge #{id}: #{e.message}"
339
- # If negative balance not allowed and user has used credits,
340
- # we'll let the error propagate
341
- raise
342
- rescue StandardError => e
379
+ if refund_transaction
380
+ Rails.logger.info "Processed refund for charge #{id}: #{-refund_transaction.amount} credits (incremental from #{already_refunded} to #{total_credits_to_refund})"
381
+ else
382
+ Rails.logger.info "Refund for charge #{id} already processed (#{already_refunded} credits already refunded)"
383
+ end
384
+ rescue => e
343
385
  Rails.logger.error "Failed to process refund for charge #{id}: #{e.message}"
344
386
  raise
345
387
  end
346
388
  end
347
389
 
390
+ def divide_rounding_up(numerator, denominator)
391
+ numerator.div(denominator) + (numerator.remainder(denominator).zero? ? 0 : 1)
392
+ end
348
393
  end
349
394
  end