Class: UsageCredits::Wallet

Inherits:
ApplicationRecord show all
Defined in:
lib/usage_credits/models/wallet.rb

Overview

A Wallet manages credit balance and transactions for a user/owner.

It's responsible for:

1. Tracking credit balance
2. Performing credit operations (add/deduct)
3. Managing credit expiration
4. Handling low balance alerts

Instance Method Summary collapse

Instance Method Details

#add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil) ⇒ Object

Add credits to the wallet (internal method)



192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/usage_credits/models/wallet.rb', line 192

def add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil)
  with_lock do
    amount = amount.to_i
    raise ArgumentError, "Cannot add non-positive credits" if amount <= 0

    previous_balance = credits  # Capture BEFORE creating transaction

    transaction = transactions.create!(
      amount: amount,
      category: category,
      expires_at: expires_at,
      metadata: ,
      fulfillment: fulfillment
    )

    # Sync the wallet's `balance` column
    self.balance = credits
    save!

    # Store balance information in transaction metadata for audit trail.
    # Note: This update! is in the same DB transaction as the create! above (via with_lock),
    # so if this fails, the entire transaction rolls back - no orphaned records possible.
    # We intentionally overwrite any user-supplied balance_before/balance_after keys
    # to ensure system-set values are authoritative.
    transaction.update!(metadata: transaction..merge(
      balance_before: previous_balance,
      balance_after: balance
    ))

    # Dispatch callback with full context
    UsageCredits::Callbacks.dispatch(:credits_added,
      wallet: self,
      amount: amount,
      category: category,
      transaction: transaction,
      previous_balance: previous_balance,
      new_balance: balance,
      metadata: 
    )

    # To finish, let's return the transaction that has been just created so we can reference it in parts of the code
    # Useful, for example, to update the transaction's `fulfillment` reference in the subscription extension
    # after the credits have been awarded and the Fulfillment object has been created, we need to store it
    return transaction
  end
end

#credit_historyObject

Get transaction history (oldest first)



77
78
79
# File 'lib/usage_credits/models/wallet.rb', line 77

def credit_history
  transactions.order(created_at: :asc)
end

#creditsObject

Get current credit balance

The first naive approach was to compute this as a sum of all non-expired transactions like:

transactions.not_expired.sum(:amount)

but that fails when we mix expiring and non-expiring credits: https://x.com/rameerez/status/1884246492837302759

So we needed to introduce the Allocation model

Now to calculate current balance, instead of summing: we sum only unexpired positive transactions’ remaining_amount



67
68
69
70
71
72
73
74
# File 'lib/usage_credits/models/wallet.rb', line 67

def credits
  # Sum the leftover in all *positive* transactions that haven't expired
  transactions
    .where("amount > 0")
    .where("expires_at IS NULL OR expires_at > ?", Time.current)
    .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM usage_credits_allocations WHERE source_transaction_id = usage_credits_transactions.id)")
    .yield_self { |sum| [sum, 0].max }.to_i
end

#deduct_credits(amount, metadata: {}, category: :credit_deducted) ⇒ Object

Remove credits from the wallet (Internal method)

After implementing the expiring FIFO inventory-like system through the Allocation model, we no longer just create one -X transaction. Now we also allocate that spend across whichever positive transactions still have leftover.

TODO: This code enumerates all unexpired positive transactions each time. That's fine if usage scale is moderate. We're already indexing this. If performance becomes a concern, we need to create a separate model to store the partial allocations efficiently.



248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/usage_credits/models/wallet.rb', line 248

def deduct_credits(amount, metadata: {}, category: :credit_deducted)
  with_lock do
  amount = amount.to_i
  raise InsufficientCredits, "Cannot deduct a non-positive amount" if amount <= 0

  # Capture previous balance for low_balance check
  previous_balance = credits

  # Figure out how many credits are available right now
  available = previous_balance
  if amount > available && !allow_negative_balance?
    raise InsufficientCredits, "Insufficient credits (#{available} < #{amount})"
  end

  # Create the negative transaction that represents the spend
  spend_tx = transactions.create!(
    amount: -amount,
    category: category,
    metadata: 
  ) # We'll attach allocations to it next.

  # We now allocate from oldest/soonest-expiring positive transactions
  remaining_to_deduct = amount

  # 1) Gather all unexpired positives with leftover, order by expire time (soonest first),
  #    then fallback to any with no expiry (which should come last).
  positive_txs = transactions
                  .where("amount > 0")
                  .where("expires_at IS NULL OR expires_at > ?", Time.current)
                  .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
                  .lock("FOR UPDATE")
                  .select(:id, :amount, :expires_at)
                  .to_a

  positive_txs.each do |pt|
    # Calculate leftover amount for this transaction
    allocated = pt.incoming_allocations.sum(:amount)
    leftover = pt.amount - allocated
    next if leftover <= 0

    allocate_amount = [leftover, remaining_to_deduct].min

    # Create allocation
    Allocation.create!(
      spend_transaction: spend_tx,
      source_transaction: pt,
      amount: allocate_amount
    )

    remaining_to_deduct -= allocate_amount
    break if remaining_to_deduct <= 0
  end

  # If anything’s still left to deduct (and we allow negative?), we just leave it unallocated
  # TODO: implement this edge case; typically we'd create an unbacked negative record.
  if remaining_to_deduct.positive? && allow_negative_balance?
    # The spend_tx already has -amount, so effectively user goes negative
    # with no “source bucket” to allocate from. That is an edge case the end user's business logic must handle.
  elsif remaining_to_deduct.positive?
    # We shouldn’t get here if InsufficientCredits is raised earlier, but just in case:
    raise InsufficientCredits, "Not enough credit buckets to cover the deduction"
  end

  # Keep the `balance` column in sync
  self.balance = credits
  save!

  # Store balance information in transaction metadata for audit trail.
  # Note: This update! is in the same DB transaction as the create! above (via with_lock),
  # so if this fails, the entire transaction rolls back - no orphaned records possible.
  # We intentionally overwrite any user-supplied balance_before/balance_after keys
  # to ensure system-set values are authoritative.
  spend_tx.update!(metadata: spend_tx..merge(
    balance_before: previous_balance,
    balance_after: balance
  ))

  # Dispatch credits_deducted callback
  UsageCredits::Callbacks.dispatch(:credits_deducted,
    wallet: self,
    amount: amount,
    category: category,
    transaction: spend_tx,
    previous_balance: previous_balance,
    new_balance: balance,
    metadata: 
  )

  # Check for low balance threshold crossing
  if !was_low_balance?(previous_balance) && low_balance?
    UsageCredits::Callbacks.dispatch(:low_balance_reached,
      wallet: self,
      threshold: UsageCredits.configuration.low_balance_threshold,
      previous_balance: previous_balance,
      new_balance: balance
    )
  end

  # Check for balance depletion (balance reaches exactly zero)
  if previous_balance > 0 && balance == 0
    UsageCredits::Callbacks.dispatch(:balance_depleted,
      wallet: self,
      previous_balance: previous_balance,
      new_balance: 0
    )
  end

  spend_tx
  end
end

#estimate_credits_to(operation_name, **params) ⇒ Object

Calculate how many credits an operation would cost



98
99
100
101
102
103
104
105
106
107
# File 'lib/usage_credits/models/wallet.rb', line 98

def estimate_credits_to(operation_name, **params)
  operation = find_and_validate_operation(operation_name, params)

  # Then calculate the cost
  operation.calculate_cost(params)
rescue InvalidOperation => e
  raise e
rescue StandardError => e
  raise InvalidOperation, "Error estimating cost: #{e.message}"
end

#give_credits(amount, reason: nil, expires_at: nil) ⇒ Object

Give credits to the wallet with optional reason and expiration date

Parameters:

  • amount (Integer)

    Number of credits to give

  • reason (String, nil) (defaults to: nil)

    Optional reason for giving credits (for auditing / trail purposes)

  • expires_at (DateTime, nil) (defaults to: nil)

    Optional expiration date for the credits

Raises:

  • (ArgumentError)


165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/usage_credits/models/wallet.rb', line 165

def give_credits(amount, reason: nil, expires_at: nil)
  raise ArgumentError, "Amount is required" if amount.nil?
  raise ArgumentError, "Cannot give negative credits" if amount.to_i.negative?
  raise ArgumentError, "Credit amount must be a whole number" unless amount == amount.to_i
  raise ArgumentError, "Expiration date must be a valid datetime" if expires_at && !expires_at.respond_to?(:to_datetime)
  raise ArgumentError, "Expiration date must be in the future" if expires_at && expires_at <= Time.current

  category = case reason&.to_s
            when "signup" then :signup_bonus
            when "referral" then :referral_bonus
            when /bonus/i then :bonus
            else :manual_adjustment
            end

  add_credits(
    amount.to_i,
    metadata: { reason: reason },
    category: category,
    expires_at: expires_at
  )
end

#has_enough_credits_to?(operation_name, **params) ⇒ Boolean

Check if wallet has enough credits for an operation

Returns:

  • (Boolean)


86
87
88
89
90
91
92
93
94
95
# File 'lib/usage_credits/models/wallet.rb', line 86

def has_enough_credits_to?(operation_name, **params)
  operation = find_and_validate_operation(operation_name, params)

  # Then check if we actually have enough credits
  credits >= operation.calculate_cost(params)
rescue InvalidOperation => e
  raise e
rescue StandardError => e
  raise InvalidOperation, "Error checking credits: #{e.message}"
end

#metadataObject

Get metadata with indifferent access (string/symbol keys) Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults)



37
38
39
# File 'lib/usage_credits/models/wallet.rb', line 37

def 
   ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
end

#metadata=(hash) ⇒ Object

Set metadata, ensuring consistent storage format



42
43
44
45
# File 'lib/usage_credits/models/wallet.rb', line 42

def metadata=(hash)
   = nil  # Clear cache
  super(hash.is_a?(Hash) ? hash.to_h : {})
end

#reloadObject

Clear metadata cache on reload to ensure fresh data from database



48
49
50
51
# File 'lib/usage_credits/models/wallet.rb', line 48

def reload(*)
   = nil
  super
end

#spend_credits_on(operation_name, **params) { ... } ⇒ Object

Spend credits on an operation

Parameters:

  • operation_name (Symbol)

    The operation to perform

  • params (Hash)

    Parameters for the operation

Yields:

  • Optional block that must succeed before credits are deducted



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/usage_credits/models/wallet.rb', line 113

def spend_credits_on(operation_name, **params)
  operation = find_and_validate_operation(operation_name, params)

  cost = operation.calculate_cost(params)

  # Check if user has enough credits
  unless has_enough_credits_to?(operation_name, **params)
    # Fire insufficient_credits callback before raising
    UsageCredits::Callbacks.dispatch(:insufficient_credits,
      wallet: self,
      amount: cost,
      operation_name: operation_name,
      metadata: {
        available: credits,
        required: cost,
        params: params
      }
    )
    raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})"
  end

  # Create audit trail
  # Stringify keys from audit_data to avoid duplicate key warnings in JSON
  audit_data = operation.to_audit_hash(params).deep_stringify_keys
  deduct_params = {
    metadata: audit_data.merge(operation..deep_stringify_keys).merge(
      "executed_at" => Time.current,
      "gem_version" => UsageCredits::VERSION
    ),
    category: :operation_charge
  }

  if block_given?
    # If block given, only deduct credits if it succeeds
    ActiveRecord::Base.transaction do
      lock!  # Row-level lock for concurrency safety

      yield  # Perform the operation first

      deduct_credits(cost, **deduct_params)  # Deduct credits only if the block was successful
    end
  else
    deduct_credits(cost, **deduct_params)
  end
rescue StandardError => e
  raise e
end