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.
-
#metadata ⇒ Object
Get metadata with indifferent access (string/symbol keys) Returns empty hash if nil (for MySQL compatibility where JSON columns can't have defaults).
-
#metadata=(hash) ⇒ Object
Set metadata, ensuring consistent storage format.
-
#reload ⇒ Object
Clear metadata cache on reload to ensure fresh data from database.
-
#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)
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_history ⇒ Object
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 |
#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: 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
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
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 |
#metadata ⇒ Object
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 (hash) = nil # Clear cache super(hash.is_a?(Hash) ? hash.to_h : {}) end |
#reload ⇒ Object
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
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 |