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)



153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/usage_credits/models/wallet.rb', line 153

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

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

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

    notify_balance_change(:credits_added, amount)
    check_low_balance if !was_low_balance?(previous_balance) && low_balance?

    # 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)



52
53
54
# File 'lib/usage_credits/models/wallet.rb', line 52

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: 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



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

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.



191
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
# File 'lib/usage_credits/models/wallet.rb', line 191

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

    # Figure out how many credits are available right now
    available = credits
    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!

    # Fire your existing notifications
    notify_balance_change(:credits_deducted, amount)
    spend_tx
  end
end

#estimate_credits_to(operation_name, **params) ⇒ Object

Calculate how many credits an operation would cost



73
74
75
76
77
78
79
80
81
82
# File 'lib/usage_credits/models/wallet.rb', line 73

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)


126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# File 'lib/usage_credits/models/wallet.rb', line 126

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.to_i.integer?
  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)


61
62
63
64
65
66
67
68
69
70
# File 'lib/usage_credits/models/wallet.rb', line 61

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

#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



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/usage_credits/models/wallet.rb', line 88

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
  raise InsufficientCredits, "Insufficient credits (#{credits} < #{cost})" unless has_enough_credits_to?(operation_name, **params)

  # Create audit trail
  audit_data = operation.to_audit_hash(params)
  deduct_params = {
    metadata: audit_data.merge(operation.).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