Class: UsageCredits::Wallet
- Inherits:
-
ApplicationRecord
- Object
- ActiveRecord::Base
- ApplicationRecord
- UsageCredits::Wallet
- 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
-
#add_credits(amount, metadata: {}, category: :credit_added, expires_at: nil, fulfillment: nil) ⇒ Object
Add credits to the wallet (internal method).
-
#credit_history ⇒ Object
Get transaction history (oldest first).
-
#credits ⇒ Object
Get current credit balance.
-
#deduct_credits(amount, metadata: {}, category: :credit_deducted) ⇒ Object
Remove credits from the wallet (Internal method).
-
#estimate_credits_to(operation_name, **params) ⇒ Object
Calculate how many credits an operation would cost.
-
#give_credits(amount, reason: nil, expires_at: nil) ⇒ Object
Give credits to the wallet with optional reason and expiration date.
-
#has_enough_credits_to?(operation_name, **params) ⇒ Boolean
Check if wallet has enough credits for an operation.
-
#spend_credits_on(operation_name, **params) { ... } ⇒ Object
Spend credits on an operation.
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_history ⇒ Object
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 |
#credits ⇒ Object
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
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
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
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 |