wallets 0.1.0 → 0.4.1

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.
@@ -5,45 +5,100 @@ module Wallets
5
5
  #
6
6
  # Balances are not maintained by incrementing a counter in place. Instead, the
7
7
  # wallet derives its current balance from transactions and allocations so it
8
- # can support FIFO consumption, expirations, transfers, and a durable audit
8
+ # can support expiration-aware consumption, transfers, and a durable audit
9
9
  # trail at the same time.
10
10
  #
11
+ # `balance` is the AVAILABLE balance: what can be spent right now. Holds keep
12
+ # value in the ledger but out of it, exactly the way expiry already does:
13
+ #
14
+ # balance (available) = P - C - R - N
15
+ # held_balance = C + R
16
+ # total_balance = P - N
17
+ #
18
+ # P is the remaining value of unexpired credits, C the part of P an active
19
+ # hold covers, R the active reservations, and N the debt no credit backs.
20
+ # Without holds, C and R are zero and nothing changes.
21
+ #
11
22
  # This class supports embedding: subclasses can override config, callbacks,
12
23
  # table names, and related model classes without affecting the base Wallets::*
13
24
  # behavior in the same application.
14
- class Wallet < ApplicationRecord
25
+ class WalletBase < ApplicationRecord
26
+ # Abstract on purpose: embedders (like usage_credits) subclass THIS
27
+ # class, not the concrete Wallet below. ActiveRecord builds a
28
+ # subclass's attribute methods on its parent's, so a concrete parent
29
+ # forces a schema load of the wallets_* tables — which do not exist in
30
+ # apps that only run an embedded ledger (fresh usage_credits installs).
31
+ # An abstract parent makes each embedded subclass its own base_class
32
+ # and keeps the base tables out of the picture entirely.
33
+ self.abstract_class = true
34
+ include Wallets::Embeddable
35
+ include Wallets::HasMetadata
36
+
37
+ CORE_TRANSACTION_ATTRIBUTE_NAMES = %i[
38
+ id wallet wallet_id amount category expires_at metadata transfer
39
+ transfer_id balance_before balance_after created_at updated_at
40
+ ].freeze
41
+
42
+ # Plain SELECTs on these adapters may answer from a stale snapshot inside
43
+ # a transaction (see `current_reads_need_locks?`).
44
+ SNAPSHOT_READ_ADAPTERS = /mysql|trilogy/i
45
+ private_constant :SNAPSHOT_READ_ADAPTERS
46
+
47
+ # The smallest step the wallet row's `updated_at` column can record (the
48
+ # timestamp marker of wallet tables without `ledger_revision`).
49
+ MARKER_STEP = Rational(1, 1_000_000)
50
+ private_constant :MARKER_STEP
51
+
52
+ # What `hold` holds when no credit is passed at all. A sentinel rather
53
+ # than nil, so a nil from a lookup that found nothing is an error instead
54
+ # of a freeze on the whole wallet.
55
+ WHOLE_WALLET = Object.new.freeze
56
+ private_constant :WHOLE_WALLET
57
+
58
+ # What `debit` and `transfer_to` spend when `from:` is left out: any
59
+ # eligible credit, soonest-expiring first. A sentinel for the same reason
60
+ # as WHOLE_WALLET: a nil from a lookup that found nothing must be an
61
+ # error, never a debit that quietly spends some other credit.
62
+ ELIGIBLE_CREDITS = Object.new.freeze
63
+ private_constant :ELIGIBLE_CREDITS
64
+
65
+ # The keywords `credit(..., hold: {...})` accepts: the ones `hold` takes.
66
+ ARRIVAL_HOLD_OPTIONS = %i[category until metadata].freeze
67
+ private_constant :ARRIVAL_HOLD_OPTIONS
68
+
69
+ self.table_suffix = "wallets"
70
+
15
71
  # =========================================
16
72
  # Embeddability Hooks
17
73
  # =========================================
18
74
 
19
- class_attribute :embedded_table_name, default: nil
20
- class_attribute :config_provider, default: -> { Wallets.configuration }
21
75
  class_attribute :callbacks_module, default: Wallets::Callbacks
22
76
  class_attribute :transaction_class_name, default: "Wallets::Transaction"
23
77
  class_attribute :allocation_class_name, default: "Wallets::Allocation"
24
78
  class_attribute :transfer_class_name, default: "Wallets::Transfer"
79
+ # Holds are opt-in per ledger. nil keeps a ledger exactly as it was before
80
+ # holds existed: no hold SQL anywhere, destroy included. The concrete
81
+ # Wallet below opts in; an embedder opts in deliberately, with its own hold
82
+ # class, table and balance policy. Never inferred from a table's existence:
83
+ # an enabled ledger with a missing table must fail loudly, not quietly
84
+ # make held value spendable.
85
+ class_attribute :hold_class_name, default: nil
86
+ # Embedded ledgers may add their own transaction columns (for example,
87
+ # usage_credits adds `fulfillment_id`). Keeping an explicit allowlist
88
+ # prevents arbitrary keyword attributes from overriding accounting fields
89
+ # such as amount, wallet, category, metadata, or timestamps.
90
+ class_attribute :additional_transaction_attribute_names, default: [].freeze
25
91
  class_attribute :callback_event_map, default: {
26
92
  credited: :balance_credited,
27
93
  debited: :balance_debited,
28
94
  insufficient: :insufficient_balance,
29
95
  low_balance: :low_balance_reached,
30
96
  depleted: :balance_depleted,
31
- transfer_completed: :transfer_completed
97
+ transfer_completed: :transfer_completed,
98
+ hold_created: :hold_created,
99
+ hold_released: :hold_released
32
100
  }.freeze
33
101
 
34
- # =========================================
35
- # Table Name Resolution
36
- # =========================================
37
-
38
- def self.table_name
39
- embedded_table_name || "#{resolved_config.table_prefix}wallets"
40
- end
41
-
42
- def self.resolved_config
43
- value = config_provider
44
- value.respond_to?(:call) ? value.call : value
45
- end
46
-
47
102
  def self.transaction_class
48
103
  transaction_class_name.constantize
49
104
  end
@@ -56,30 +111,102 @@ module Wallets
56
111
  transfer_class_name.constantize
57
112
  end
58
113
 
114
+ def self.hold_class
115
+ raise HoldsNotEnabled, "#{name} does not keep holds (its hold_class_name is nil)" unless holds_enabled?
116
+
117
+ hold_class_name.constantize
118
+ end
119
+
120
+ def self.holds_enabled?
121
+ hold_class_name.present?
122
+ end
123
+
124
+ # Whether this database can answer a plain read from a stale snapshot, so
125
+ # that a mutation may need locking reads to see the ledger as it is now.
126
+ # Under MySQL's (and MariaDB's) default repeatable read, a transaction's
127
+ # consistent snapshot is fixed by its FIRST plain read: a caller that read
128
+ # anything before calling a wallet command keeps that snapshot after the
129
+ # wallet lock is granted, and a plain SUM would miss what the previous
130
+ # lock holder committed. Locking reads always read the latest committed
131
+ # rows. PostgreSQL's default read committed takes a fresh snapshot per
132
+ # statement (and refuses FOR UPDATE beside an aggregate), and SQLite
133
+ # serializes writers, so both already read current rows. Whether a given
134
+ # mutation actually needs locking reads is `stale_snapshot?`.
135
+ #
136
+ # @api private Public only because wallet instances ask it through their
137
+ # class; not part of the supported API, and free to change in any release.
138
+ def self.current_reads_need_locks?
139
+ transaction_class.connection.adapter_name.match?(SNAPSHOT_READ_ADAPTERS)
140
+ end
141
+
142
+ # One definition of "held" for credits, shared by `held_balance`, FEFO
143
+ # eligibility and `Transaction#held?`, so the three can never disagree.
144
+ # SQL that is true for a credit row of this ledger that an active hold
145
+ # covers at `at`: a hold on that very credit, or a hold on its whole
146
+ # wallet. Correlated EXISTS, never a join, so a credit held for two
147
+ # reasons still counts once.
148
+ #
149
+ # @api private Public only so `Transaction#held?` and friends share this
150
+ # one definition; not part of the supported API, and free to change in
151
+ # any release.
152
+ def self.coverage_sql(at:, lock: false)
153
+ holds = hold_class.arel_table
154
+ credits = transaction_class.arel_table
155
+ active_holds = hold_class.active(at: at).select(Arel.sql("1"))
156
+ active_holds = active_holds.lock("FOR UPDATE") if lock && current_reads_need_locks?
157
+
158
+ credit_holds = active_holds.where(holds[:transaction_id].eq(credits[:id]))
159
+ wallet_holds = active_holds.coverage.wallet_scoped.where(holds[:wallet_id].eq(credits[:wallet_id]))
160
+
161
+ "(EXISTS (#{credit_holds.to_sql}) OR EXISTS (#{wallet_holds.to_sql}))"
162
+ end
163
+
59
164
  # =========================================
60
165
  # Associations & Validations
61
166
  # =========================================
62
167
 
63
- belongs_to :owner, polymorphic: true
168
+ # `optional: false` is explicit everywhere because the gem's models load
169
+ # before Rails applies `belongs_to_required_by_default`, so the host app's
170
+ # default never reaches these associations.
171
+ belongs_to :owner, polymorphic: true, optional: false
64
172
 
65
- has_many :transactions, class_name: "Wallets::Transaction", dependent: :destroy
173
+ # foreign_key is explicit because this association is declared on the
174
+ # abstract WalletBase: ActiveRecord would otherwise derive it from the
175
+ # declaring class name ("wallet_base_id").
176
+ has_many :transactions, class_name: "Wallets::Transaction", foreign_key: :wallet_id, inverse_of: :wallet, dependent: :destroy
66
177
  has_many :outgoing_transfers,
67
- class_name: "Wallets::Transfer",
68
- foreign_key: :from_wallet_id,
69
- dependent: :destroy,
70
- inverse_of: :from_wallet
178
+ class_name: "Wallets::Transfer",
179
+ foreign_key: :from_wallet_id,
180
+ dependent: :destroy,
181
+ inverse_of: :from_wallet
71
182
  has_many :incoming_transfers,
72
- class_name: "Wallets::Transfer",
73
- foreign_key: :to_wallet_id,
74
- dependent: :destroy,
75
- inverse_of: :to_wallet
76
-
77
- validates :asset_code, presence: true, uniqueness: { scope: [:owner_type, :owner_id] }
78
- validates :balance, numericality: { only_integer: true }
79
- validates :balance, numericality: { greater_than_or_equal_to: 0 }, unless: :allow_negative_balance?
183
+ class_name: "Wallets::Transfer",
184
+ foreign_key: :to_wallet_id,
185
+ dependent: :destroy,
186
+ inverse_of: :to_wallet
187
+
188
+ validates :asset_code, presence: true, uniqueness: {scope: [:owner_type, :owner_id]}
189
+ # Signed on purpose: available balance can drop below zero without any
190
+ # overdraft (reserve 80 of 100, then a fraud hold covers the 100; or
191
+ # reserved value expires). A save-time floor would reject the very hold,
192
+ # release or incoming credit that follows. Commands enforce funding under
193
+ # the wallet lock instead. The cached column is what is validated, never
194
+ # the `balance` reader (see `read_attribute_for_validation`).
195
+ validates :balance, numericality: {only_integer: true}
80
196
 
81
197
  before_validation :normalize_asset_code!
82
- before_save :sync_metadata_cache
198
+
199
+ # A caller's own write to the wallet row inside a transaction leaves that
200
+ # transaction reading its own version of the row, which then matches the
201
+ # locked row even while its ledger snapshot is stale (see
202
+ # `stale_snapshot?`). Such a wallet counts as stale for the rest of the
203
+ # transaction: conservative, and it only ever costs locking reads.
204
+ # Creating the wallet is exempt (nobody else can have touched a ledger
205
+ # nobody else can see), and so is the ledger's own refresh. Writes that
206
+ # skip Active Record callbacks (raw SQL, `update_columns`) are outside the
207
+ # supported API.
208
+ after_save :remember_own_write, if: :saved_changes?, unless: :previously_new_record?
209
+ after_touch :remember_own_write
83
210
 
84
211
  # =========================================
85
212
  # Class Methods
@@ -89,33 +216,35 @@ module Wallets
89
216
  def create_for_owner!(owner:, asset_code:, initial_balance: 0, metadata: {})
90
217
  initial_balance = normalize_initial_balance(initial_balance)
91
218
  asset_code = normalize_asset_code(asset_code)
92
- metadata = metadata.respond_to?(:to_h) ? metadata.to_h : {}
93
219
 
94
220
  existing_wallet = find_by(owner: owner, asset_code: asset_code)
95
221
  return existing_wallet if existing_wallet.present?
96
222
 
97
- transaction do
98
- wallet = create!(
99
- owner: owner,
100
- asset_code: asset_code,
101
- balance: 0,
102
- metadata: metadata
103
- )
223
+ # `requires_new` gives us a savepoint when we are already inside a
224
+ # caller's transaction (e.g. `after_create` wallet auto-creation), and
225
+ # the rescue lives OUTSIDE the transaction block. Both matter on
226
+ # PostgreSQL: a concurrent duplicate INSERT aborts the transaction it
227
+ # ran in, so recovering with a SELECT only works after that
228
+ # transaction (or savepoint) has rolled back.
229
+ begin
230
+ transaction(requires_new: true) do
231
+ wallet = create!(
232
+ owner: owner,
233
+ asset_code: asset_code,
234
+ balance: 0,
235
+ metadata: metadata
236
+ )
237
+
238
+ wallet.credit(initial_balance, **initial_balance_credit_attributes) if initial_balance.positive?
104
239
 
105
- if initial_balance.positive?
106
- wallet.credit(initial_balance, **initial_balance_credit_attributes)
240
+ wallet
107
241
  end
108
-
109
- wallet
110
242
  rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
111
- wallet = find_by(owner: owner, asset_code: asset_code)
112
- raise error if wallet.nil?
243
+ raise error unless record_conflict_due_to_existing_wallet?(error)
113
244
 
114
- if record_conflict_due_to_existing_wallet?(error)
115
- wallet
116
- else
117
- raise error
118
- end
245
+ # Two callers raced to create the same wallet; the loser gracefully
246
+ # returns the winner's row.
247
+ find_by(owner: owner, asset_code: asset_code) || raise(error)
119
248
  end
120
249
  end
121
250
 
@@ -124,64 +253,94 @@ module Wallets
124
253
  def initial_balance_credit_attributes
125
254
  {
126
255
  category: :adjustment,
127
- metadata: { reason: "initial_balance" }
256
+ metadata: {reason: "initial_balance"}
128
257
  }
129
258
  end
130
259
 
131
260
  def normalize_initial_balance(value)
132
261
  return 0 if value.nil?
133
- raise ArgumentError, "Initial balance must be a whole number" unless value == value.to_i
134
262
 
135
- value = value.to_i
263
+ value = Wallets::WholeNumber.parse(value, name: "Initial balance")
136
264
  raise ArgumentError, "Initial balance cannot be negative" if value.negative?
137
265
 
138
266
  value
139
267
  end
140
268
 
141
269
  def normalize_asset_code(value)
142
- value.to_s.strip.downcase.presence || raise(ArgumentError, "Asset code is required")
270
+ Wallets.normalize_asset_code(value).presence || raise(ArgumentError, "Asset code is required")
143
271
  end
144
272
 
145
273
  def record_conflict_due_to_existing_wallet?(error)
146
274
  return true if error.is_a?(ActiveRecord::RecordNotUnique)
147
- return false unless error.is_a?(ActiveRecord::RecordInvalid)
148
275
 
149
276
  error.record.errors.of_kind?(:asset_code, :taken)
150
277
  end
151
278
  end
152
279
 
153
280
  # =========================================
154
- # Metadata Handling
281
+ # Balance & History
155
282
  # =========================================
156
283
 
157
- def metadata
158
- @indifferent_metadata ||= ActiveSupport::HashWithIndifferentAccess.new(super || {})
284
+ # The available balance: what can be spent right now. Held, reserved and
285
+ # expired value is excluded.
286
+ def balance
287
+ current_balance
288
+ end
289
+
290
+ # A ledger without holds computes exactly what 0.3 did, through the two
291
+ # helpers an embedder may override (D12). With holds, every term comes
292
+ # from one statement (see `balance_components`).
293
+ def current_balance
294
+ return positive_remaining_balance - unbacked_negative_balance unless holds_enabled?
295
+
296
+ balance_components.available
159
297
  end
160
298
 
161
- def metadata=(hash)
162
- @indifferent_metadata = nil
163
- super(hash.respond_to?(:to_h) ? hash.to_h : {})
299
+ def available_balance
300
+ balance
164
301
  end
165
302
 
166
- def reload(*)
167
- @indifferent_metadata = nil
168
- super
303
+ # Validations read the cached `balance` column. The `balance` reader
304
+ # computes the available balance from the whole ledger, so validating it
305
+ # would run that statement on every save (several times per command) and
306
+ # make even `create!` read the holds table.
307
+ def read_attribute_for_validation(attribute)
308
+ (attribute.to_s == "balance") ? read_attribute(:balance) : super
169
309
  end
170
310
 
171
- # =========================================
172
- # Balance & History
173
- # =========================================
311
+ # Value in the ledger that holds keep out of `balance`: held credits plus
312
+ # active reservations. It can exceed `total_balance` (a hold over value
313
+ # that is already reserved, or reservations whose backing expired), and is
314
+ # deliberately not capped.
315
+ def held_balance
316
+ return 0 unless holds_enabled?
174
317
 
175
- def balance
176
- current_balance
318
+ balance_components.held
177
319
  end
178
320
 
179
- def current_balance
180
- positive_remaining_balance - unbacked_negative_balance
321
+ # Everything the ledger holds for the owner, spendable or not: P - N.
322
+ # Without holds it is simply `balance`, whatever policy an embedder's
323
+ # `current_balance` applies.
324
+ def total_balance
325
+ return balance unless holds_enabled?
326
+
327
+ balance_components.total
328
+ end
329
+
330
+ # Whether a hold covers the whole wallet, including credits received
331
+ # later. A pending reservation is not a blanket hold.
332
+ def on_hold?
333
+ return false unless holds_enabled?
334
+
335
+ wallet_holds.coverage.wallet_scoped.active(at: evaluation_time).exists?
336
+ end
337
+
338
+ def holds_enabled?
339
+ self.class.holds_enabled?
181
340
  end
182
341
 
183
342
  def history
184
- transactions.order(created_at: :asc)
343
+ transactions.order(created_at: :asc, id: :asc)
185
344
  end
186
345
 
187
346
  def has_enough_balance?(amount)
@@ -195,117 +354,294 @@ module Wallets
195
354
  # Credit & Debit Operations
196
355
  # =========================================
197
356
 
198
- def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, **extra_transaction_attributes)
357
+ # Adds value to the wallet. With `hold:` or `available_at:` the value
358
+ # arrives held: the credit and its hold are written in one step, so it is
359
+ # never spendable in between, and the callbacks report the finished
360
+ # operation.
361
+ #
362
+ # wallet.credit(751, category: :reward, hold: :pending_validation)
363
+ # wallet.credit(751, category: :reward, hold: {category: :pending_validation, until: 3.months.from_now, metadata: {ride_id: 7}})
364
+ # wallet.credit(500, category: :reward, available_at: 14.days.from_now) # held as :pending until then
365
+ def credit(amount, metadata: {}, category: :credit, expires_at: nil, transfer: nil, hold: nil, available_at: nil, **extra_transaction_attributes)
199
366
  metadata = normalize_metadata(metadata)
200
-
201
- with_lock do
202
- apply_credit(
203
- amount,
204
- metadata: metadata,
205
- category: category,
206
- expires_at: expires_at,
207
- transfer: transfer,
208
- extra_attributes: extra_transaction_attributes
209
- )
367
+ extra_transaction_attributes = validate_additional_transaction_attributes!(extra_transaction_attributes)
368
+ arrival_hold = arrival_hold_attributes(hold, available_at)
369
+
370
+ # A savepoint even inside a caller's transaction, like every command: a
371
+ # credit that cannot get its hold must not survive as a spendable credit
372
+ # because the caller rescued the error and committed.
373
+ with_lock(requires_new: true) do
374
+ mutating_at(Time.current) do
375
+ # Value that becomes available at an instant already reached is
376
+ # simply available: there is nothing left to hold.
377
+ arrival_hold = nil if available_at && arrival_hold[:releases_at] <= evaluation_time
378
+
379
+ apply_credit(
380
+ amount,
381
+ metadata: metadata,
382
+ category: category,
383
+ expires_at: expires_at,
384
+ transfer: transfer,
385
+ extra_attributes: extra_transaction_attributes,
386
+ hold: arrival_hold
387
+ )
388
+ end
210
389
  end
211
390
  end
212
391
 
213
- def debit(amount, metadata: {}, category: :debit, transfer: nil, **extra_transaction_attributes)
392
+ # Takes value out of the wallet. Ordinarily it spends eligible credits,
393
+ # soonest-expiring first, and never touches held value.
394
+ #
395
+ # `from:` names the credits to take it from instead, in the order given.
396
+ # It is the one way to take value a hold covers, so it is how a hold ends
397
+ # the other way: releasing gives the value back, `from:` takes it away.
398
+ # The debit is allocated to the named credits in full or not at all,
399
+ # never to other credits and never to debt, and the holds stay exactly as
400
+ # they were: a cancelled credit keeps its reasons with nothing left to
401
+ # hold.
402
+ #
403
+ # wallet.debit(751, category: :reversal, from: credit, metadata: {reason: "reward_rejected"})
404
+ # wallet.debit(150, category: :reversal, from: [first_credit, second_credit])
405
+ #
406
+ # Held value needs no funding, but the rest of a targeted debit does:
407
+ # with overdrafts off, what it takes from unheld credits must fit in the
408
+ # available balance, so naming an unheld credit cannot spend value that a
409
+ # reservation set aside, nor deepen a deficit.
410
+ def debit(amount, metadata: {}, category: :debit, transfer: nil, from: ELIGIBLE_CREDITS, **extra_transaction_attributes)
214
411
  metadata = normalize_metadata(metadata)
412
+ extra_transaction_attributes = validate_additional_transaction_attributes!(extra_transaction_attributes)
413
+ sources = named_credits(from)
215
414
 
216
- with_lock do
415
+ with_lock(requires_new: true) do
217
416
  apply_debit(
218
417
  amount,
219
418
  metadata: metadata,
220
419
  category: category,
221
420
  transfer: transfer,
222
- extra_attributes: extra_transaction_attributes
421
+ extra_attributes: extra_transaction_attributes,
422
+ from: sources
223
423
  )
224
424
  end
225
425
  end
226
426
 
427
+ # =========================================
428
+ # Holds & Reservations
429
+ # =========================================
430
+
431
+ # Keeps value in the wallet but out of `balance` until the hold is
432
+ # released or its deadline passes. Given a credit, it holds that credit's
433
+ # whole remaining value; without one, it holds every credit in the wallet,
434
+ # including credits that arrive later. Holding an already held credit is
435
+ # fine: each hold is an independent reason, released on its own.
436
+ #
437
+ # wallet.hold(credit, category: :fraud_review, metadata: {case_id: 42})
438
+ # wallet.hold(category: :chargeback, until: 30.days.from_now)
439
+ #
440
+ # Only leaving the credit out holds the whole wallet: an explicit nil
441
+ # (a credit that was looked up and not found) is refused.
442
+ def hold(credit = WHOLE_WALLET, category:, until: nil, metadata: {})
443
+ ensure_holds_enabled!
444
+ raise ArgumentError, "Pass no credit to hold the whole wallet; nil is not a credit" if credit.nil?
445
+
446
+ credit = nil if credit.equal?(WHOLE_WALLET)
447
+ metadata = normalize_metadata(metadata)
448
+ # `until` is a Ruby keyword: the argument reads as plain English at the
449
+ # call site, and the binding is the only way to reach it in here.
450
+ releases_at = normalize_deadline(binding.local_variable_get(:until))
451
+
452
+ with_lock(requires_new: true) do
453
+ mutating_at(Time.current) do
454
+ # The target first: a wrong argument is a wrong argument, whatever
455
+ # the deadline says.
456
+ ensure_holdable!(credit) unless credit.nil?
457
+ ensure_deadline_ahead!(releases_at)
458
+
459
+ place_hold!(credit: credit, category: category, releases_at: releases_at, metadata: metadata)
460
+ end
461
+ end
462
+ end
463
+
464
+ # Sets an amount of the available balance aside, like a pending card
465
+ # charge; reservations add up. Funded exactly like a debit: the amount
466
+ # must fit in `balance` unless `allow_negative_balance` is on.
467
+ #
468
+ # wallet.reserve(3_000, category: :booking, until: 7.days.from_now, metadata: {booking_id: 9})
469
+ def reserve(amount, category:, until: nil, metadata: {})
470
+ ensure_holds_enabled!
471
+ amount = normalize_positive_amount!(amount)
472
+ metadata = normalize_metadata(metadata)
473
+ releases_at = normalize_deadline(binding.local_variable_get(:until))
474
+
475
+ with_lock(requires_new: true) do
476
+ mutating_at(Time.current) do
477
+ ensure_deadline_ahead!(releases_at)
478
+ available = balance
479
+
480
+ if amount > available && !allow_negative_balance?
481
+ reject_insufficient_balance!(amount, available, metadata)
482
+ end
483
+
484
+ place_hold!(amount: amount, category: category, releases_at: releases_at, metadata: metadata, previous_balance: available)
485
+ end
486
+ end
487
+ end
488
+
227
489
  # =========================================
228
490
  # Transfers
229
491
  # =========================================
230
492
 
231
- def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil)
493
+ # Moves value to another wallet of the same asset. `from:` works as it
494
+ # does on `debit`: the outbound leg is taken from the named credits only,
495
+ # held value included, and the inbound legs are credited as usual.
496
+ #
497
+ # wallet.transfer_to(platform.wallet, 751, category: :reversal, from: credit)
498
+ def transfer_to(other_wallet, amount, category: :transfer, metadata: {}, expiration_policy: nil, expires_at: nil, from: ELIGIBLE_CREDITS)
232
499
  raise InvalidTransfer, "Target wallet is required" if other_wallet.nil?
500
+ raise InvalidTransfer, "Source wallet must be persisted" unless persisted?
233
501
  raise InvalidTransfer, "Target wallet must be persisted" unless other_wallet.persisted?
502
+ # The class first: two ledgers keep separate tables, so a wallet of
503
+ # another class can share this wallet's id without being this wallet.
504
+ raise InvalidTransfer, "Wallet classes must match" unless other_wallet.instance_of?(self.class)
234
505
  raise InvalidTransfer, "Cannot transfer to the same wallet" if other_wallet.id == id
235
506
  raise InvalidTransfer, "Wallet assets must match" unless asset_code == other_wallet.asset_code
236
- raise InvalidTransfer, "Wallet classes must match" unless other_wallet.class == self.class
237
507
 
238
508
  amount = normalize_positive_amount!(amount)
239
509
  metadata = normalize_metadata(metadata)
240
510
  resolved_policy, inbound_expires_at = resolve_transfer_expiration!(expiration_policy, expires_at)
511
+ sources = named_credits(from)
241
512
 
242
- ActiveRecord::Base.transaction do
513
+ # A savepoint even inside a caller's transaction, like every command: a
514
+ # transfer whose inbound leg fails must take its outbound debit and its
515
+ # Transfer row with it, even when the caller rescues and commits.
516
+ self.class.transaction(requires_new: true) do
243
517
  lock_wallet_pair!(other_wallet)
518
+ now = Time.current
519
+
520
+ # The recipient pins the same instant in its own `apply_credit`.
521
+ mutating_at(now) do
522
+ previous_balance = balance
523
+
524
+ # Insufficient-balance gate.
525
+ #
526
+ # `allow_negative_balance` is the single source of truth for whether
527
+ # the gem permits a wallet to drop below zero. Direct `wallet.debit`
528
+ # has always honored it via `apply_debit`; transfers must too, or
529
+ # the flag is half-applied and surprising (debits go through, the
530
+ # canonical transfer primitive doesn't).
531
+ #
532
+ # When the flag is OFF, this is the only chance to refuse cleanly:
533
+ # we want to dispatch `:insufficient_balance` for the observability
534
+ # callback, then raise BEFORE creating the Transfer row. (The later
535
+ # `apply_debit` call would also catch this, but only after a
536
+ # pointless Transfer row was created.)
537
+ #
538
+ # When the flag is ON, we let the transfer through. `apply_debit`
539
+ # below will allocate as many eligible buckets as exist; the
540
+ # remaining shortfall lands as an unbacked negative transaction the
541
+ # wallet's `balance` accounting accommodates via
542
+ # `unbacked_negative_balance`.
543
+ #
544
+ # A targeted transfer is funded like a targeted debit instead (see
545
+ # `named_buckets!`), and checked here for the same reason: a refusal
546
+ # must leave no Transfer row behind.
547
+ if sources
548
+ named_buckets!(sources, amount, previous_balance, metadata)
549
+ elsif amount > previous_balance && !allow_negative_balance?
550
+ reject_insufficient_balance!(amount, previous_balance, metadata)
551
+ end
244
552
 
245
- previous_balance = balance
246
- if amount > previous_balance
247
- dispatch_insufficient_balance!(amount, previous_balance, metadata)
248
- raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})"
249
- end
250
-
251
- transfer = transfer_class.create!(
252
- from_wallet: self,
253
- to_wallet: other_wallet,
254
- asset_code: asset_code,
255
- amount: amount,
256
- category: category,
257
- expiration_policy: resolved_policy,
258
- metadata: metadata
259
- )
553
+ # `:preserve` gives each inbound credit the expiration of the source
554
+ # bucket it was allocated from. A portion of the outbound debit that
555
+ # no bucket backs has no source expiration to preserve, and
556
+ # `build_preserved_transfer_inbound_credit_specs` would refuse with
557
+ # `InvalidTransfer` on the count mismatch, defeating the very callers
558
+ # who opted into negative balances. The only honest fallback is to
559
+ # collapse the inbound side to a single evergreen credit (`:none`):
560
+ # value created without a source bucket has no source expiration.
561
+ #
562
+ # The fallback is decided by what allocation will actually back,
563
+ # never by the available balance. Available also nets out
564
+ # reservations and earlier debt, so a transfer can exceed it while
565
+ # FEFO still backs every unit from eligible buckets (a reservation
566
+ # outstanding, or old debt followed by fresh credits); such a
567
+ # transfer keeps its source expirations. `eligible_remaining_balance`
568
+ # sums exactly the buckets `allocate_debit!` walks, at this instant
569
+ # and under these locks, so it is the outcome, not an estimate.
570
+ #
571
+ # A targeted transfer never has an unbacked portion (the named
572
+ # credits fund all of it or the transfer is refused above), so it
573
+ # always keeps its sources' expirations, even when they are held
574
+ # and nothing is available.
575
+ #
576
+ # Callers who care about a specific expiration on the inbound side
577
+ # should pass `expiration_policy: :fixed, expires_at: …`. Those are
578
+ # honored unchanged because they don't depend on source-bucket
579
+ # walking. Same for an explicit `:none`.
580
+ if resolved_policy == "preserve" && sources.nil? && amount > eligible_remaining_balance
581
+ resolved_policy = "none"
582
+ inbound_expires_at = nil
583
+ end
260
584
 
261
- shared_metadata = metadata.to_h.deep_stringify_keys.merge(
262
- "transfer_id" => transfer.id,
263
- "asset_code" => asset_code,
264
- "transfer_category" => category.to_s,
265
- "transfer_expiration_policy" => resolved_policy
266
- )
585
+ transfer = transfer_class.create!(
586
+ from_wallet: self,
587
+ to_wallet: other_wallet,
588
+ asset_code: asset_code,
589
+ amount: amount,
590
+ category: category,
591
+ expiration_policy: resolved_policy,
592
+ metadata: metadata
593
+ )
267
594
 
268
- outbound_transaction = apply_debit(
269
- amount,
270
- category: :transfer_out,
271
- metadata: shared_metadata.merge(
272
- "counterparty_wallet_id" => other_wallet.id,
273
- "counterparty_owner_id" => other_wallet.owner_id,
274
- "counterparty_owner_type" => other_wallet.owner_type
275
- ),
276
- transfer: transfer
277
- )
595
+ shared_metadata = metadata.to_h.deep_stringify_keys.merge(
596
+ "transfer_id" => transfer.id,
597
+ "asset_code" => asset_code,
598
+ "transfer_category" => category.to_s,
599
+ "transfer_expiration_policy" => resolved_policy
600
+ )
278
601
 
279
- build_transfer_inbound_credit_specs(
280
- transfer: transfer,
281
- outbound_transaction: outbound_transaction,
282
- amount: amount,
283
- expiration_policy: resolved_policy,
284
- expires_at: inbound_expires_at
285
- ).each do |spec|
286
- other_wallet.send(
287
- :apply_credit,
288
- spec[:amount],
289
- category: :transfer_in,
602
+ _outbound_transaction, allocations = apply_debit_with_allocations(
603
+ amount,
604
+ category: :transfer_out,
290
605
  metadata: shared_metadata.merge(
291
- "counterparty_wallet_id" => id,
292
- "counterparty_owner_id" => owner_id,
293
- "counterparty_owner_type" => owner_type
606
+ "counterparty_wallet_id" => other_wallet.id,
607
+ "counterparty_owner_id" => other_wallet.owner_id,
608
+ "counterparty_owner_type" => other_wallet.owner_type
294
609
  ),
295
- expires_at: spec[:expires_at],
296
- transfer: transfer
610
+ transfer: transfer,
611
+ from: sources
297
612
  )
298
- end
299
613
 
300
- dispatch_callback(:transfer_completed,
301
- wallet: self,
302
- transfer: transfer,
303
- amount: amount,
304
- category: category,
305
- metadata: metadata
306
- )
614
+ build_transfer_inbound_credit_specs(
615
+ transfer: transfer,
616
+ allocations: allocations,
617
+ amount: amount,
618
+ expiration_policy: resolved_policy,
619
+ expires_at: inbound_expires_at
620
+ ).each do |spec|
621
+ other_wallet.send(
622
+ :apply_credit,
623
+ spec[:amount],
624
+ category: :transfer_in,
625
+ metadata: shared_metadata.merge(
626
+ "counterparty_wallet_id" => id,
627
+ "counterparty_owner_id" => owner_id,
628
+ "counterparty_owner_type" => owner_type
629
+ ),
630
+ expires_at: spec[:expires_at],
631
+ transfer: transfer,
632
+ at: now
633
+ )
634
+ end
635
+
636
+ dispatch_callback(:transfer_completed,
637
+ wallet: self,
638
+ transfer: transfer,
639
+ amount: amount,
640
+ category: category,
641
+ metadata: metadata)
307
642
 
308
- transfer
643
+ transfer
644
+ end
309
645
  end
310
646
  end
311
647
 
@@ -335,6 +671,10 @@ module Wallets
335
671
  self.class.transfer_class
336
672
  end
337
673
 
674
+ def hold_class
675
+ self.class.hold_class
676
+ end
677
+
338
678
  # =========================================
339
679
  # Callback Dispatching
340
680
  # =========================================
@@ -343,54 +683,294 @@ module Wallets
343
683
  event = self.class.callback_event_map[kind]
344
684
  return if event.nil?
345
685
 
346
- callbacks.dispatch(event, **data)
686
+ callback_dispatcher = callbacks
687
+ dispatch = -> { callback_dispatcher.dispatch(event, **data) }
688
+
689
+ # Successful ledger callbacks describe committed facts, so they wait for
690
+ # the commit. They are queued on the innermost open transaction, the
691
+ # command's own savepoint: a failure later in the command rolls it back
692
+ # and drops them, even when the caller rescues and commits; a released
693
+ # savepoint hands them to its parent; an enclosing rollback drops them.
694
+ # The same rules as Active Record's own after_commit, which also treats a
695
+ # `transaction(joinable: false)` as a commit boundary. Failure callbacks
696
+ # are intentionally immediate because their transaction is about to roll
697
+ # back by design.
698
+ if kind == :insufficient || !ActiveRecord.respond_to?(:after_all_transactions_commit)
699
+ dispatch.call
700
+ else
701
+ ActiveRecord.after_all_transactions_commit(&dispatch)
702
+ end
703
+ end
704
+
705
+ # =========================================
706
+ # Evaluation Instant
707
+ # =========================================
708
+
709
+ # A mutation decides on several accounting reads: affordability, FEFO
710
+ # eligibility, the cached balance, snapshots and callbacks. They must all
711
+ # describe one instant (a credit expiring or a hold lapsing between two of
712
+ # them would make the operation contradict itself) and the rows as they
713
+ # are now, not as an older snapshot saw them. `evaluating_at` pins both for
714
+ # the block, and every balance read inside it picks them up, including an
715
+ # embedder's own `current_balance` that calls the helpers without
716
+ # arguments. The first pin wins, so a nested mutation keeps its caller's
717
+ # instant.
718
+ def evaluating_at(time, current_reads: false)
719
+ return yield if @evaluation_time
720
+
721
+ @evaluation_time = time
722
+ @current_reads = current_reads
723
+ begin
724
+ yield
725
+ ensure
726
+ @evaluation_time = @current_reads = nil
727
+ end
728
+ end
729
+
730
+ # The pin a mutation takes once the wallet lock is held: one instant, and
731
+ # current reads exactly when this transaction's snapshot is stale.
732
+ def mutating_at(time, &block)
733
+ return yield if @evaluation_time
734
+
735
+ evaluating_at(time, current_reads: stale_snapshot?, &block)
736
+ end
737
+
738
+ def evaluation_time
739
+ @evaluation_time || Time.current
740
+ end
741
+
742
+ def current_reads?
743
+ @current_reads == true
744
+ end
745
+
746
+ # Whether this mutation must use locking reads to see the current ledger.
747
+ # Called with the wallet lock held.
748
+ #
749
+ # Only when this transaction's snapshot is stale for this wallet: every
750
+ # command increments the wallet's `ledger_revision` under its lock
751
+ # (`refresh_cached_balance!`), so when the revision this transaction's
752
+ # snapshot shows is the revision the lock returned, nothing was committed
753
+ # to this wallet's ledger after the snapshot and plain reads already see
754
+ # it as it is now. That covers every command that runs first in its
755
+ # transaction, or in a transaction that created the wallet, or that read
756
+ # before taking the lock without anyone writing in between. A counter,
757
+ # not a timestamp: no clock, `no_touching` or `touch(time:)` can make two
758
+ # revisions of the ledger look alike.
759
+ #
760
+ # Locking reads everywhere would be correct but not safe to run: InnoDB
761
+ # takes gap locks on the ranges they scan, and the gaps after a wallet's
762
+ # newest rows are the very gaps OTHER wallets insert into, so unrelated
763
+ # concurrent debits or sign-ups deadlock each other (a tenth of them in a
764
+ # stress run). Kept to the stale case, which needs a caller that read
765
+ # first AND a concurrent change to this same wallet, they are rare.
766
+ #
767
+ # A stale verdict is remembered for as long as the real database
768
+ # transaction lasts, savepoints and all: once this transaction writes the
769
+ # wallet row, its snapshot shows its own write and could no longer tell.
770
+ def stale_snapshot?
771
+ return false unless self.class.current_reads_need_locks?
772
+ return true if remembered_stale_snapshots.key?(stale_snapshot_key)
773
+
774
+ columns = row_marker_columns
775
+ snapshot = self.class.uncached { self.class.unscoped.where(id: id).pick(*columns) }
776
+ return false if Array.wrap(snapshot) == columns.map { |column| read_attribute(column) }
777
+
778
+ remember_stale_snapshot!
779
+ true
780
+ end
781
+
782
+ # What tells one committed state of this wallet's ledger from another:
783
+ # its `ledger_revision`. See `ledger_revision?` for tables without one.
784
+ def row_marker_columns
785
+ ledger_revision? ? %w[ledger_revision] : %w[updated_at balance]
786
+ end
787
+
788
+ # Whether the wallet table has `ledger_revision`, as every 0.4 schema
789
+ # does. The counter is the robust marker. A ledger without holds on an
790
+ # embedder's 0.3-era table falls back to a strictly increasing
791
+ # `updated_at` (see `next_row_marker`) compared together with `balance`:
792
+ # it keeps those schemas working, but another connection's
793
+ # `touch(time:)` can still spoof it, so embedders should add the column
794
+ # (bigint, not null, default 0). A ledger that keeps holds refuses to run
795
+ # without it, like a ledger whose holds table is missing: a spoofed
796
+ # marker could let a stale read spend held value.
797
+ def ledger_revision?
798
+ return true if has_attribute?(:ledger_revision)
799
+ return false unless holds_enabled?
800
+
801
+ raise Error, "#{self.class.name} keeps holds, so its wallet table needs a ledger_revision column (bigint, not null, default 0)"
802
+ end
803
+
804
+ # Remembers the verdict with the real transaction it belongs to. The
805
+ # hook at that transaction's end is only the cleanup: Active Record skips
806
+ # the remaining commit hooks when an earlier one raises (a host model's
807
+ # `after_commit`, say), and a verdict outliving its transaction would make
808
+ # every later one on this connection lock for nothing. So a verdict whose
809
+ # transaction has ended counts as gone, whether or not its hook ran.
810
+ def remember_stale_snapshot!
811
+ remembered = remembered_stale_snapshots
812
+ key = stale_snapshot_key
813
+ return if remembered.key?(key)
814
+
815
+ connection = self.class.connection
816
+ remembered[key] = real_transaction(connection)
817
+ at_end_of_real_transaction(connection) { remembered.delete(key) }
818
+ end
819
+
820
+ # Per thread, and per connection inside it: a verdict is about one
821
+ # connection's database transaction, never another's. Each maps to the
822
+ # real transaction it was reached in, and one whose transaction has ended
823
+ # is dropped on the way out.
824
+ def remembered_stale_snapshots
825
+ remembered = ActiveSupport::IsolatedExecutionState[:wallets_stale_snapshots] ||= {}
826
+ remembered.delete_if { |_key, transaction| transaction&.state&.finalized? }
827
+ end
828
+
829
+ # The outermost transaction open on `connection`: the real database
830
+ # transaction, whose snapshot a verdict is about. Active Record keeps no
831
+ # public pointer from a savepoint to its parent, so this reads the
832
+ # transaction manager's stack, an internal structure (the same on Rails
833
+ # 7.2 and 8.1). Its state is public: `finalized?` once it commits or
834
+ # rolls back, on both versions (7.2's `open?` stays true to the end, so
835
+ # it cannot tell). Should a later Rails change the stack, this answers
836
+ # nil, and the verdict then lasts until its hook ends it, exactly as long
837
+ # as it used to: never shorter, which would read a stale snapshot. The
838
+ # current transaction is no substitute, as it may be a savepoint that
839
+ # ends long before the real transaction does.
840
+ def real_transaction(connection)
841
+ manager = connection.transaction_manager
842
+ return unless manager.instance_variable_defined?(:@stack)
843
+
844
+ manager.instance_variable_get(:@stack).first
845
+ end
846
+
847
+ def stale_snapshot_key
848
+ [self.class.connection.object_id, self.class.table_name, id]
849
+ end
850
+
851
+ # Runs the block once the real database transaction open on `connection`
852
+ # ends, by commit or by rollback, however deep in savepoints this is
853
+ # called. A savepoint's own hooks are not enough: one released under a
854
+ # `transaction(joinable: false)` runs its commit hooks at once, with the
855
+ # real transaction (and its snapshot) still open. So each hook hands the
856
+ # block on to the next transaction out, until none is left.
857
+ #
858
+ # The hooks go on Active Record's internal transaction, not on the public
859
+ # `ActiveRecord::Transaction` it exposes: a `transaction(joinable: false)`
860
+ # exposes the null transaction, whose `after_commit` runs at once, and
861
+ # would end a verdict while its transaction still runs.
862
+ def at_end_of_real_transaction(connection, &block)
863
+ transaction = connection.current_transaction
864
+ return yield unless transaction.open?
865
+
866
+ hand_on = -> { at_end_of_real_transaction(connection, &block) }
867
+ transaction.after_commit(&hand_on)
868
+ transaction.after_rollback(&hand_on)
869
+ end
870
+
871
+ # Turns a read a mutation decides on into a current read (see
872
+ # `stale_snapshot?`). Public readers never lock.
873
+ def current_read(relation, lock)
874
+ return relation unless lock && self.class.current_reads_need_locks?
875
+
876
+ relation.lock("FOR UPDATE")
877
+ end
878
+
879
+ # The same rule for a raw subquery inside a locked statement: MySQL locks
880
+ # (and reads current) rows of a subquery only when the subquery itself
881
+ # says FOR UPDATE.
882
+ def locking_clause(lock)
883
+ return "" unless lock && self.class.current_reads_need_locks?
884
+
885
+ " FOR UPDATE"
347
886
  end
348
887
 
349
888
  # =========================================
350
889
  # Credit/Debit Implementation
351
890
  # =========================================
352
891
 
353
- def apply_credit(amount, metadata:, category:, expires_at:, transfer:, extra_attributes: {})
354
- amount = normalize_positive_amount!(amount)
355
- validate_expiration!(expires_at)
892
+ # `hold:` (category, releases_at, metadata) places a hold on the new
893
+ # credit before anything reads the balance again, so no snapshot, cache
894
+ # or callback ever sees the value spendable. A credit never lowers the
895
+ # available balance (a hold on arrival covers only the credit itself), so
896
+ # it can cross no threshold.
897
+ def apply_credit(amount, metadata:, category:, expires_at:, transfer:, extra_attributes: {}, hold: nil, at: evaluation_time)
898
+ mutating_at(at) do
899
+ amount = normalize_positive_amount!(amount)
900
+ validate_expiration!(expires_at, at: evaluation_time)
901
+ ensure_deadline_ahead!(hold[:releases_at]) if hold
356
902
 
357
- previous_balance = balance
903
+ previous_balance = balance
358
904
 
359
- transaction = transactions.create!(
360
- {
905
+ transaction = transactions.create!(
906
+ {
907
+ amount: amount,
908
+ category: category,
909
+ expires_at: expires_at,
910
+ metadata: metadata,
911
+ transfer: transfer
912
+ }.merge(extra_attributes)
913
+ )
914
+ arrival_hold = hold_class.create!(wallet: self, credit: transaction, **hold) if hold
915
+
916
+ new_balance = refresh_cached_balance!
917
+ transaction.sync_balance_snapshot!(before: previous_balance, after: new_balance)
918
+
919
+ dispatch_callback(:credited,
920
+ wallet: self,
361
921
  amount: amount,
362
922
  category: category,
363
- expires_at: expires_at,
364
- metadata: metadata,
365
- transfer: transfer
366
- }.merge(extra_attributes)
367
- )
368
-
369
- refresh_cached_balance!
370
- transaction.sync_balance_snapshot!(before: previous_balance, after: balance)
923
+ transaction: transaction,
924
+ previous_balance: previous_balance,
925
+ new_balance: new_balance,
926
+ metadata: metadata)
927
+ dispatch_hold_created!(arrival_hold, previous_balance: previous_balance, new_balance: new_balance) if arrival_hold
371
928
 
372
- dispatch_callback(:credited,
373
- wallet: self,
374
- amount: amount,
375
- category: category,
376
- transaction: transaction,
377
- previous_balance: previous_balance,
378
- new_balance: balance,
379
- metadata: metadata
380
- )
929
+ transaction
930
+ end
931
+ end
381
932
 
382
- transaction
933
+ def apply_debit(...)
934
+ apply_debit_with_allocations(...).first
383
935
  end
384
936
 
385
- def apply_debit(amount, metadata:, category:, transfer:, extra_attributes: {})
386
- amount = normalize_positive_amount!(amount)
387
- previous_balance = balance
937
+ # The debit, and the allocations that fund it, each holding the very
938
+ # source row the allocator locked (a transfer keeps their expirations).
939
+ # `from:` is the credits a targeted debit names (see `named_credits`).
940
+ def apply_debit_with_allocations(amount, metadata:, category:, transfer:, extra_attributes: {}, allow_unbacked: allow_negative_balance?, from: nil, at: evaluation_time)
941
+ mutating_at(at) do
942
+ amount = normalize_positive_amount!(amount)
943
+ previous_balance = balance
944
+
945
+ buckets = named_buckets!(from, amount, previous_balance, metadata) if from
946
+ if from.nil? && amount > previous_balance && !allow_unbacked
947
+ reject_insufficient_balance!(amount, previous_balance, metadata)
948
+ end
949
+
950
+ # A targeted debit is never left unbacked, whatever the flag says.
951
+ spend_transaction, allocations = post_debit!(
952
+ amount,
953
+ metadata: metadata,
954
+ category: category,
955
+ transfer: transfer,
956
+ extra_attributes: extra_attributes,
957
+ buckets: buckets,
958
+ allow_unbacked: allow_unbacked && from.nil?
959
+ )
388
960
 
389
- if amount > previous_balance && !allow_negative_balance?
390
- dispatch_insufficient_balance!(amount, previous_balance, metadata)
391
- raise InsufficientBalance, "Insufficient balance (#{previous_balance} < #{amount})"
961
+ new_balance = refresh_cached_balance!
962
+ report_debit!(spend_transaction, amount: amount, category: category, metadata: metadata, previous_balance: previous_balance, new_balance: new_balance)
963
+ dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
964
+
965
+ [spend_transaction, allocations]
392
966
  end
967
+ end
393
968
 
969
+ # Writes a debit and the allocations that fund it: from `buckets` when
970
+ # given, else from eligible credits. Whatever no bucket backs is left
971
+ # unbacked, which only `allow_unbacked` permits. Funding has been checked
972
+ # by then; this only refuses to write a debit that does not add up.
973
+ def post_debit!(amount, metadata:, category:, transfer:, extra_attributes: {}, buckets: nil, allow_unbacked: false)
394
974
  spend_transaction = transactions.create!(
395
975
  {
396
976
  amount: -amount,
@@ -400,14 +980,20 @@ module Wallets
400
980
  }.merge(extra_attributes)
401
981
  )
402
982
 
403
- remaining_to_allocate = allocate_debit!(spend_transaction, amount)
983
+ allocations = allocate_debit!(spend_transaction, amount, buckets: buckets)
984
+ remaining_to_allocate = amount - allocations.sum(&:amount)
404
985
 
405
- if remaining_to_allocate.positive? && !allow_negative_balance?
986
+ if remaining_to_allocate.positive? && !allow_unbacked
406
987
  raise InsufficientBalance, "Not enough balance buckets to cover the debit"
407
988
  end
408
989
 
409
- refresh_cached_balance!
410
- spend_transaction.sync_balance_snapshot!(before: previous_balance, after: balance)
990
+ [spend_transaction, allocations]
991
+ end
992
+
993
+ # The debit's balance snapshot and its `debited` callback, from the
994
+ # balances before and after the whole command.
995
+ def report_debit!(spend_transaction, amount:, category:, metadata:, previous_balance:, new_balance:)
996
+ spend_transaction.sync_balance_snapshot!(before: previous_balance, after: new_balance)
411
997
 
412
998
  dispatch_callback(:debited,
413
999
  wallet: self,
@@ -415,42 +1001,432 @@ module Wallets
415
1001
  category: category,
416
1002
  transaction: spend_transaction,
417
1003
  previous_balance: previous_balance,
418
- new_balance: balance,
419
- metadata: metadata
420
- )
421
-
422
- dispatch_balance_threshold_callbacks!(previous_balance)
423
-
424
- spend_transaction
1004
+ new_balance: new_balance,
1005
+ metadata: metadata)
425
1006
  end
426
1007
 
427
- def allocate_debit!(spend_transaction, amount)
428
- remaining_to_allocate = amount
1008
+ # Allocates `amount` of the debit to buckets and returns the allocations,
1009
+ # in order: the named `buckets` of a targeted debit, else every eligible
1010
+ # credit (see `eligible_buckets`). Whatever no bucket can back is left
1011
+ # unbacked.
1012
+ def allocate_debit!(spend_transaction, amount, buckets: nil)
1013
+ split_across(buckets || eligible_buckets, amount).map do |source_transaction, portion|
1014
+ allocation_class.create!(
1015
+ spend_transaction: spend_transaction,
1016
+ source_transaction: source_transaction,
1017
+ amount: portion
1018
+ )
1019
+ end
1020
+ end
429
1021
 
430
- positive_transactions = transactions
431
- .where("amount > 0")
432
- .where("expires_at IS NULL OR expires_at > ?", Time.current)
1022
+ # The buckets an ordinary debit walks, locked, in the order it spends
1023
+ # them.
1024
+ #
1025
+ # Only eligible buckets fund a debit (see `eligible_credits`).
1026
+ # Reservations pin no bucket: affordability enforces them, so ordinary
1027
+ # debits leave them backed by the longest-lived buckets.
1028
+ #
1029
+ # Soonest-expiring buckets are consumed first so expirable value never
1030
+ # sits unused behind evergreen value; ties (including all-evergreen
1031
+ # wallets) fall back to oldest-first.
1032
+ #
1033
+ # Each bucket's remaining value comes from the same locked statement
1034
+ # that loads it, so allocation decides on current rows (see
1035
+ # `current_reads_need_locks?`), never on an older snapshot.
1036
+ def eligible_buckets
1037
+ eligible_credits
1038
+ .select(transaction_class.arel_table[Arel.star], Arel.sql("#{remaining_amount_sql} AS allocatable_amount"))
433
1039
  .order(Arel.sql("COALESCE(expires_at, '9999-12-31 23:59:59'), id ASC"))
434
1040
  .lock("FOR UPDATE")
435
1041
  .to_a
1042
+ end
436
1043
 
437
- positive_transactions.each do |source_transaction|
438
- leftover = source_transaction.remaining_amount
439
- next if leftover <= 0
1044
+ # Splits `amount` across `buckets` in their order: each gives what it has
1045
+ # left until nothing is left to take. Returns [bucket, portion] pairs;
1046
+ # their sum falls short of `amount` only when the buckets run out.
1047
+ def split_across(buckets, amount)
1048
+ remaining_to_allocate = amount
440
1049
 
441
- allocation_amount = [leftover, remaining_to_allocate].min
1050
+ buckets.each_with_object([]) do |bucket, portions|
1051
+ break portions unless remaining_to_allocate.positive?
442
1052
 
443
- allocation_class.create!(
444
- spend_transaction: spend_transaction,
445
- source_transaction: source_transaction,
446
- amount: allocation_amount
1053
+ portion = [bucket.allocatable_amount.to_i, remaining_to_allocate].min
1054
+ next unless portion.positive?
1055
+
1056
+ portions << [bucket, portion]
1057
+ remaining_to_allocate -= portion
1058
+ end
1059
+ end
1060
+
1061
+ # =========================================
1062
+ # Targeted Debit Implementation (`from:`)
1063
+ # =========================================
1064
+
1065
+ # The credits `from:` names, as far as they can be checked without the
1066
+ # ledger: one saved credit of this ledger, or a list of distinct ones, in
1067
+ # the caller's order. nil when `from:` was left out. Whose they are,
1068
+ # whether they are credits, and what they have left to give is read under
1069
+ # the wallet lock (see `named_buckets!`), never taken from the caller's
1070
+ # copies.
1071
+ def named_credits(from)
1072
+ return if from.equal?(ELIGIBLE_CREDITS)
1073
+
1074
+ ensure_holds_enabled!
1075
+ raise ArgumentError, "from: is nil; leave it out to spend any eligible credit" if from.nil?
1076
+
1077
+ credits = Array.wrap(from)
1078
+ raise ArgumentError, "from: names no credit" if credits.empty?
1079
+
1080
+ credits.each do |credit|
1081
+ unless credit.instance_of?(transaction_class)
1082
+ raise ArgumentError, "from: takes #{transaction_class.name} credits, not #{credit.class.name}"
1083
+ end
1084
+ raise ArgumentError, "from: takes saved credits only" unless credit.persisted?
1085
+ end
1086
+
1087
+ repeated = credits.map(&:id).tally.select { |_id, count| count > 1 }.keys
1088
+ raise ArgumentError, "from: names transaction #{repeated.join(", ")} more than once" if repeated.any?
1089
+
1090
+ credits
1091
+ end
1092
+
1093
+ # The named credits, read again under the wallet lock in the caller's
1094
+ # order, once they are known to fund `amount` (D7, D8). Returns them as
1095
+ # the buckets to allocate from; raises otherwise, before anything is
1096
+ # written.
1097
+ #
1098
+ # - Every one is a credit of this wallet; anything else is a wrong
1099
+ # argument (ArgumentError), like `hold` draws it.
1100
+ # - A credit expired at this instant has nothing left to give, exactly
1101
+ # like a spent one, so it is passed over and never allocated from:
1102
+ # handing on its value would create value that no longer exists,
1103
+ # whatever a transfer's expiration policy.
1104
+ # - Together the rest hold at least `amount`: a targeted debit is
1105
+ # allocated to them in full, never to other credits and never to debt.
1106
+ # - With overdrafts off, the part they give from unheld value must fit in
1107
+ # the available balance, floored at zero. Held value is not part of
1108
+ # the available balance, so taking it needs no funding; unheld value
1109
+ # is, so naming an unheld credit can neither spend what a reservation
1110
+ # set aside nor deepen a deficit.
1111
+ def named_buckets!(credits, amount, available, metadata)
1112
+ buckets = locked_named_buckets(credits)
1113
+ portions = split_across(buckets, amount)
1114
+ found = portions.sum { |_bucket, portion| portion }
1115
+ covered_consumed = portions.sum { |bucket, portion| covered_bucket?(bucket) ? portion : 0 }
1116
+
1117
+ if found < amount
1118
+ reject_insufficient_balance!(amount, available, metadata,
1119
+ comparison: "#{found} < #{amount} in the named credits",
1120
+ covered_consumed: covered_consumed)
1121
+ end
1122
+
1123
+ needed = amount - covered_consumed
1124
+ if needed > [available, 0].max && !allow_negative_balance?
1125
+ reject_insufficient_balance!(amount, available, metadata,
1126
+ comparison: covered_consumed.zero? ? "#{available} < #{amount}" : "#{available} < #{amount} - #{covered_consumed} from held credits",
1127
+ covered_consumed: covered_consumed)
1128
+ end
1129
+
1130
+ buckets
1131
+ end
1132
+
1133
+ # One locked statement, like the buckets an ordinary debit walks: each
1134
+ # named credit with its remaining value and whether an active hold covers
1135
+ # it, all at this instant and as the rows are now. The expired ones are
1136
+ # left out: their remaining value is what they held, not what they can
1137
+ # still give.
1138
+ def locked_named_buckets(credits)
1139
+ at = evaluation_time
1140
+ rows = transactions.where(id: credits.map(&:id))
1141
+ .select(
1142
+ transaction_class.arel_table[Arel.star],
1143
+ Arel.sql("#{remaining_amount_sql} AS allocatable_amount"),
1144
+ Arel.sql("CASE WHEN #{self.class.coverage_sql(at: at, lock: current_reads?)} THEN 1 ELSE 0 END AS covered_bucket")
447
1145
  )
1146
+ .lock("FOR UPDATE")
1147
+ .index_by(&:id)
448
1148
 
449
- remaining_to_allocate -= allocation_amount
450
- break if remaining_to_allocate <= 0
1149
+ credits.filter_map do |credit|
1150
+ row = rows[credit.id] || raise(ArgumentError, missing_credit_message(credit))
1151
+ raise ArgumentError, "Transaction #{credit.id} is a debit: only credits can fund a targeted debit" unless row.credit?
1152
+
1153
+ row unless row.expired?(at: at)
451
1154
  end
1155
+ end
452
1156
 
453
- remaining_to_allocate
1157
+ # Read from the flag `locked_named_buckets` selects with each row.
1158
+ def covered_bucket?(bucket)
1159
+ bucket.read_attribute(:covered_bucket).to_i == 1
1160
+ end
1161
+
1162
+ def missing_credit_message(credit)
1163
+ if transaction_class.where(id: credit.id).exists?
1164
+ "Transaction #{credit.id} belongs to another wallet"
1165
+ else
1166
+ "Transaction #{credit.id} no longer exists"
1167
+ end
1168
+ end
1169
+
1170
+ # =========================================
1171
+ # Hold Implementation
1172
+ # =========================================
1173
+
1174
+ # Writes a hold and reports it: one cache refresh, `hold_created`, then
1175
+ # the threshold crossings from the balances before and after. A hold can
1176
+ # lower the available balance (a credit hold, a wallet hold, a
1177
+ # reservation) or leave it where it was (another reason on value already
1178
+ # held), and only a real crossing is reported.
1179
+ def place_hold!(previous_balance: balance, **attributes)
1180
+ hold = hold_class.create!(wallet: self, **attributes)
1181
+ new_balance = refresh_cached_balance!
1182
+
1183
+ dispatch_hold_created!(hold, previous_balance: previous_balance, new_balance: new_balance)
1184
+ dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
1185
+
1186
+ hold
1187
+ end
1188
+
1189
+ # The wallet side of `Hold#release!`. The wallet is locked first, like in
1190
+ # every command, and only then is the hold read, so concurrent releases
1191
+ # (or a release racing a capture) queue on the wallet and every one after
1192
+ # the first finds the hold already ended. Releasing gives value back and
1193
+ # can never cross a low-balance threshold, so none is evaluated.
1194
+ def release_hold!(hold, reason:, metadata:)
1195
+ metadata = normalize_metadata(metadata)
1196
+
1197
+ ending_hold(hold) do
1198
+ mutating_at(Time.current) do
1199
+ # A locking read: the hold as it is now, whatever an earlier read in
1200
+ # this transaction saw.
1201
+ hold.lock!
1202
+
1203
+ if hold.active?(at: evaluation_time)
1204
+ previous_balance = balance
1205
+ hold.update!(released_at: evaluation_time, release_reason: reason, release_metadata: metadata)
1206
+ new_balance = refresh_cached_balance!
1207
+
1208
+ dispatch_hold_released!(hold, previous_balance: previous_balance, new_balance: new_balance)
1209
+ end
1210
+
1211
+ hold
1212
+ end
1213
+ end
1214
+ end
1215
+
1216
+ # Runs a command that ends `hold`, a release or a capture, under the
1217
+ # wallet lock and in its own savepoint. Should it fail, the caller's copy
1218
+ # of the hold is read back from the ledger: Active Record leaves a record
1219
+ # holding the values a rolled-back write gave it, so the copy would
1220
+ # otherwise go on reading released (or captured, linked to a debit that
1221
+ # never existed), and a retry that checks `active?` or `captured?` first
1222
+ # would never run. If even that read fails, the original error is the
1223
+ # one worth raising.
1224
+ #
1225
+ # It is a current read, locking where plain reads may answer from an
1226
+ # older snapshot (MySQL, see `current_reads_need_locks?`). A plain one
1227
+ # there would undo what the command's own locking read had just shown:
1228
+ # a capture refused because another connection captured first would
1229
+ # leave the copy reading uncaptured again. It locks the hold's row by
1230
+ # its primary key, no range, until the caller's transaction ends.
1231
+ def ending_hold(hold, &block)
1232
+ with_lock(requires_new: true, &block)
1233
+ rescue => error
1234
+ begin
1235
+ hold.reload(lock: self.class.current_reads_need_locks?)
1236
+ rescue ActiveRecord::ActiveRecordError
1237
+ nil
1238
+ end
1239
+ raise error
1240
+ end
1241
+
1242
+ # The wallet side of `Hold#capture!`: one composite step (D9). The wallet
1243
+ # is locked first and the reservation read as it is now, exactly like a
1244
+ # release, so a capture racing a release or another capture finds the
1245
+ # reservation already ended and creates nothing.
1246
+ #
1247
+ # The reservation becomes the debit: the amount it set aside funds its
1248
+ # own capture, while every other reservation stays fully in force. The
1249
+ # debit spends eligible credits only, like any ordinary debit, so value
1250
+ # a hold covers is never captured. The balances the debit's snapshot and
1251
+ # the callbacks report are the ones before and after the whole step,
1252
+ # never the instant in between where the reservation is gone and the
1253
+ # debit not yet posted: capturing a fully reserved wallet reads 0 -> 0.
1254
+ def capture_reservation!(reservation, amount, category:, metadata:)
1255
+ metadata = normalize_metadata(metadata)
1256
+
1257
+ ending_hold(reservation) do
1258
+ mutating_at(Time.current) do
1259
+ # A locking read: the reservation as it is now, whatever an earlier
1260
+ # read in this transaction saw.
1261
+ reservation.lock!
1262
+ ensure_capturable!(reservation)
1263
+ amount = capture_amount(reservation, amount)
1264
+
1265
+ previous_balance = balance
1266
+ available_for_capture = previous_balance + reservation.amount
1267
+
1268
+ if amount > available_for_capture && !allow_negative_balance?
1269
+ reject_insufficient_balance!(amount, previous_balance, metadata,
1270
+ comparison: "#{available_for_capture} < #{amount} to capture reservation #{reservation.id}",
1271
+ available_for_capture: available_for_capture)
1272
+ end
1273
+
1274
+ # With overdrafts on, a capture beyond the eligible credits leaves
1275
+ # exactly the rest unbacked; one within them leaves no debt at all,
1276
+ # however underfunded the other reservations end up.
1277
+ debit, _allocations = post_debit!(amount, metadata: metadata, category: category, transfer: nil, allow_unbacked: allow_negative_balance?)
1278
+ reservation.update!(released_at: evaluation_time, release_reason: "captured", capture_transaction: debit)
1279
+ new_balance = refresh_cached_balance!
1280
+
1281
+ report_debit!(debit, amount: amount, category: category, metadata: metadata, previous_balance: previous_balance, new_balance: new_balance)
1282
+ dispatch_hold_released!(reservation, previous_balance: previous_balance, new_balance: new_balance, transaction: debit)
1283
+ dispatch_balance_threshold_callbacks!(previous_balance, new_balance)
1284
+
1285
+ debit
1286
+ end
1287
+ end
1288
+ end
1289
+
1290
+ # What a capture takes: the reserved amount unless told otherwise, and
1291
+ # never more. A positive whole number, parsed like any debit's. Read only
1292
+ # once the reservation is known to be capturable, so that retrying a
1293
+ # finished capture raises Wallets::InvalidHold whatever amount it
1294
+ # repeats, never an ArgumentError about the amount.
1295
+ def capture_amount(reservation, amount)
1296
+ return reservation.amount if amount.nil?
1297
+
1298
+ amount = normalize_positive_amount!(amount)
1299
+ if amount > reservation.amount
1300
+ raise ArgumentError, "Cannot capture #{amount}: reservation #{reservation.id} sets aside #{reservation.amount}"
1301
+ end
1302
+
1303
+ amount
1304
+ end
1305
+
1306
+ # Only an active reservation can be captured, and only once. Read at the
1307
+ # command's instant: a reservation is gone from its deadline on.
1308
+ def ensure_capturable!(reservation)
1309
+ unless reservation.reservation?
1310
+ raise InvalidHold, "Hold #{reservation.id} holds credit value: only a reservation can be captured"
1311
+ end
1312
+ if reservation.captured?
1313
+ raise InvalidHold, "Reservation #{reservation.id} was already captured (transaction #{reservation.capture_transaction_id})"
1314
+ end
1315
+ if reservation.released?
1316
+ raise InvalidHold, "Reservation #{reservation.id} was released and can no longer be captured"
1317
+ end
1318
+ unless reservation.active?(at: evaluation_time)
1319
+ raise InvalidHold, "Reservation #{reservation.id} lapsed at #{reservation.releases_at.iso8601} and can no longer be captured"
1320
+ end
1321
+ end
1322
+
1323
+ # A credit can be held when it is a saved credit of this very wallet and
1324
+ # ledger, still unexpired at the command's instant, with value left to
1325
+ # hold. Read from the database under the wallet lock, never from the
1326
+ # caller's copy, so a debit that just spent it is seen.
1327
+ #
1328
+ # The same line as `from:` draws: anything that is not a credit of this
1329
+ # wallet is a wrong argument (ArgumentError); a credit of this wallet
1330
+ # that the ledger's state rules out, expired or spent at this instant,
1331
+ # is a hold that cannot be placed now (InvalidHold).
1332
+ def ensure_holdable!(credit)
1333
+ unless credit.instance_of?(transaction_class)
1334
+ raise ArgumentError, "A hold covers a #{transaction_class.name}, not #{credit.class.name}"
1335
+ end
1336
+ raise ArgumentError, "Only a saved credit can be held" unless credit.persisted?
1337
+
1338
+ row = current_read(transaction_class.where(id: credit.id), current_reads?)
1339
+ .pick(:wallet_id, :amount, :expires_at, Arel.sql(remaining_amount_sql))
1340
+ raise ArgumentError, "Transaction #{credit.id} no longer exists" if row.nil?
1341
+
1342
+ wallet_id, amount, expires_at, remaining = row
1343
+ raise ArgumentError, "Transaction #{credit.id} belongs to another wallet" unless wallet_id == id
1344
+ raise ArgumentError, "Transaction #{credit.id} is a debit: only credits can be held" unless amount.positive?
1345
+ raise InvalidHold, "Credit #{credit.id} has expired" if expires_at.present? && expires_at <= evaluation_time
1346
+ raise InvalidHold, "Credit #{credit.id} has no remaining value to hold" unless remaining.to_i.positive?
1347
+ end
1348
+
1349
+ # The hold a credit arrives under, from `hold:` or `available_at:`,
1350
+ # checked before anything is written. `hold:` is a category, or a Hash
1351
+ # with the same keywords `hold` takes.
1352
+ def arrival_hold_attributes(hold, available_at)
1353
+ return if hold.nil? && available_at.nil?
1354
+ raise ArgumentError, "Pass hold: or available_at:, not both" unless hold.nil? || available_at.nil?
1355
+
1356
+ ensure_holds_enabled!
1357
+ return {category: :pending, releases_at: normalize_deadline(available_at, name: "available_at:"), metadata: {}} unless available_at.nil?
1358
+
1359
+ options =
1360
+ case hold
1361
+ when Symbol, String then {category: hold}
1362
+ when Hash then hold.symbolize_keys
1363
+ else raise ArgumentError, "hold: takes a category or a Hash of category:, until: and metadata:, not #{hold.inspect}"
1364
+ end
1365
+
1366
+ unknown = options.keys - ARRIVAL_HOLD_OPTIONS
1367
+ raise ArgumentError, "Unknown hold: options: #{unknown.join(", ")}" if unknown.any?
1368
+ raise ArgumentError, "hold: needs a category" unless options.key?(:category)
1369
+
1370
+ {
1371
+ category: options[:category],
1372
+ releases_at: normalize_deadline(options[:until]),
1373
+ metadata: normalize_metadata(options.fetch(:metadata, {}))
1374
+ }
1375
+ end
1376
+
1377
+ # The instant a deadline names, read the way Active Record reads it into
1378
+ # a datetime column (strings and dates in Time.zone), so the instant a
1379
+ # command checks is the instant it stores. Anything that is not a time is
1380
+ # refused: stored, it would quietly mean "no deadline".
1381
+ def normalize_deadline(value, name: "until:")
1382
+ return if value.nil?
1383
+
1384
+ deadline = begin
1385
+ value.in_time_zone if value.respond_to?(:in_time_zone)
1386
+ rescue ArgumentError
1387
+ nil
1388
+ end
1389
+
1390
+ deadline || raise(ArgumentError, "#{name} must be a time, not #{value.inspect}")
1391
+ end
1392
+
1393
+ # A deadline already reached would make a hold that is over before it
1394
+ # starts: a caller error to surface, never a row to store.
1395
+ def ensure_deadline_ahead!(releases_at)
1396
+ return if releases_at.nil? || releases_at > evaluation_time
1397
+
1398
+ raise InvalidHold, "A hold's deadline must be in the future (until: #{releases_at.iso8601})"
1399
+ end
1400
+
1401
+ # A ledger that did not opt in refuses every hold command here, before a
1402
+ # single query runs.
1403
+ def ensure_holds_enabled!
1404
+ self.class.hold_class
1405
+ end
1406
+
1407
+ def dispatch_hold_created!(hold, previous_balance:, new_balance:)
1408
+ dispatch_callback(:hold_created,
1409
+ wallet: self,
1410
+ hold: hold,
1411
+ amount: hold.amount,
1412
+ category: hold.category,
1413
+ previous_balance: previous_balance,
1414
+ new_balance: new_balance,
1415
+ metadata: hold.metadata)
1416
+ end
1417
+
1418
+ # The same shape as `hold_created`, with the release metadata, and the
1419
+ # debit a capture posted as the `transaction`.
1420
+ def dispatch_hold_released!(hold, previous_balance:, new_balance:, transaction: nil)
1421
+ dispatch_callback(:hold_released,
1422
+ wallet: self,
1423
+ hold: hold,
1424
+ amount: hold.amount,
1425
+ category: hold.category,
1426
+ transaction: transaction,
1427
+ previous_balance: previous_balance,
1428
+ new_balance: new_balance,
1429
+ metadata: hold.release_metadata)
454
1430
  end
455
1431
 
456
1432
  # =========================================
@@ -490,21 +1466,23 @@ module Wallets
490
1466
  value.to_s.strip.downcase
491
1467
  end
492
1468
 
493
- def build_transfer_inbound_credit_specs(transfer:, outbound_transaction:, amount:, expiration_policy:, expires_at:)
1469
+ def build_transfer_inbound_credit_specs(transfer:, allocations:, amount:, expiration_policy:, expires_at:)
494
1470
  case expiration_policy
495
1471
  when "none"
496
- [{ amount: amount, expires_at: nil }]
1472
+ [{amount: amount, expires_at: nil}]
497
1473
  when "fixed"
498
- [{ amount: amount, expires_at: expires_at }]
1474
+ [{amount: amount, expires_at: expires_at}]
499
1475
  when "preserve"
500
- build_preserved_transfer_inbound_credit_specs(transfer, outbound_transaction, amount)
1476
+ build_preserved_transfer_inbound_credit_specs(transfer, allocations, amount)
501
1477
  else
502
1478
  raise ArgumentError, "Unsupported transfer expiration policy: #{expiration_policy}"
503
1479
  end
504
1480
  end
505
1481
 
506
- def build_preserved_transfer_inbound_credit_specs(transfer, outbound_transaction, amount)
507
- allocations = outbound_transaction.outgoing_allocations.includes(:source_transaction).order(:id).to_a
1482
+ # Built from the allocations as the allocator made them, each holding the
1483
+ # source row it locked. Never re-read: a plain read could answer from a
1484
+ # stale snapshot that has never seen a bucket committed after it.
1485
+ def build_preserved_transfer_inbound_credit_specs(transfer, allocations, amount)
508
1486
  grouped_specs = []
509
1487
 
510
1488
  allocations.each do |allocation|
@@ -513,7 +1491,7 @@ module Wallets
513
1491
  if grouped_specs.last && grouped_specs.last[:expires_at] == expires_at
514
1492
  grouped_specs.last[:amount] += allocation.amount
515
1493
  else
516
- grouped_specs << { amount: allocation.amount, expires_at: expires_at }
1494
+ grouped_specs << {amount: allocation.amount, expires_at: expires_at}
517
1495
  end
518
1496
  end
519
1497
 
@@ -528,38 +1506,216 @@ module Wallets
528
1506
  # =========================================
529
1507
  # Balance Calculation
530
1508
  # =========================================
1509
+ #
1510
+ # Every term reads the pinned mutation context (see `evaluating_at`): the
1511
+ # instant to evaluate at and whether to read current rows. Outside a
1512
+ # mutation that is Time.current and plain reads.
1513
+
1514
+ # The terms of the balance at one instant: P (the remaining value of
1515
+ # unexpired credits), C (the part of P an active hold covers), R (active
1516
+ # reservations) and N (the debt no credit backs).
1517
+ BalanceComponents = Struct.new(:positive, :covered, :reserved, :unbacked) do
1518
+ def available
1519
+ positive - covered - reserved - unbacked
1520
+ end
531
1521
 
1522
+ def held
1523
+ covered + reserved
1524
+ end
1525
+
1526
+ def total
1527
+ positive - unbacked
1528
+ end
1529
+ end
1530
+ private_constant :BalanceComponents
1531
+
1532
+ # All four terms from ONE statement over this wallet's transactions, with
1533
+ # R as a scalar subquery in the same statement. Separate statements would
1534
+ # each see their own snapshot under read committed (PostgreSQL's
1535
+ # default): a credit committed between two of them could count in P yet
1536
+ # miss C. One statement is one snapshot, so available, held and total
1537
+ # always agree. Ledgers that keep holds only: the others never query one.
1538
+ def balance_components
1539
+ at = evaluation_time
1540
+ lock = current_reads?
1541
+ credits = transaction_class.arel_table
1542
+ unexpired_credit = credits[:amount].gt(0).and(credits[:expires_at].eq(nil).or(credits[:expires_at].gt(at))).to_sql(transaction_class)
1543
+ debit = credits[:amount].lt(0).to_sql(transaction_class)
1544
+ remaining = remaining_amount_sql(lock: lock)
1545
+ reservations = current_read(wallet_holds.reservations.active(at: at), lock).select(hold_class.arel_table[:amount].sum)
1546
+
1547
+ terms = [
1548
+ "COALESCE(SUM(CASE WHEN #{unexpired_credit} THEN #{remaining} ELSE 0 END), 0)",
1549
+ "COALESCE(SUM(CASE WHEN #{unexpired_credit} AND #{self.class.coverage_sql(at: at, lock: lock)} THEN #{remaining} ELSE 0 END), 0)",
1550
+ "(#{reservations.to_sql})",
1551
+ "COALESCE(SUM(CASE WHEN #{debit} THEN #{unbacked_amount_sql(lock: lock)} ELSE 0 END), 0)"
1552
+ ]
1553
+
1554
+ # An unsaved wallet has no ledger to read: every term is zero.
1555
+ values = current_read(transactions, lock).pick(*terms.map { |sql| Arel.sql(sql) }) || []
1556
+ BalanceComponents.new(*Array.new(terms.size) { |index| values[index].to_i })
1557
+ end
1558
+
1559
+ # P: the remaining value of unexpired credits, held or not. Kept with its
1560
+ # 0.3 signature and meaning (D12): embedders override it and call it.
532
1561
  def positive_remaining_balance
533
- txn_table = transaction_class.table_name
534
- alloc_table = allocation_class.table_name
1562
+ current_read(transactions.credits.not_expired(at: evaluation_time), current_reads?)
1563
+ .sum(remaining_amount_sql)
1564
+ .to_i
1565
+ end
535
1566
 
536
- transactions
537
- .where("amount > 0")
538
- .where("expires_at IS NULL OR expires_at > ?", Time.current)
539
- .sum("amount - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE source_transaction_id = #{txn_table}.id)")
1567
+ # N: the part of debits no credit backs. Kept with its 0.3 signature and
1568
+ # meaning (D12): usage_credits passes its own scope of debits.
1569
+ def unbacked_negative_balance(transaction_scope = transactions.debits)
1570
+ current_read(transaction_scope, current_reads?)
1571
+ .sum(unbacked_amount_sql)
540
1572
  .to_i
541
1573
  end
542
1574
 
543
- def unbacked_negative_balance
544
- txn_table = transaction_class.table_name
545
- alloc_table = allocation_class.table_name
1575
+ # C and R, for telling a refused caller what holds keep out of reach.
1576
+ def held_and_reserved
1577
+ return [0, 0] unless holds_enabled?
1578
+
1579
+ components = balance_components
1580
+ [components.covered, components.reserved]
1581
+ end
1582
+
1583
+ # The buckets a debit may consume now: unexpired, and not covered by an
1584
+ # active hold (none at all while a wallet hold is active). The one
1585
+ # eligibility definition behind both affordability (through C) and
1586
+ # allocation, so the two can never disagree.
1587
+ def eligible_credits
1588
+ credits = transactions.credits.not_expired(at: evaluation_time)
1589
+ return credits unless holds_enabled?
1590
+
1591
+ credits.where("NOT #{self.class.coverage_sql(at: evaluation_time, lock: current_reads?)}")
1592
+ end
546
1593
 
547
- transactions
548
- .where("amount < 0")
549
- .sum("ABS(amount) - (SELECT COALESCE(SUM(amount), 0) FROM #{alloc_table} WHERE transaction_id = #{txn_table}.id)")
1594
+ # P - C: what FEFO allocation can take from eligible buckets right now.
1595
+ def eligible_remaining_balance
1596
+ current_read(eligible_credits, current_reads?)
1597
+ .sum(remaining_amount_sql)
550
1598
  .to_i
551
1599
  end
552
1600
 
1601
+ # A credit's remaining value: its amount minus what debits allocated from
1602
+ # it.
1603
+ def remaining_amount_sql(lock: current_reads?)
1604
+ connection = transaction_class.connection
1605
+ txn_table = connection.quote_table_name(transaction_class.table_name)
1606
+ alloc_table = connection.quote_table_name(allocation_class.table_name)
1607
+ amount_column = connection.quote_column_name("amount")
1608
+ id_column = connection.quote_column_name("id")
1609
+ source_transaction_id_column = connection.quote_column_name("source_transaction_id")
1610
+
1611
+ "#{txn_table}.#{amount_column} - (SELECT COALESCE(SUM(#{amount_column}), 0) FROM #{alloc_table} WHERE #{source_transaction_id_column} = #{txn_table}.#{id_column}#{locking_clause(lock)})"
1612
+ end
1613
+
1614
+ # A debit's unbacked value: its size minus what credits allocated to it.
1615
+ def unbacked_amount_sql(lock: current_reads?)
1616
+ connection = transaction_class.connection
1617
+ txn_table = connection.quote_table_name(transaction_class.table_name)
1618
+ alloc_table = connection.quote_table_name(allocation_class.table_name)
1619
+ amount_column = connection.quote_column_name("amount")
1620
+ id_column = connection.quote_column_name("id")
1621
+ transaction_id_column = connection.quote_column_name("transaction_id")
1622
+
1623
+ "ABS(#{txn_table}.#{amount_column}) - (SELECT COALESCE(SUM(#{amount_column}), 0) FROM #{alloc_table} WHERE #{transaction_id_column} = #{txn_table}.#{id_column}#{locking_clause(lock)})"
1624
+ end
1625
+
1626
+ def wallet_holds
1627
+ hold_class.where(wallet_id: id)
1628
+ end
1629
+
1630
+ # Writes the available balance to the cached column and returns it. Like
1631
+ # any cache it can go stale through time alone (expiry, lapsed deadlines),
1632
+ # so it is never authoritative for accounting.
1633
+ #
1634
+ # Every command increments `ledger_revision`, even when the balance did
1635
+ # not move (a second reason on a held credit, a capture of a whole
1636
+ # reservation): every committed change to the ledger must change the
1637
+ # wallet row, which is how `stale_snapshot?` knows a snapshot is current
1638
+ # (and how PostgreSQL's repeatable read detects a concurrent update on
1639
+ # `lock!`). `updated_at` then follows ordinary Rails rules and decides
1640
+ # nothing. A wallet table without the column (see `ledger_revision?`)
1641
+ # gets the timestamp marker instead, written with `update_column` when
1642
+ # the balance did not move, because `touch` writes nothing under
1643
+ # `no_touching`.
1644
+ #
1645
+ # `update!` rather than assigning and then saving: if the command's
1646
+ # savepoint rolls back afterwards, Active Record leaves the wallet with no
1647
+ # pending changes, so its next command can lock it. It is clean, not
1648
+ # restored: it keeps the rolled-back values in memory until that lock
1649
+ # reloads the row. Assigning first would leave them pending instead, and
1650
+ # no later command could lock the wallet.
553
1651
  def refresh_cached_balance!
554
- write_attribute(:balance, current_balance)
555
- save!
1652
+ available = current_balance
1653
+
1654
+ refreshing_cached_balance do
1655
+ if ledger_revision?
1656
+ update!(balance: available, ledger_revision: ledger_revision + 1)
1657
+ elsif available == read_attribute(:balance)
1658
+ update_column(:updated_at, next_row_marker)
1659
+ else
1660
+ update!(balance: available, updated_at: next_row_marker)
1661
+ end
1662
+ end
1663
+
1664
+ available
1665
+ end
1666
+
1667
+ # The timestamp marker of a wallet table without `ledger_revision`: the
1668
+ # command's instant, or one microsecond past the current `updated_at`
1669
+ # when the clock has not moved past it (two commands within a
1670
+ # microsecond, a frozen clock), so no two commits leave the same row
1671
+ # behind. Assumes the column keeps microseconds, as `t.timestamps` does
1672
+ # from Rails 7 on (datetime(6)).
1673
+ def next_row_marker
1674
+ now = evaluation_time
1675
+ now = now.change(usec: now.usec) # the column's precision
1676
+ return now if updated_at.nil?
1677
+
1678
+ [now, updated_at + MARKER_STEP].max
1679
+ end
1680
+
1681
+ def refreshing_cached_balance
1682
+ @refreshing_cached_balance = true
1683
+ yield
1684
+ ensure
1685
+ @refreshing_cached_balance = false
1686
+ end
1687
+
1688
+ def remember_own_write
1689
+ return if @refreshing_cached_balance || !self.class.current_reads_need_locks?
1690
+
1691
+ remember_stale_snapshot!
556
1692
  end
557
1693
 
558
1694
  # =========================================
559
1695
  # Threshold Callbacks
560
1696
  # =========================================
561
1697
 
562
- def dispatch_insufficient_balance!(amount, previous_balance, metadata)
1698
+ # A refused funding attempt reports once, immediately (its transaction is
1699
+ # about to roll back), and names what holds keep out of reach, so the app
1700
+ # can say "20 available, 30 held, 50 reserved" rather than just "20".
1701
+ #
1702
+ # `available` is always the wallet's available balance, which nothing
1703
+ # moved. A command funded by something else says what it compared in
1704
+ # `comparison` (a capture counts its own reservation back in) and adds
1705
+ # the figures behind it to the metadata (`diagnostics`).
1706
+ def reject_insufficient_balance!(amount, available, metadata, comparison: "#{available} < #{amount}", **diagnostics)
1707
+ held, reserved = held_and_reserved
1708
+ dispatch_insufficient_balance!(amount, available, metadata, held: held, reserved: reserved, **diagnostics)
1709
+
1710
+ details = {held: held, reserved: reserved}.reject { |_name, value| value.zero? }.map { |name, value| "#{value} #{name}" }
1711
+ raise InsufficientBalance, "Insufficient balance (#{comparison}#{"; #{details.join(", ")}" if details.any?})"
1712
+ end
1713
+
1714
+ # `held:` is the credit value holds cover and `reserved:` the active
1715
+ # reservations, so available + held + reserved is the total balance.
1716
+ def dispatch_insufficient_balance!(amount, previous_balance, metadata, held: nil, reserved: nil, **diagnostics)
1717
+ held, reserved = held_and_reserved if held.nil? || reserved.nil?
1718
+
563
1719
  dispatch_callback(:insufficient,
564
1720
  wallet: self,
565
1721
  amount: amount,
@@ -567,35 +1723,42 @@ module Wallets
567
1723
  new_balance: previous_balance,
568
1724
  metadata: metadata.merge(
569
1725
  available: previous_balance,
570
- required: amount
571
- )
572
- )
1726
+ required: amount,
1727
+ held: held,
1728
+ reserved: reserved,
1729
+ **diagnostics
1730
+ ))
573
1731
  end
574
1732
 
575
- def dispatch_balance_threshold_callbacks!(previous_balance)
576
- if !was_low_balance?(previous_balance) && low_balance?
1733
+ def dispatch_balance_threshold_callbacks!(previous_balance, new_balance = balance)
1734
+ if !was_low_balance?(previous_balance) && low_balance?(new_balance)
577
1735
  dispatch_callback(:low_balance,
578
1736
  wallet: self,
579
1737
  threshold: config.low_balance_threshold,
580
1738
  previous_balance: previous_balance,
581
- new_balance: balance
582
- )
1739
+ new_balance: new_balance)
583
1740
  end
584
1741
 
585
- if previous_balance.positive? && balance.zero?
1742
+ # `:balance_depleted` fires when the wallet crosses from a positive
1743
+ # balance to ZERO OR LOWER. We use `<= 0` rather than `== 0` because
1744
+ # available balance can skip exact zero: an overdraft debit takes it
1745
+ # from +100 to -50, and holds or expiry can take it below zero even
1746
+ # with `allow_negative_balance` off. Either way the callback is
1747
+ # one-shot per crossing — going from -50 to -80 doesn't re-fire
1748
+ # because `previous_balance` was already non-positive.
1749
+ if previous_balance.positive? && !new_balance.positive?
586
1750
  dispatch_callback(:depleted,
587
1751
  wallet: self,
588
1752
  previous_balance: previous_balance,
589
- new_balance: 0
590
- )
1753
+ new_balance: new_balance)
591
1754
  end
592
1755
  end
593
1756
 
594
- def low_balance?
1757
+ def low_balance?(available = balance)
595
1758
  threshold = config.low_balance_threshold
596
1759
  return false if threshold.nil?
597
1760
 
598
- balance <= threshold
1761
+ available <= threshold
599
1762
  end
600
1763
 
601
1764
  def was_low_balance?(previous_balance)
@@ -613,17 +1776,23 @@ module Wallets
613
1776
  # Validation Helpers
614
1777
  # =========================================
615
1778
 
616
- def validate_expiration!(expires_at)
1779
+ def validate_expiration!(expires_at, at: Time.current)
617
1780
  return if expires_at.nil?
618
1781
  raise ArgumentError, "Expiration date must respond to to_datetime" unless expires_at.respond_to?(:to_datetime)
619
- raise ArgumentError, "Expiration date must be in the future" if expires_at <= Time.current
1782
+
1783
+ expiration = begin
1784
+ expires_at.to_datetime
1785
+ rescue
1786
+ raise ArgumentError, "Expiration date must be a valid date or time"
1787
+ end
1788
+
1789
+ raise ArgumentError, "Expiration date must be in the future" if expiration <= at
620
1790
  end
621
1791
 
622
1792
  def normalize_positive_amount!(amount)
623
1793
  raise ArgumentError, "Amount is required" if amount.nil?
624
- raise ArgumentError, "Amount must be a whole number" unless amount == amount.to_i
625
1794
 
626
- amount = amount.to_i
1795
+ amount = Wallets::WholeNumber.parse(amount, name: "Amount")
627
1796
  raise ArgumentError, "Amount must be positive" unless amount.positive?
628
1797
 
629
1798
  amount
@@ -633,6 +1802,15 @@ module Wallets
633
1802
  metadata.respond_to?(:to_h) ? metadata.to_h : {}
634
1803
  end
635
1804
 
1805
+ def validate_additional_transaction_attributes!(attributes)
1806
+ allowed = self.class.additional_transaction_attribute_names.map(&:to_sym)
1807
+ requested = attributes.keys.map(&:to_sym)
1808
+ unsupported = (requested - allowed) | (requested & CORE_TRANSACTION_ATTRIBUTE_NAMES)
1809
+ return attributes if unsupported.empty?
1810
+
1811
+ raise ArgumentError, "Unsupported transaction attributes: #{unsupported.join(", ")}"
1812
+ end
1813
+
636
1814
  def lock_wallet_pair!(other_wallet)
637
1815
  first, second = [self, other_wallet].sort_by(&:id)
638
1816
  first.lock!
@@ -640,15 +1818,32 @@ module Wallets
640
1818
  end
641
1819
 
642
1820
  def normalize_asset_code!
643
- self.asset_code = asset_code.to_s.strip.downcase.presence
1821
+ self.asset_code = Wallets.normalize_asset_code(asset_code).presence
644
1822
  end
1823
+ end
645
1824
 
646
- def sync_metadata_cache
647
- if @indifferent_metadata
648
- write_attribute(:metadata, @indifferent_metadata.to_h)
649
- elsif read_attribute(:metadata).nil?
650
- write_attribute(:metadata, {})
651
- end
1825
+ # The concrete standalone ledger model (table: wallets_wallets via the
1826
+ # default config). Embedders subclass WalletBase instead.
1827
+ #
1828
+ # It keeps holds. Their associations live here, never on WalletBase, so a
1829
+ # ledger that did not opt in never touches a holds table.
1830
+ class Wallet < WalletBase
1831
+ self.hold_class_name = "Wallets::Hold"
1832
+
1833
+ has_many :holds, class_name: "Wallets::Hold", foreign_key: :wallet_id, inverse_of: :wallet
1834
+
1835
+ # Holds reference this wallet's transactions (the credit a hold covers,
1836
+ # the debit a reservation was captured into), so they must be destroyed
1837
+ # BEFORE the transaction and transfer dependencies WalletBase declares. A
1838
+ # `dependent: :destroy` here would be appended after those inherited
1839
+ # callbacks; prepending puts it first. It runs inside destroy's
1840
+ # transaction, so a hold that refuses to go aborts the whole destroy.
1841
+ before_destroy :destroy_holds, prepend: true
1842
+
1843
+ private
1844
+
1845
+ def destroy_holds
1846
+ holds.destroy_all
652
1847
  end
653
1848
  end
654
1849
  end