usage_credits 0.5.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 +68 -0
  3. data/README.md +120 -29
  4. data/lib/generators/usage_credits/install_generator.rb +9 -1
  5. data/lib/generators/usage_credits/templates/create_usage_credits_tables.rb.erb +64 -20
  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 +25 -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 +51 -158
  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 -37
  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
@@ -3,106 +3,118 @@
3
3
  module UsageCredits
4
4
  # A Wallet manages credit balance and transactions for a user/owner.
5
5
  #
6
- # It's responsible for:
7
- # 1. Tracking credit balance
8
- # 2. Performing credit operations (add/deduct)
9
- # 3. Managing credit expiration
10
- # 4. Handling low balance alerts
11
-
12
- class Wallet < ApplicationRecord
13
- self.table_name = "usage_credits_wallets"
6
+ # This class extends Wallets::Wallet with usage_credits-specific features:
7
+ # - Operation-based spending (spend_credits_on)
8
+ # - Human-friendly API (give_credits, credits, credit_history)
9
+ # - Fulfillment tracking for subscriptions and credit packs
10
+ # - Usage-credits specific callbacks (credits_added, credits_deducted, etc.)
14
11
 
12
+ class Wallet < Wallets::WalletBase
15
13
  # =========================================
16
- # Associations & Validations
14
+ # Embeddability Configuration
17
15
  # =========================================
18
16
 
19
- belongs_to :owner, polymorphic: true
20
- has_many :transactions, class_name: "UsageCredits::Transaction", dependent: :destroy
21
- has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy
22
- has_many :outbound_allocations, through: :transactions, source: :outgoing_allocations
23
- has_many :inbound_allocations, through: :transactions, source: :incoming_allocations
24
- has_many :allocations, ->(wallet) { unscope(:where).where("usage_credits_allocations.transaction_id IN (?) OR usage_credits_allocations.source_transaction_id IN (?)", wallet.transaction_ids, wallet.transaction_ids) }, class_name: "UsageCredits::Allocation", dependent: :destroy
25
-
26
- validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
17
+ self.embedded_table_name = "usage_credits_wallets"
18
+ self.config_provider = -> { UsageCredits.configuration }
19
+ self.callbacks_module = UsageCredits::Callbacks
20
+ self.transaction_class_name = "UsageCredits::Transaction"
21
+ self.allocation_class_name = "UsageCredits::Allocation"
22
+ self.transfer_class_name = "UsageCredits::Transfer"
23
+ self.additional_transaction_attribute_names = %i[fulfillment].freeze
24
+
25
+ # Map base wallet events to usage_credits-specific event names
26
+ self.callback_event_map = {
27
+ credited: :credits_added,
28
+ debited: :credits_deducted,
29
+ insufficient: :insufficient_credits,
30
+ low_balance: :low_balance_reached,
31
+ depleted: :balance_depleted,
32
+ transfer_completed: nil
33
+ }.freeze
27
34
 
28
35
  # =========================================
29
- # Metadata Handling
36
+ # Re-declare Associations with Correct Classes
30
37
  # =========================================
31
38
 
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
39
+ # Override parent associations to use UsageCredits classes
40
+ has_many :transactions,
41
+ class_name: "UsageCredits::Transaction",
42
+ inverse_of: :wallet,
43
+ dependent: :destroy
44
+ has_many :outgoing_transfers,
45
+ class_name: "UsageCredits::Transfer",
46
+ foreign_key: :from_wallet_id,
47
+ dependent: :destroy,
48
+ inverse_of: :from_wallet
49
+ has_many :incoming_transfers,
50
+ class_name: "UsageCredits::Transfer",
51
+ foreign_key: :to_wallet_id,
52
+ dependent: :destroy,
53
+ inverse_of: :to_wallet
54
+
55
+ # UsageCredits-specific associations
56
+ has_many :fulfillments, class_name: "UsageCredits::Fulfillment", dependent: :destroy
40
57
 
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
58
+ class << self
59
+ private
46
60
 
47
- # Clear metadata cache on reload to ensure fresh data from database
48
- def reload(*)
49
- @indifferent_metadata = nil
50
- super
61
+ def initial_balance_credit_attributes
62
+ {
63
+ category: :manual_adjustment,
64
+ metadata: {reason: "initial_balance"}
65
+ }
66
+ end
51
67
  end
52
68
 
53
69
  # =========================================
54
- # Credit Balance & History
70
+ # Backwards Compatibility API
55
71
  # =========================================
56
72
 
57
- # Get current credit balance
58
- #
59
- # The first naive approach was to compute this as a sum of all non-expired transactions like:
60
- # transactions.not_expired.sum(:amount)
61
- # but that fails when we mix expiring and non-expiring credits: https://x.com/rameerez/status/1884246492837302759
62
- #
63
- # So we needed to introduce the Allocation model
73
+ # Get current credit balance (alias for balance)
64
74
  #
65
- # Now to calculate current balance, instead of summing:
66
- # we sum only unexpired positive transactions’ remaining_amount
75
+ # usage_credits historically floors negative balances to zero even when
76
+ # allow_negative_balance is enabled. Keep that contract for backwards
77
+ # compatibility, even though the shared wallets core can represent unbacked
78
+ # negative debits explicitly.
67
79
  def credits
68
- # Sum the leftover in all *positive* transactions that haven't expired
69
- transactions
70
- .where("amount > 0")
71
- .where("expires_at IS NULL OR expires_at > ?", Time.current)
72
- .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM usage_credits_allocations WHERE source_transaction_id = usage_credits_transactions.id)")
73
- .yield_self { |sum| [sum, 0].max }.to_i
80
+ balance
74
81
  end
75
82
 
76
- # Get transaction history (oldest first)
83
+ def current_balance
84
+ # Wallets::Wallet#balance delegates dynamically to current_balance, so
85
+ # this override is the active public balance implementation (not a dead
86
+ # helper). Refund debits remain explicit ledger debt while the Usage
87
+ # Credits compatibility view stays floored at zero.
88
+ refund_debits = transactions.debits.where(category: "credit_pack_refund")
89
+ [positive_remaining_balance - unbacked_negative_balance(refund_debits), 0].max
90
+ end
91
+
92
+ # Get transaction history (oldest first) - alias for history
77
93
  def credit_history
78
- transactions.order(created_at: :asc)
94
+ history
79
95
  end
80
96
 
81
97
  # =========================================
82
- # Credit Operations
98
+ # Credit Operations (High-Level API)
83
99
  # =========================================
84
100
 
85
101
  # Check if wallet has enough credits for an operation
86
102
  def has_enough_credits_to?(operation_name, **params)
87
- operation = find_and_validate_operation(operation_name, params)
88
-
89
- # Then check if we actually have enough credits
103
+ operation = find_operation(operation_name)
90
104
  credits >= operation.calculate_cost(params)
91
- rescue InvalidOperation => e
92
- raise e
93
- rescue StandardError => e
105
+ rescue InvalidOperation
106
+ raise
107
+ rescue => e
94
108
  raise InvalidOperation, "Error checking credits: #{e.message}"
95
109
  end
96
110
 
97
111
  # Calculate how many credits an operation would cost
98
112
  def estimate_credits_to(operation_name, **params)
99
- operation = find_and_validate_operation(operation_name, params)
100
-
101
- # Then calculate the cost
113
+ operation = find_operation(operation_name)
102
114
  operation.calculate_cost(params)
103
- rescue InvalidOperation => e
104
- raise e
105
- rescue StandardError => e
115
+ rescue InvalidOperation
116
+ raise
117
+ rescue => e
106
118
  raise InvalidOperation, "Error estimating cost: #{e.message}"
107
119
  end
108
120
 
@@ -111,74 +123,65 @@ module UsageCredits
111
123
  # @param params [Hash] Parameters for the operation
112
124
  # @yield Optional block that must succeed before credits are deducted
113
125
  def spend_credits_on(operation_name, **params)
114
- operation = find_and_validate_operation(operation_name, params)
115
-
126
+ operation = find_operation(operation_name)
116
127
  cost = operation.calculate_cost(params)
117
128
 
118
- # Check if user has enough credits
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
133
-
134
129
  # Create audit trail
135
- # Stringify keys from audit_data to avoid duplicate key warnings in JSON
136
- audit_data = operation.to_audit_hash(params).deep_stringify_keys
130
+ audit_data = operation.to_audit_hash(params, cost: cost).deep_stringify_keys
137
131
  deduct_params = {
138
- metadata: audit_data.merge(operation.metadata.deep_stringify_keys).merge(
139
- "executed_at" => Time.current,
140
- "gem_version" => UsageCredits::VERSION
141
- ),
132
+ metadata: audit_data,
142
133
  category: :operation_charge
143
134
  }
144
135
 
145
- if block_given?
146
- # If block given, only deduct credits if it succeeds
147
- ActiveRecord::Base.transaction do
148
- lock! # Row-level lock for concurrency safety
149
-
150
- yield # Perform the operation first
151
-
152
- deduct_credits(cost, **deduct_params) # Deduct credits only if the block was successful
136
+ # One command, like every core command: the affordability check, the
137
+ # protected operation and the debit share the wallet lock, one instant
138
+ # and the core's verdict on this transaction's snapshot. Otherwise a
139
+ # caller that read before spending could check a stale MySQL balance:
140
+ # run the block on credits another request already took, or refuse
141
+ # credits another request just added. Its own savepoint makes it whole
142
+ # either way: a refused debit rolls back the block's writes too, even
143
+ # when the caller rescues and commits.
144
+ with_lock(requires_new: true) do
145
+ mutating_at(Time.current) do
146
+ available = credits
147
+ if cost > available
148
+ UsageCredits::Callbacks.dispatch(:insufficient_credits,
149
+ wallet: self,
150
+ amount: cost,
151
+ operation_name: operation_name,
152
+ metadata: {
153
+ available: available,
154
+ required: cost,
155
+ params: params
156
+ })
157
+ raise InsufficientCredits, "Insufficient credits (#{available} < #{cost})"
158
+ end
159
+
160
+ yield if block_given?
161
+
162
+ # Free operations are a supported part of the DSL. There is no valid
163
+ # zero-amount ledger transaction to record, so execute the block and
164
+ # return nil without calling the strictly-positive debit primitive.
165
+ cost.zero? ? nil : deduct_credits(cost, **deduct_params)
153
166
  end
154
- else
155
- deduct_credits(cost, **deduct_params)
156
167
  end
157
- rescue StandardError => e
158
- raise e
159
168
  end
160
169
 
161
170
  # Give credits to the wallet with optional reason and expiration date
162
171
  # @param amount [Integer] Number of credits to give
163
- # @param reason [String, nil] Optional reason for giving credits (for auditing / trail purposes)
172
+ # @param reason [String, nil] Optional reason for giving credits
164
173
  # @param expires_at [DateTime, nil] Optional expiration date for the credits
165
174
  def give_credits(amount, reason: nil, expires_at: nil)
166
- raise ArgumentError, "Amount is required" if amount.nil?
167
- raise ArgumentError, "Cannot give negative credits" if amount.to_i.negative?
168
- raise ArgumentError, "Credit amount must be a whole number" unless amount == amount.to_i
169
- raise ArgumentError, "Expiration date must be a valid datetime" if expires_at && !expires_at.respond_to?(:to_datetime)
170
- raise ArgumentError, "Expiration date must be in the future" if expires_at && expires_at <= Time.current
171
-
172
175
  category = case reason&.to_s
173
- when "signup" then :signup_bonus
174
- when "referral" then :referral_bonus
175
- when /bonus/i then :bonus
176
- else :manual_adjustment
177
- end
176
+ when "signup" then :signup_bonus
177
+ when "referral" then :referral_bonus
178
+ when /bonus/i then :bonus
179
+ else :manual_adjustment
180
+ end
178
181
 
179
182
  add_credits(
180
- amount.to_i,
181
- metadata: { reason: reason },
183
+ amount,
184
+ metadata: {reason: reason},
182
185
  category: category,
183
186
  expires_at: expires_at
184
187
  )
@@ -188,227 +191,129 @@ module UsageCredits
188
191
  # Credit Management (Internal API)
189
192
  # =========================================
190
193
 
191
- # Add credits to the wallet (internal method)
194
+ # Add credits to the wallet (wraps parent's credit method)
195
+ # Maintains backwards compatibility with fulfillment parameter
192
196
  def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil)
193
- with_lock do
194
- amount = amount.to_i
195
- raise ArgumentError, "Cannot add non-positive credits" if amount <= 0
196
-
197
- previous_balance = credits # Capture BEFORE creating transaction
198
-
199
- transaction = transactions.create!(
200
- amount: amount,
201
- category: category,
202
- expires_at: expires_at,
203
- metadata: metadata,
204
- fulfillment: fulfillment
205
- )
206
-
207
- # Sync the wallet's `balance` column
208
- self.balance = credits
209
- save!
210
-
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
- )
231
-
232
- # To finish, let's return the transaction that has been just created so we can reference it in parts of the code
233
- # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
234
- # after the credits have been awarded and the Fulfillment object has been created, we need to store it
235
- return transaction
236
- end
197
+ credit(
198
+ amount,
199
+ metadata: metadata,
200
+ category: category,
201
+ expires_at: expires_at,
202
+ fulfillment: fulfillment
203
+ )
237
204
  end
238
205
 
239
- # Remove credits from the wallet (Internal method)
240
- #
241
- # After implementing the expiring FIFO inventory-like system through the Allocation model,
242
- # we no longer just create one -X transaction. Now we also allocate that spend across whichever
243
- # positive transactions still have leftover.
244
- #
245
- # TODO: This code enumerates all unexpired positive transactions each time.
246
- # That's fine if usage scale is moderate. We're already indexing this.
247
- # If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently.
248
- def deduct_credits(amount, metadata: {}, category: :credit_deducted)
249
- with_lock do
250
- amount = amount.to_i
251
- raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0
252
-
253
- # Capture previous balance for low_balance check
254
- previous_balance = credits
255
-
256
- # Figure out how many credits are available right now
257
- available = previous_balance
258
- if amount > available && !allow_negative_balance?
259
- raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})"
260
- end
206
+ # Remove credits from the wallet (wraps parent's debit method)
207
+ # Converts Wallets::InsufficientBalance to InsufficientCredits for backwards compatibility
208
+ def deduct_credits(amount, metadata: {}, category: :credit_deducted, fulfillment: nil)
209
+ debit(amount, metadata: metadata, category: category, fulfillment: fulfillment)
210
+ rescue Wallets::InsufficientBalance => e
211
+ raise InsufficientCredits, e.message
212
+ end
261
213
 
262
- # Create the negative transaction that represents the spend
263
- spend_tx = transactions.create!(
264
- amount: -amount,
265
- category: category,
266
- metadata: metadata
267
- ) # We'll attach allocations to it next.
268
-
269
- # We now allocate from oldest/soonest-expiring positive transactions
270
- remaining_to_deduct = amount
271
-
272
- # 1) Gather all unexpired positives with leftover, order by expire time (soonest first),
273
- # then fallback to any with no expiry (which should come last).
274
- positive_txs = transactions
275
- .where("amount > 0")
276
- .where("expires_at IS NULL OR expires_at > ?", Time.current)
277
- .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
278
- .lock("FOR UPDATE")
279
- .select(:id, :amount, :expires_at)
280
- .to_a
281
-
282
- positive_txs.each do |pt|
283
- # Calculate leftover amount for this transaction
284
- allocated = pt.incoming_allocations.sum(:amount)
285
- leftover = pt.amount - allocated
286
- next if leftover <= 0
287
-
288
- allocate_amount = [leftover, remaining_to_deduct].min
289
-
290
- # Create allocation
291
- Allocation.create!(
292
- spend_transaction: spend_tx,
293
- source_transaction: pt,
294
- amount: allocate_amount
295
- )
296
-
297
- remaining_to_deduct -= allocate_amount
298
- break if remaining_to_deduct <= 0
214
+ # Shorten the lifetime of credits minted by one fulfillment and reconcile
215
+ # the wallet through the same balance/callback internals as core mutations.
216
+ # This keeps Pay lifecycle code out of Wallets' private implementation and
217
+ # makes immediate cancellation expiry observable through low/depleted
218
+ # callbacks after the surrounding transaction commits.
219
+ def expire_fulfillment_credits!(fulfillment:, expires_at:)
220
+ unless expires_at.respond_to?(:to_datetime)
221
+ raise ArgumentError, "Expiration date must respond to to_datetime"
299
222
  end
300
223
 
301
- # If anything’s still left to deduct (and we allow negative?), we just leave it unallocated
302
- # TODO: implement this edge case; typically we'd create an unbacked negative record.
303
- if remaining_to_deduct.positive? && allow_negative_balance?
304
- # The spend_tx already has -amount, so effectively user goes negative
305
- # with no “source bucket” to allocate from. That is an edge case the end user's business logic must handle.
306
- elsif remaining_to_deduct.positive?
307
- # We shouldn’t get here if InsufficientCredits is raised earlier, but just in case:
308
- raise InsufficientCredits, "Not enough credit buckets to cover the deduction"
224
+ expiration = begin
225
+ expires_at.to_datetime
226
+ rescue
227
+ raise ArgumentError, "Expiration date must be a valid date or time"
309
228
  end
310
229
 
311
- # Keep the `balance` column in sync
312
- self.balance = credits
313
- save!
314
-
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
- )
230
+ # A command like any other: its own savepoint, and one instant and one
231
+ # snapshot verdict for every read. Outside the core's mutation context,
232
+ # refreshing the cached balance on a stale MySQL snapshot would cache a
233
+ # stale balance and bump `ledger_revision` unnoticed; the next command in
234
+ # the same transaction would then see its own revision and trust the
235
+ # stale snapshot.
236
+ with_lock(requires_new: true) do
237
+ mutating_at(Time.current) do
238
+ previous_balance = balance
239
+ transactions_to_expire = transactions.credits.where(fulfillment: fulfillment)
240
+ expiry = transactions_to_expire.klass.arel_table[:expires_at]
241
+ updated_count = transactions_to_expire
242
+ .where(expiry.eq(nil).or(expiry.gt(expiration)))
243
+ .update_all(expires_at: expiration, updated_at: evaluation_time)
244
+
245
+ if updated_count.positive?
246
+ new_balance = refresh_cached_balance!
247
+ dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
248
+ end
249
+
250
+ updated_count
251
+ end
344
252
  end
253
+ end
345
254
 
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
255
+ # Keep the inherited wallet primitive inside usage_credits' public error
256
+ # hierarchy. Both transfer entry points share this implementation and the
257
+ # complete wallets transfer surface, including expiration overrides.
258
+ def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil)
259
+ super
260
+ rescue Wallets::InvalidTransfer => e
261
+ raise InvalidTransfer, e.message
262
+ rescue Wallets::InsufficientBalance => e
263
+ raise InsufficientCredits, e.message
264
+ rescue Wallets::Error => e
265
+ raise UsageCredits::Error, e.message
266
+ end
354
267
 
355
- spend_tx
268
+ alias_method :transfer_credits_to, :transfer_to
269
+
270
+ # The Pay charge extension's refund webhook handler calls this; it is not a
271
+ # general-purpose API. Claws back a refund: the part of `total_credits`, what the refunds of
272
+ # one purchase now add up to, that the ledger has not taken yet. The
273
+ # block receives this wallet's refund debits and returns what earlier
274
+ # refunds of the same purchase already took (nothing, without a block).
275
+ # The lookup and the debit are one command, under the wallet lock, at one
276
+ # instant and with one verdict on this transaction's snapshot, so the
277
+ # refund debits are read as they are now. Outside the command the lookup
278
+ # could miss a refund another connection committed after this
279
+ # transaction's first read (Pay's commit callbacks run inside a
280
+ # `transaction(joinable: false)`), and under MySQL's repeatable read the
281
+ # same credits would be clawed back twice. The debit may be unbacked: a
282
+ # refund is owed even after the purchased credits were spent, as ledger
283
+ # debt that later credits repay while `credits` stays floored at zero.
284
+ # Returns the refund transaction, or nil when nothing is outstanding.
285
+ def deduct_refunded_credits(total_credits, metadata:, fulfillment:)
286
+ with_lock(requires_new: true) do
287
+ mutating_at(Time.current) do
288
+ refunds = current_read(transactions.debits.where(category: "credit_pack_refund"), current_reads?)
289
+ already_refunded = block_given? ? yield(refunds) : 0
290
+ outstanding = total_credits - already_refunded
291
+ next if outstanding <= 0
292
+
293
+ apply_debit(
294
+ outstanding,
295
+ metadata: metadata.merge(incremental_credits: outstanding),
296
+ category: :credit_pack_refund,
297
+ transfer: nil,
298
+ extra_attributes: {fulfillment: fulfillment},
299
+ allow_unbacked: true
300
+ )
301
+ end
356
302
  end
357
303
  end
358
304
 
359
-
360
305
  private
361
306
 
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
-
373
307
  # =========================================
374
308
  # Helper Methods
375
309
  # =========================================
376
310
 
377
- # Find an operation and validate its parameters
378
- # @param name [Symbol] Operation name
379
- # @param params [Hash] Operation parameters to validate
380
- # @return [Operation] The validated operation
381
- # @raise [InvalidOperation] If operation not found or validation fails
382
- def find_and_validate_operation(name, params)
311
+ # Find an operation. `Operation#calculate_cost` owns parameter validation,
312
+ # keeping validation and user-supplied cost code single-evaluation.
313
+ def find_operation(name)
383
314
  operation = UsageCredits.operations[name.to_sym]
384
315
  raise InvalidOperation, "Operation not found: #{name}" unless operation
385
- operation.validate!(params)
386
316
  operation
387
317
  end
388
-
389
- def insufficient_credits?(amount)
390
- !allow_negative_balance? && amount > credits
391
- end
392
-
393
- def allow_negative_balance?
394
- UsageCredits.configuration.allow_negative_balance
395
- end
396
-
397
- # =========================================
398
- # Balance Threshold Helpers
399
- # =========================================
400
-
401
- def low_balance?
402
- threshold = UsageCredits.configuration.low_balance_threshold
403
- return false if threshold.nil? || threshold.negative?
404
- credits <= threshold
405
- end
406
-
407
- def was_low_balance?(previous_balance)
408
- threshold = UsageCredits.configuration.low_balance_threshold
409
- return false if threshold.nil? || threshold.negative?
410
- previous_balance <= threshold
411
- end
412
318
  end
413
-
414
319
  end
@@ -1,17 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module UsageCredits
4
- # Railtie for Rails integration
5
- class Railtie < Rails::Railtie
6
- railtie_name :usage_credits
7
-
8
- # Set up action view helpers if needed
9
- initializer "usage_credits.action_view" do
10
- ActiveSupport.on_load :action_view do
11
- require "usage_credits/helpers/credits_helper"
12
- include UsageCredits::CreditsHelper
13
- end
14
- end
3
+ require "usage_credits" unless defined?(UsageCredits::Engine)
15
4
 
16
- end
5
+ module UsageCredits
6
+ # Compatibility constant for applications that require this historical path.
7
+ Railtie = Engine unless const_defined?(:Railtie, false)
17
8
  end