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.
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  > [!TIP]
6
6
  > **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=wallets)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks. Go [check it out](https://railsfast.com/?ref=wallets)!
7
7
 
8
- Allow your users to have wallets with money-like balances for value holding and transfering. `wallets` gives any Rails model money-like wallets backed by an append-only transaction ledger. You can use these wallets to store and transfer value in any "currency" (points inside your app, call minutes, in-game resources, in-app assets, etc.)
8
+ Allow your users to have wallets with money-like balances for value holding and transferring. `wallets` gives any Rails model money-like wallets backed by an append-oriented transaction ledger. You can use these wallets to store and transfer value in any "currency" (points inside your app, call minutes, in-game resources, in-app assets, etc.)
9
9
 
10
10
  ![wallets](wallets.webp)
11
11
 
@@ -17,8 +17,9 @@ Use it for:
17
17
  - **Multi-currency balances**: EUR, USD, GBP wallets per user
18
18
  - **Game resources**: Wood, stone, gems, gold, energy; any virtual economy
19
19
  - **Telecom / SIM data plans**: "This plan gives you 10 GB per month, transfer unused data to friends"
20
+ - **Pending, vesting & retained value**: A referral bonus that unlocks in 14 days, seller earnings held through the return window, a booking price set aside until delivery, a reward retained until a review clears
20
21
 
21
- At its core, `wallets` provides your users with: a wallet with balance, a log of transactions, expirable balances, and transfers between users.
22
+ At its core, `wallets` provides your users with: a wallet with balance, a log of transactions, expirable balances, pending balances, and transfers between users.
22
23
 
23
24
  For example, imagine you're building a SIM card app with data plans. At the beginning of each month, you give your users expirable data and call minutes:
24
25
 
@@ -50,8 +51,9 @@ user.wallet(:mb).balance # => 6656 MB remaining
50
51
  | Feature | What it does |
51
52
  |---------|--------------|
52
53
  | **Multi-asset** | One wallet per asset: `user.wallet(:usd)`, `user.wallet(:gems)` |
53
- | **Append-only ledger** | Every balance change is a transaction: no edits, only new entries |
54
- | **FIFO allocation** | Debits consume oldest credits first (important for expiring balances) |
54
+ | **Append-oriented ledger API** | Every movement of value made through the wallet API creates a transaction row. Holds, reservations and their releases change what is available through hold rows instead, so the available balance over time is rebuilt from both |
55
+ | **Expiration-aware allocation** | Debits consume soonest-expiring credits first, then the oldest bucket |
56
+ | **Holds and reservations** | Keep value pending or set aside: `balance` is what can be spent, `held_balance` what is held |
55
57
  | **Linked transfers** | Both sides of a transfer are recorded and queryable |
56
58
  | **Row-level locking** | Prevents race conditions and double-spending |
57
59
  | **Balance snapshots** | Each transaction records before/after balance for reconciliation |
@@ -65,6 +67,8 @@ Add the gem to your Gemfile:
65
67
  gem "wallets"
66
68
  ```
67
69
 
70
+ Requires Ruby 3.2+ and Rails 7.2.3.1–8.x. Ruby 3.2 is the security floor because current patched versions of transitive Rails dependencies no longer support Ruby 3.1.
71
+
68
72
  Then run:
69
73
 
70
74
  ```bash
@@ -96,26 +100,53 @@ user.wallet(:gems).credit(5, category: :top_up)
96
100
 
97
101
  ## Example
98
102
 
103
+ Here is a more relatable money flow for a home-cleaning app:
104
+
99
105
  ```ruby
100
106
  class User < ApplicationRecord
101
- has_wallets default_asset: :eur
107
+ has_wallets default_asset: :eur_cents
102
108
  end
103
109
 
104
- buyer = User.find(1)
105
- seller = User.find(2)
110
+ class Platform < ApplicationRecord
111
+ has_wallets default_asset: :eur_cents
112
+ end
113
+
114
+ sara = User.find(1) # customer
115
+ lucia = User.find(2) # cleaner
116
+ platform = Platform.first
117
+
118
+ # Sara books a cleaning and tops up her wallet
119
+ sara.wallet.credit(10_000, category: :top_up, metadata: { source: "card" })
106
120
 
107
- buyer.wallet(:eur).credit(10_000, category: :top_up, metadata: { source: "card" })
108
- buyer.wallet(:eur).debit(2_500, category: :purchase, metadata: { order_id: 42 })
121
+ # Sara books a 2-hour cleaning for €60.00
122
+ booking_total = 6_000
123
+ platform_fee = 900
124
+ cleaner_payout = booking_total - platform_fee
109
125
 
110
- buyer.wallet(:eur).transfer_to(
111
- seller.wallet(:eur),
112
- 1_800,
113
- category: :marketplace_sale,
114
- metadata: { order_id: 42 }
126
+ # Sara pays the platform the gross booking price
127
+ charge = sara.wallet.transfer_to(
128
+ platform.wallet,
129
+ booking_total,
130
+ category: :cleaning_booking_charge,
131
+ metadata: { booking_id: 42, cleaner_id: lucia.id }
115
132
  )
116
133
 
117
- buyer.wallet(:wood).credit(50, category: :quest_reward)
118
- buyer.wallet(:wood).debit(10, category: :crafting)
134
+ # The app later pays Lucia the net amount and keeps its fee
135
+ payout = platform.wallet.transfer_to(
136
+ lucia.wallet,
137
+ cleaner_payout,
138
+ category: :cleaner_payout,
139
+ metadata: {
140
+ booking_id: 42,
141
+ customer_id: sara.id,
142
+ gross_transfer_id: charge.id,
143
+ platform_fee: platform_fee
144
+ }
145
+ )
146
+
147
+ # Query the linked transfer records later
148
+ charge.outbound_transaction
149
+ payout.inbound_transactions
119
150
  ```
120
151
 
121
152
  Amounts are always integers. For money, store the smallest unit like cents. For games, store whole resource units.
@@ -188,6 +219,23 @@ transfer.outbound_transaction
188
219
  transfer.inbound_transactions
189
220
  ```
190
221
 
222
+ `category:` on `transfer_to` describes the **business meaning of the transfer itself**. The ledger entries created underneath are still recorded as `transfer_out` and `transfer_in`, and the original transfer category is mirrored into transaction metadata as `transfer_category`.
223
+
224
+ That means you can query transfers directly for business flows:
225
+
226
+ ```ruby
227
+ user.wallet.outgoing_transfers.where(category: :peer_payment)
228
+ user.wallet.incoming_transfers.where(category: :peer_payment)
229
+ ```
230
+
231
+ Or query the ledger legs when you need transaction-level audit data:
232
+
233
+ ```ruby
234
+ user.wallet.history
235
+ .by_category(:transfer_in)
236
+ .where("metadata->>'transfer_category' = ?", "peer_payment")
237
+ ```
238
+
191
239
  Transfers require both wallets to use the same asset and the same wallet class. `:eur` can move to `:eur`; `:wood` can move to `:wood`; `Wallets::Wallet` cannot transfer directly to `UsageCredits::Wallet`.
192
240
 
193
241
  > [!NOTE]
@@ -201,6 +249,36 @@ Transfers require both wallets to use the same asset and the same wallet class.
201
249
  > sender.transfer_to(receiver, 100, expiration_policy: :fixed, expires_at: 30.days.from_now)
202
250
  > ```
203
251
 
252
+ ### Negative balances and overdraft
253
+
254
+ By default `wallets` rejects any debit or transfer that would push a wallet below zero. Some apps (ride-fare apps with rewards, family wallets with shared spend, telecom plans with bridging credit, etc.) want to allow a small overdraft so the user experience doesn't hard-stop on a low balance.
255
+
256
+ Flip one flag and it applies consistently to `wallet.debit`, `wallet.transfer_to`, `wallet.reserve` and `reservation.capture!`:
257
+
258
+ ```ruby
259
+ Wallets.configure do |config|
260
+ config.allow_negative_balance = true
261
+ end
262
+
263
+ passenger.wallet(:eur).balance # => 100 (1€)
264
+ passenger.wallet(:eur).transfer_to(driver.wallet(:eur), 300, category: :ride_fare)
265
+ passenger.wallet(:eur).reload.balance # => -200 (-2€)
266
+ driver.wallet(:eur).reload.balance # => 300 (3€)
267
+ ```
268
+
269
+ A few things to know:
270
+
271
+ - **Apps own the floor.** The flag is binary — it doesn't cap how negative a wallet can go. If you want a "5€ convenience overdraft", enforce that in your domain code before calling `transfer_to` (e.g. a `WalletPolicy.can_afford?(wallet:, amount:)` service that checks `wallet.balance + MAX_OVERDRAFT >= amount`).
272
+ - **`has_enough_balance?` stays strict.** It answers "does this wallet have enough on hand right now?" — overdraft is a deliberate choice the caller makes by attempting the debit/transfer, not by querying. So `wallet.has_enough_balance?(amount)` returns `false` even when the gem would happily complete an overdraft.
273
+ - **`:preserve` expiration falls back to `:none` when a transfer goes negative.** With `allow_negative_balance = true` and the default `:preserve` policy, transfers that exceed the source's positive buckets can't honestly "preserve" an expiration on the deficit portion (there is no source bucket). The transfer's `expiration_policy` is automatically downgraded to `:none` for that transfer; the receiver gets a single evergreen credit. Explicit `:fixed` (with `expires_at:`) and `:none` are honored as-is.
274
+ - **`:balance_depleted` fires on positive→non-positive crossings**, not on exact zero. A debit that takes a wallet from +100 to -50 in one shot still fires the callback. With `allow_negative_balance = false` a debit stops at exactly zero, but a hold over value that is also reserved can still take the available balance below it (see [Holds and reservations](#holds-and-reservations)), and the callback fires the same way.
275
+ - **`:low_balance_reached` is one-shot per crossing**, regardless of how deep the dip goes. Going from +200 to -100 fires once; the next debit from -100 to -200 does not re-fire because the wallet was already below threshold.
276
+ - **`:insufficient_balance` callback.** Fires only when a debit, transfer, reservation or capture is actually rejected (i.e. flag off or your service-layer floor refused). Successful overdraft transfers do not fire it.
277
+ - **Credits don't auto-settle prior debt.** If a wallet is at -50 and you `credit(80)`, the wallet's balance becomes 30 — but the unbacked -50 debit and the +80 credit persist as independent ledger rows. The next debit consumes from the +80 bucket without back-filling the older unbacked debit. The math is consistent; the audit story is "we never settled the original debt with this credit". If you want auto-settlement, do it in your service layer (e.g. when crediting, `wallet.transactions.where("amount < 0").where("ABS(amount) > spent_amount") …` and create allocations explicitly).
278
+ - **System-initiated reversals.** Refunds and payout reversals via `transfer_to` will also go through, even if the recipient's wallet ends up below zero. That's correct: the ledger has to settle, and a negative wallet records a real debt instead of a silent failure. Apps that want to *block* user-initiated overdrafts but *allow* system reversals should keep the floor check in their service layer, not toggle the flag mid-request.
279
+ - **The flag decides what commands may do, not what the balance may read.** With it off, no command may spend beyond the available balance. Yet the available balance can still read negative: when a hold covers value that is also reserved, or when reserved value expires (see [Holds and reservations](#holds-and-reservations)). Such a wallet stays saveable, takes credits and releases, and refuses new spending until it is back in funds. So turning the flag off while some wallets are negative is safe: they keep their debt and stop going deeper.
280
+ - **Concurrent writes serialize.** `wallet.debit` / `wallet.credit` use `with_lock` (row-level `SELECT … FOR UPDATE`); `wallet.transfer_to` locks both wallets in id order via `lock_wallet_pair!`. Two concurrent overdraft debits on the same wallet serialize cleanly — they don't double-count `previous_balance`.
281
+
204
282
  ### Expiring balances
205
283
 
206
284
  Credits can expire:
@@ -213,7 +291,332 @@ user.wallet(:coins).credit(
213
291
  )
214
292
  ```
215
293
 
216
- Debits allocate against the oldest available, non-expired credits first.
294
+ Debits consume the soonest-expiring credits first, then the oldest. Expired value is never spent, and neither is held value (see [Holds and reservations](#holds-and-reservations)).
295
+
296
+ ## Holds and reservations
297
+
298
+ Sometimes value is in a wallet but must not be spent yet. A reward waits for validation. A seller's earnings wait out the return window. A booking sets money aside until the service is delivered. A fraud review freezes one credit, or the whole wallet. `wallets` models all of these with **holds**, and reads them the way your bank statement does:
299
+
300
+ | On your bank statement | In `wallets` |
301
+ |---|---|
302
+ | A deposit that has not cleared | A credit under a **hold** (on that credit, or on the whole wallet) |
303
+ | A card charge that stays pending until the merchant captures it | A **reservation** of an amount, later **captured** or released |
304
+ | Available balance | `wallet.balance` (also `wallet.available_balance`) |
305
+ | Pending | `wallet.held_balance` |
306
+ | Current balance | `wallet.total_balance` |
307
+
308
+ `balance` is always what can be spent right now. Held value is left out of it, exactly the way expired value already is, and `total_balance` is always `balance + held_balance`. If you never place a hold, nothing changes: `held_balance` is `0` and `total_balance` equals `balance`.
309
+
310
+ There is one honest difference from a bank. Value in `wallets` can expire, and a hold never stops that clock. So a capture still needs funding at the moment it happens.
311
+
312
+ Hold categories are free-form labels such as `:fraud_review` or `:pending_validation`, and need no configuration. The debit categories in these examples (`:reversal`, `:booking_charge`) are ordinary transaction categories, so add them to `config.additional_categories`.
313
+
314
+ ### Pending value you received
315
+
316
+ Credit the value now and let it become spendable later:
317
+
318
+ ```ruby
319
+ credit = wallet.credit(751, category: :reward, hold: :pending_validation)
320
+
321
+ wallet.balance # => 0
322
+ wallet.held_balance # => 751
323
+ wallet.total_balance # => 751
324
+
325
+ credit.held? # => true
326
+ credit.holds.active.map(&:category) # => ["pending_validation"]
327
+ ```
328
+
329
+ The credit and its hold are written in one step, so the value is never spendable in between, not even in a callback or a balance snapshot.
330
+
331
+ Pass a Hash to give the hold a deadline and its own metadata. When the deadline arrives, the value becomes available on its own:
332
+
333
+ ```ruby
334
+ wallet.credit(751, category: :reward, metadata: { ride_id: 7 },
335
+ hold: { category: :pending_validation, until: 3.months.from_now, metadata: { case_id: 42 } })
336
+ ```
337
+
338
+ Or just say when the value becomes available. `available_at:` is shorthand for a hold of category `:pending` until that time:
339
+
340
+ ```ruby
341
+ wallet.credit(500, category: :reward, available_at: 14.days.from_now) # a referral bonus that unlocks in 14 days
342
+ ```
343
+
344
+ A time that has already passed means available now, with no hold at all. Pass `hold:` or `available_at:`, not both.
345
+
346
+ ### Holding value that already landed
347
+
348
+ Hold a credit that is already in the wallet:
349
+
350
+ ```ruby
351
+ hold = wallet.hold(credit, category: :fraud_review, metadata: { case_id: 42 })
352
+ ```
353
+
354
+ A credit hold always holds the credit's whole remaining value. One credit can be held for several reasons at once. Each hold is an independent reason, released on its own, and the credit stays held while any of them is active. Its value is still counted once:
355
+
356
+ ```ruby
357
+ fraud = wallet.hold(credit, category: :fraud_review)
358
+ dispute = wallet.hold(credit, category: :dispute, until: 30.days.from_now)
359
+
360
+ fraud.release!
361
+ credit.held? # => true, the dispute still holds it
362
+
363
+ dispute.release!
364
+ credit.held? # => false
365
+ ```
366
+
367
+ Leave the credit out to hold the whole wallet. Every credit is held, including credits that arrive later:
368
+
369
+ ```ruby
370
+ wallet.hold(category: :chargeback, metadata: { case_id: 7 })
371
+
372
+ wallet.on_hold? # => true
373
+ wallet.balance # => 0
374
+ wallet.credit(40, category: :reward).held? # => true
375
+ ```
376
+
377
+ Only leaving the credit out holds the whole wallet. Passing `nil` raises `ArgumentError`, so a lookup that found nothing can never freeze a wallet by accident. `on_hold?` is true only for a hold on the whole wallet: a held credit or a pending reservation does not count.
378
+
379
+ Release a hold to make its value available again. Why it ended is recorded next to why it began, never over it:
380
+
381
+ ```ruby
382
+ hold.release!(reason: "cleared_by_operator", metadata: { actor_id: 12 })
383
+
384
+ hold.released? # => true
385
+ hold.release_reason # => "cleared_by_operator"
386
+ hold.release_metadata # => { "actor_id" => 12 }
387
+ hold.metadata # => { "case_id" => 42 }
388
+ ```
389
+
390
+ `release!` is idempotent. On a hold that has already ended (released, captured, or past its deadline) it changes nothing and reports nothing.
391
+
392
+ There is no `release_all`, on purpose: an operator clears the exact holds they reviewed, never "everything in a category". To release a set together, release each one inside one wallet lock:
393
+
394
+ ```ruby
395
+ wallet.with_lock do
396
+ reviewed_holds.each { |hold| hold.release!(reason: "cleared_by_operator") }
397
+ end
398
+ ```
399
+
400
+ To find holds:
401
+
402
+ ```ruby
403
+ wallet.holds.active # every hold in force right now
404
+ wallet.holds.coverage.active # holds on credits and on the whole wallet
405
+ wallet.holds.reservations.active # pending reservations
406
+ credit.holds.active # the reasons this credit is held
407
+ ```
408
+
409
+ The `Wallets::Hold` scopes are `active` (which takes `at:`), `released`, `coverage`, `reservations`, `wallet_scoped` and `credit_scoped`. Each hold answers `active?`, `released?`, `lapsed?` (its deadline passed without a release), `captured?`, `reservation?`, `coverage?`, `wallet_scoped?` and `credit_scoped?`. A credit hold reaches its credit through `hold.credit`. The column is `transaction_id`, but Active Record reserves the name `transaction`, so there is no `hold.transaction`. Each credit answers `held?`, `held_amount` and `available_amount`.
410
+
411
+ ### Reserving value you are about to charge
412
+
413
+ A reservation sets an amount aside, like a card charge that stays pending until the merchant captures it:
414
+
415
+ ```ruby
416
+ wallet.balance # => 10000
417
+
418
+ reservation = wallet.reserve(3_000, category: :booking, until: 7.days.from_now, metadata: { booking_id: 42 })
419
+
420
+ wallet.balance # => 7000
421
+ wallet.held_balance # => 3000
422
+ ```
423
+
424
+ When the service is delivered, capture what it really cost. The rest of the reservation is released in the same step:
425
+
426
+ ```ruby
427
+ debit = reservation.capture!(2_750, category: :booking_charge, metadata: { booking_id: 42 })
428
+
429
+ wallet.balance # => 7250
430
+ reservation.captured? # => true
431
+ reservation.capture_transaction # => debit
432
+ ```
433
+
434
+ Leave the amount out to capture the whole reservation. If the booking is cancelled, `reservation.release!` gives the amount back. If the deadline passes first, the reservation lapses and its amount is available again.
435
+
436
+ Reservations add up, like two pending card charges:
437
+
438
+ ```ruby
439
+ wallet.reserve(40, category: :booking)
440
+ wallet.reserve(40, category: :booking)
441
+
442
+ wallet.held_balance # => 80
443
+ ```
444
+
445
+ The rules:
446
+
447
+ - **A reservation is funded like a debit.** It must fit in `balance`, or it raises `Wallets::InsufficientBalance`, unless `allow_negative_balance` is on. The amount is parsed exactly like a debit's, and `until:` must be a time: anything else raises `ArgumentError` instead of quietly meaning "no deadline".
448
+ - **A capture happens once.** Only an active reservation can be captured. A second capture, or a capture after a release or a lapse, raises `Wallets::InvalidHold` and creates nothing, so a retried request never charges twice. Capturing more than was reserved raises `ArgumentError`. The reservation's state is checked before the amount, so retrying a finished capture always raises `Wallets::InvalidHold`.
449
+ - **A capture is funded when it happens.** It spends credits like any debit, with its own reservation counted back in. Held value is never captured, so a fraud hold placed after the reservation can make the capture fail with `Wallets::InsufficientBalance`, and so can expiry. A failed capture leaves the reservation as it was, ready to try again.
450
+ - **A capture is one step.** Its balance snapshots and callbacks see the balance before and after the whole step, never the moment in between. Capturing the whole reservation of a fully reserved wallet reports an available balance of `0` before and `0` after. A capture never lowers the available balance: it takes at most what the reservation already kept out of it.
451
+
452
+ ### Ending a hold by taking the value
453
+
454
+ Releasing a hold gives the value back. Sometimes the answer is the opposite: the reward was rejected, or the payment behind it bounced, and the value has to go. Name the credits to take it from with `from:`.
455
+
456
+ > [!WARNING]
457
+ > **`from:` is the one way to spend held value.** A plain `debit` or `transfer_to` never touches it. Reach for `from:` only when taking held value is exactly what you mean, such as cancelling a rejected reward.
458
+
459
+ ```ruby
460
+ credit = wallet.credit(751, category: :reward, hold: :pending_validation)
461
+
462
+ # The reward was rejected: take it back...
463
+ wallet.debit(751, category: :reversal, from: credit, metadata: { reason: "reward_rejected" })
464
+
465
+ # ...or move it to the platform instead.
466
+ wallet.transfer_to(platform_wallet, 751, category: :reversal, from: credit)
467
+ ```
468
+
469
+ Name several credits as a list, or pass any relation. They are spent in the order given, and a credit with nothing left simply gives nothing:
470
+
471
+ ```ruby
472
+ wallet.debit(150, category: :reversal, from: [first_credit, second_credit])
473
+ ```
474
+
475
+ How a debit with `from:` is funded:
476
+
477
+ - **It takes from the named credits only, in full or not at all.** It never falls back to other credits and never leaves debt, whatever `allow_negative_balance` says. Asking for more than the named credits hold raises `Wallets::InsufficientBalance`.
478
+ - **Held value needs no funding.** It was never part of the available balance, so cancelling a fully held credit works even when nothing is available.
479
+ - **Unheld value does.** With overdrafts off, the part taken from unheld credits must fit in the available balance. So naming an unheld credit cannot spend what a reservation set aside, or deepen a deficit.
480
+ - **Only saved credits of this wallet.** Every other source is a wrong argument and raises `ArgumentError` before anything is written: an empty list, a credit named twice, a debit, an unsaved or deleted credit, another wallet's credit or another ledger's, and `from: nil` (leave `from:` out to spend as usual). An expired credit is not an error: like a spent one it simply has nothing left to give, so it is passed over and never allocated from (no value is ever created from an expired bucket, under any expiration policy), and if the named credits then fall short the debit raises `Wallets::InsufficientBalance`.
481
+ - **The holds stay where they are.** The credit keeps its reasons, with nothing left to hold. No `on_hold_released` fires, because nothing was released.
482
+ - **A transfer with `from:` always keeps its sources' expirations** under the default `:preserve` policy. It is always fully backed by the named credits, even when the value was held and nothing was available.
483
+
484
+ ### How holds change the rules you already know
485
+
486
+ - **Holds decide which credits can fund a debit, and `allow_negative_balance` decides whether a debit nothing funds may go ahead.** An ordinary debit, transfer or capture skips held credits. With overdrafts on, whatever unheld credits cannot cover becomes debt, and held value is still never touched.
487
+ - **A hold is not a ban.** It keeps value from being spent. It does not stop the wallet from receiving credits, and it does not stop your app from doing anything. If an action must be blocked during a review, such as a withdrawal, check for that in your app and show the hold as the reason.
488
+ - **The available balance is a signed integer, and it can go negative without any overdraft.** Reserve 80 of 100, then hold the credit for fraud review:
489
+
490
+ ```ruby
491
+ wallet.reserve(80, category: :booking)
492
+ wallet.hold(credit, category: :fraud_review)
493
+
494
+ wallet.balance # => -80
495
+ wallet.held_balance # => 180
496
+ wallet.total_balance # => 100
497
+ ```
498
+
499
+ Reserved value that expires has the same effect. With `allow_negative_balance` off, such a wallet stays perfectly usable: credits and releases work, and new spending is refused until the balance covers it. The flag decides what commands may do, not what the balance may read.
500
+ - **`held_balance` can be larger than `total_balance`.** It happens above, where a hold covers value that is also reserved, and when reserved value expires. It is deliberately not capped, and `total_balance == balance + held_balance` still applies.
501
+ - **A hold never pauses expiry.** A held credit that expires is gone, not held: its `held_amount` drops to `0`, while the hold stays readable as the reason it was held.
502
+ - **Deadlines are computed, never announced.** When an `until:` passes, the hold simply stops counting (`lapsed?`), exactly like an expired credit. No job runs and no callback fires: `on_hold_released` fires only for `release!` and for a capture.
503
+ - **The `:preserve` fallback follows what is really unbacked.** A transfer falls back to a single evergreen credit (`:none`) only when part of it is actually unbacked debt, not whenever it exceeds the available balance. This changes one case even without holds: with old debt and fresh expiring credits, a transfer above the net available balance that those credits fully cover now keeps their expiration instead of arriving evergreen.
504
+ - **`insufficient_balance` says what is out of reach.** Its metadata gains `held:` (credit value under holds) and `reserved:` (active reservations), so `available + held + reserved` is the total balance. Both are `0` when nothing is held. The error message names the ones that are not zero: `Insufficient balance (20 < 40; 100 held, 30 reserved)`. A debit with `from:` adds `covered_consumed:` (the held value it would have taken), and a capture adds `available_for_capture:`. Their messages say what was compared, such as `(100 < 101 in the named credits; ...)`, `(10 < 61 - 50 from held credits; ...)` or `(0 < 80 to capture reservation 5; ...)`.
505
+ - **Every change bumps the wallet's `ledger_revision`.** Each command that records something increments it, even when the available balance does not move, as with a second reason on a held credit or a whole capture. It is how a stale MySQL snapshot is told from a current one, and unlike a timestamp, no clock, `no_touching` or `touch` can repeat it. `updated_at` follows ordinary Rails rules. Read a new value of neither as "the balance changed".
506
+ - **The cached `balance` column stores the available balance.** As before, it can go stale through time alone (expiry, and now deadlines), so call `wallet.balance` when the number matters.
507
+
508
+ ### Hold callbacks
509
+
510
+ ```ruby
511
+ Wallets.configure do |config|
512
+ config.on_hold_created do |ctx|
513
+ Rails.logger.info("[Wallets] #{ctx.category} hold on wallet #{ctx.wallet.id}, available #{ctx.previous_balance} -> #{ctx.new_balance}")
514
+ end
515
+
516
+ config.on_hold_released do |ctx|
517
+ if ctx.transaction
518
+ Rails.logger.info("[Wallets] Reservation #{ctx.hold.id} captured as transaction #{ctx.transaction.id}")
519
+ else
520
+ Rails.logger.info("[Wallets] Hold #{ctx.hold.id} released: #{ctx.hold.release_reason}")
521
+ end
522
+ end
523
+ end
524
+ ```
525
+
526
+ On both events:
527
+
528
+ - `ctx.hold` is the `Wallets::Hold`, and `ctx.category` is its category.
529
+ - `ctx.amount` is a reservation's amount, and `nil` for a hold on a credit or a wallet. On a capture it is still the reserved amount: the debit carries what was captured.
530
+ - `ctx.previous_balance` and `ctx.new_balance` are available balances.
531
+ - `ctx.metadata` is the creation metadata on `on_hold_created` and the release metadata on `on_hold_released`. A capture's metadata lives on its debit.
532
+ - `ctx.transaction` is set only when a capture ended the reservation, and it is the debit.
533
+
534
+ What each command reports:
535
+
536
+ | Command | Callbacks |
537
+ |---|---|
538
+ | `credit(..., hold:)` | `on_balance_credited`, then `on_hold_created` |
539
+ | `hold`, `reserve` | `on_hold_created` |
540
+ | `release!` | `on_hold_released`, or nothing when the hold had already ended |
541
+ | `capture!` | `on_balance_debited`, then `on_hold_released` |
542
+ | `debit(..., from:)` | `on_balance_debited` only: nothing is released |
543
+ | A deadline passing | Nothing |
544
+
545
+ A hold, a reservation or a debit that lowers the available balance can also fire `on_low_balance_reached` and `on_balance_depleted`, once per real crossing. A credit, a release and a deadline never do. Like every success callback, these run when the command's transaction commits, which is the outermost commit unless a `transaction(joinable: false)` boundary intervenes: inside one, Active Record's own `after_commit` rule applies, and they fire when the command's own transaction completes. A command that failed reports nothing.
546
+
547
+ ### Upgrading an existing install
548
+
549
+ ```bash
550
+ bundle update wallets
551
+ rails generate wallets:upgrade
552
+ rails db:migrate
553
+ ```
554
+
555
+ > **Migrate before the 0.4.0 code serves traffic.** The new code expects the holds table and the wallets' `ledger_revision` column. Until `rails db:migrate` has added them, reading a `Wallets::Wallet`'s balance, or running any command on it (an initial balance included), raises: a ledger that keeps holds fails loudly on purpose rather than treat held value as spendable. Deploy the migration first, then the code.
556
+
557
+ The upgrade generator writes one migration that adds the holds table, and a `ledger_revision` column on the wallets table, to a 0.1, 0.2 or 0.3 install. It follows your `table_prefix`, and reads the key types from the tables you already have, uuid and unsigned keys included. Running it again never writes a second migration, and it writes none at all when both already exist. Rolling the migration back removes them only while the holds table is empty. Fresh installs get both from `rails generate wallets:install`.
558
+
559
+ Then look at where your app reads `wallet.balance`. It still means "what can be spent", so spending checks need no change. Screens and reports that mean "everything the user has", such as a total earned, should read `total_balance`. A reconciliation of the cached `balance` column compares it with `wallet.balance`, never with `total_balance`: the column stores the available balance, so reserving 30 of 100 leaves it at 70 while `total_balance` reads 100. Even then the two can differ through time alone, as credits expire and deadlines pass.
560
+
561
+ ### Embedding holds in your own ledger
562
+
563
+ Holds are opt-in per ledger. `Wallets::Wallet` keeps them. `Wallets::WalletBase`, the class embedding gems subclass, does not, until your subclass sets `hold_class_name`. A ledger that has not opted in never touches holds:
564
+
565
+ - `balance` runs the 0.3 math through the two helpers you may override, `positive_remaining_balance` and `unbacked_negative_balance(scope)`, which keep their signatures and their meaning. No hold SQL runs anywhere, destroy included.
566
+ - `held_balance` is `0`, `on_hold?` is `false`, and `available_balance` and `total_balance` both equal your `balance`, whatever your `current_balance` override returns.
567
+ - `hold`, `reserve`, `credit(hold:)`, `credit(available_at:)` and `from:` raise `Wallets::HoldsNotEnabled` before running a single query.
568
+
569
+ To opt in, give your ledger its own hold class and table, declare the associations, and set `hold_class_name`:
570
+
571
+ ```ruby
572
+ class MyGem::Hold < Wallets::HoldBase
573
+ self.embedded_table_name = "my_gem_holds"
574
+ self.config_provider = -> { MyGem.configuration }
575
+
576
+ belongs_to :wallet, class_name: "MyGem::Wallet", inverse_of: :holds
577
+ belongs_to :credit, class_name: "MyGem::Transaction", foreign_key: :transaction_id, inverse_of: :holds, optional: true
578
+ belongs_to :capture_transaction, class_name: "MyGem::Transaction", optional: true
579
+ end
580
+
581
+ class MyGem::Transaction < Wallets::TransactionBase
582
+ has_many :holds, class_name: "MyGem::Hold", foreign_key: :transaction_id, inverse_of: :credit
583
+ end
584
+
585
+ class MyGem::Wallet < Wallets::WalletBase
586
+ self.hold_class_name = "MyGem::Hold"
587
+ self.callback_event_map = callback_event_map.merge(hold_created: :coins_held, hold_released: :coins_released).freeze
588
+
589
+ has_many :holds, class_name: "MyGem::Hold", foreign_key: :wallet_id, inverse_of: :wallet
590
+ end
591
+ ```
592
+
593
+ - Create the table with the same columns, check constraints and indexes as `wallets_holds`. The install template has them.
594
+ - Add `ledger_revision` to your wallets table, which is one `add_column :my_gem_wallets, :ledger_revision, :bigint, null: false, default: 0`. It is the robust marker for telling a stale MySQL snapshot from a current one, and a ledger that keeps holds refuses to run without it. A ledger without holds whose table lacks it still works, on a strictly increasing `updated_at` compared with `balance`, but another connection's `touch(time:)` can spoof that.
595
+ - If your wallets can be destroyed, destroy their holds first. `Wallets::Wallet` does it in a `before_destroy` callback declared with `prepend: true`, so the holds go before the transactions they point at.
596
+ - If you override `current_balance`, decide on purpose how it treats held and reserved value. With holds on, `balance`, `held_balance` and `total_balance` come from one set of terms. Overriding `current_balance` alone can break `total_balance == balance + held_balance`, so keep all three coherent.
597
+ - `credit` now takes the keywords `hold:` and `available_at:`, and `debit` takes `from:`. If your transaction table has columns by those names, you can no longer pass them through `credit` or `debit` as extra attributes.
598
+
599
+ ### Performance and concurrency
600
+
601
+ With holds on, `balance`, `held_balance` and `total_balance` are each one SQL statement: the same sums over the wallet's transactions as before, plus an indexed `EXISTS` lookup on the holds table for each credit and one sum over the active reservations. Without holds, reading a balance runs exactly the 0.3 queries.
602
+
603
+ On MySQL, a transaction's snapshot is fixed by its first read (repeatable read). If your code reads something and then calls a wallet command in the same transaction, a plain read taken after the wallet lock could miss what another transaction just committed to that wallet. So each command compares the wallet's `ledger_revision`, which every command increments, as its snapshot shows it with the one the lock returns. When they differ, that command's ledger reads become locking reads, which always see the latest rows, and so do the reads of every later command in the same transaction, until that transaction commits or rolls back. A wallet your own code saved or touched earlier in the transaction counts as stale too, because its snapshot would otherwise look current. Locking reads are not used every time, because InnoDB's gap locks would then make unrelated wallets deadlock each other. PostgreSQL and SQLite always read the latest rows and never need this. It relies on every ledger change going through the wallet API, which increments the revision each time: raw SQL writes to the wallet row (`update_columns` included), and holds written as `Wallets::Hold` rows instead of through `hold`, `reserve` and `credit(hold:)`, are outside the supported API. The full reasoning is in [the 0.4 plan](docs/plans/2026-10-17-holds-and-reservations.md#6-operations).
604
+
605
+ ### Not in this release
606
+
607
+ - **Partial holds on a credit.** A credit hold always holds the credit's whole remaining value. To set an amount aside, reserve it.
608
+ - **Holds on incoming transfers.** `transfer_to` cannot yet issue the recipient's credits under an atomic initial hold. The recipient side can hold them with `wallet.hold(credit, ...)` after the transfer, but that second step is not atomic with it:
609
+
610
+ ```ruby
611
+ transfer = buyer_wallet.transfer_to(seller_wallet, 5_000, category: :sale)
612
+
613
+ transfer.inbound_transactions.each do |credit|
614
+ seller_wallet.hold(credit, category: :return_window, until: 14.days.from_now)
615
+ end
616
+ ```
617
+
618
+ - **Deadline notifications.** Nothing fires when an `until:` passes. Schedule your own job if you need to act at that moment.
619
+ - **Bucket pinning.** A reservation is an amount of the wallet, not a claim on particular credits. Ordinary debits leave it backed by the longest-lived credits, but expiry can still leave it underfunded.
217
620
 
218
621
  ## Configuration
219
622
 
@@ -223,12 +626,13 @@ Create or edit `config/initializers/wallets.rb`:
223
626
  Wallets.configure do |config|
224
627
  config.default_asset = :coins
225
628
 
226
- # Useful for app-specific business events like games, marketplaces, or rewards.
629
+ # Useful for direct credit/debit events in your app.
630
+ # Transfer business categories live on Wallets::Transfer.category.
227
631
  config.additional_categories = %w[
228
632
  quest_reward
229
- marketplace_sale
230
- ride_fare
231
- peer_payment
633
+ provider_bonus
634
+ payout
635
+ promo_credit
232
636
  ]
233
637
 
234
638
  config.allow_negative_balance = false
@@ -241,6 +645,8 @@ end
241
645
 
242
646
  `wallets` ships with lifecycle callbacks you can use for notifications, analytics, or product logic.
243
647
 
648
+ Successful callbacks run when the command's transaction commits, which is the outermost database transaction's commit unless a `transaction(joinable: false)` boundary intervenes (inside one, as with Active Record's own `after_commit`, they fire when the command's own transaction completes). If a caller wraps a wallet operation in a larger transaction and later rolls it back, no success callback is emitted, and a command that fails emits none even when the caller rescues the error and commits. Callback exceptions are logged and isolated from the already-committed ledger write. `on_insufficient_balance` remains immediate because it describes a rejected operation rather than a committed row.
649
+
244
650
  ```ruby
245
651
  Wallets.configure do |config|
246
652
  config.on_balance_credited do |ctx|
@@ -273,6 +679,7 @@ Useful fields on `ctx` include:
273
679
  - `ctx.previous_balance`
274
680
  - `ctx.new_balance`
275
681
  - `ctx.transaction`
682
+ - `ctx.hold` (hold events, see [Hold callbacks](#hold-callbacks))
276
683
  - `ctx.category`
277
684
  - `ctx.metadata`
278
685
 
@@ -284,14 +691,14 @@ Both gems handle balances, but they solve different problems:
284
691
  ┌─────────────────────────────────────────────────────────────────┐
285
692
  │ usage_credits │
286
693
  │ ┌───────────────────────────────────────────────────────────┐ │
287
- │ │ Subscriptions, Credit Packs, Pay Intgration, Fulfillment │ │
694
+ │ │ Subscriptions, Credit Packs, Pay Integration, Fulfillment│ │
288
695
  │ │ Operations DSL, Pricing, Refunds, Webhook Handling │ │
289
696
  │ └───────────────────────────────────────────────────────────┘ │
290
697
  │ │ │
291
698
  │ ▼ │
292
699
  │ ┌───────────────────────────────────────────────────────────┐ │
293
700
  │ │ wallets │ │
294
- │ │ Balance, Credit, Debit, Transfer, Expiration, FIFO, │ │
701
+ │ │ Balance, Credit, Debit, Transfer, Expiration, FEFO, │ │
295
702
  │ │ Audit Trail, Row-Level Locking, Multi-Asset │ │
296
703
  │ └───────────────────────────────────────────────────────────┘ │
297
704
  └─────────────────────────────────────────────────────────────────┘
@@ -414,52 +821,107 @@ player.wallet(:gold).transfer_to(
414
821
  )
415
822
  ```
416
823
 
417
- ### Marketplace with seller balances
824
+ A tournament prize can wait for the anti-cheat review. It arrives held, so the player sees it but cannot spend it until the match is cleared:
825
+
826
+ ```ruby
827
+ prize = player.wallet(:gems).credit(
828
+ 1_000,
829
+ category: :tournament_prize,
830
+ hold: { category: :anti_cheat_review, metadata: { match_id: 314 } }
831
+ )
418
832
 
419
- An Etsy/Fiverr-style marketplace where sellers earn and can withdraw:
833
+ player.wallet(:gems).held_balance # => 1000, on the player's screen, not spendable yet
834
+
835
+ # The match is clean: the gems are the player's to spend
836
+ prize.holds.find_by(category: :anti_cheat_review).release!(reason: "match_clean")
837
+ player.wallet(:gems).held_balance # => 0, the 1,000 gems are in balance now
838
+
839
+ # Or cheating is confirmed: the prize is taken back instead
840
+ player.wallet(:gems).debit(1_000, category: :prize_revoked, from: prize)
841
+ ```
842
+
843
+ ### Home services booking app
844
+
845
+ A home-services app where the platform collects the customer payment, keeps its fee, and later pays the provider:
420
846
 
421
847
  ```ruby
422
848
  class User < ApplicationRecord
423
849
  has_wallets default_asset: :usd_cents
424
850
  end
425
851
 
426
- # Order completed — credit seller (minus platform fee)
427
- order_total = 5000 # $50.00
428
- platform_fee = (order_total * 0.10).to_i # 10%
429
- seller_earnings = order_total - platform_fee
852
+ class Marketplace < ApplicationRecord
853
+ has_wallets default_asset: :usd_cents
854
+ end
430
855
 
431
- seller.wallet(:usd_cents).credit(
432
- seller_earnings,
433
- category: :sale,
856
+ sara = User.find(1) # customer
857
+ lucia = User.find(2) # cleaner
858
+ booking = Booking.find(42)
859
+ marketplace = Marketplace.first
860
+
861
+ # Booking completed
862
+ booking_total = 5000 # $50.00
863
+ platform_fee = (booking_total * 0.10).to_i # 10%
864
+ provider_earnings = booking_total - platform_fee
865
+
866
+ # Customer pays the marketplace
867
+ charge = sara.wallet(:usd_cents).transfer_to(
868
+ marketplace.wallet(:usd_cents),
869
+ booking_total,
870
+ category: :booking_charge,
434
871
  metadata: {
435
- order_id: order.id,
436
- gross_amount: order_total,
437
- platform_fee: platform_fee,
438
- buyer_id: buyer.id
872
+ booking_id: booking.id,
873
+ provider_id: lucia.id
439
874
  }
440
875
  )
441
876
 
442
- # Buyer uses store credit
443
- buyer.wallet(:usd_cents).debit(
444
- 2000,
445
- category: :purchase,
446
- metadata: { order_id: order.id }
877
+ # Marketplace pays the cleaner the net amount
878
+ payout = marketplace.wallet(:usd_cents).transfer_to(
879
+ lucia.wallet(:usd_cents),
880
+ provider_earnings,
881
+ category: :provider_payout,
882
+ metadata: {
883
+ booking_id: booking.id,
884
+ gross_amount: booking_total,
885
+ platform_fee: platform_fee,
886
+ customer_id: sara.id,
887
+ booking_charge_transfer_id: charge.id
888
+ }
447
889
  )
448
890
 
449
- # Seller requests payout
450
- seller.wallet(:usd_cents).debit(
451
- seller.wallet(:usd_cents).balance,
891
+ # Cleaner later cashes out to Stripe
892
+ lucia.wallet(:usd_cents).debit(
893
+ lucia.wallet(:usd_cents).balance,
452
894
  category: :payout,
453
895
  metadata: { stripe_transfer_id: "tr_xxx" }
454
896
  )
897
+ ```
898
+
899
+ Or charge the way a card does: set the price aside when Sara books, and capture what the job really cost once it is done. She cannot spend the same money twice in the meantime:
900
+
901
+ ```ruby
902
+ # Sara has $80.00 and books a 2-hour cleaning for $50.00: the price is set aside, not charged
903
+ reservation = sara.wallet(:usd_cents).reserve(
904
+ 5000,
905
+ category: :booking,
906
+ until: 3.days.from_now,
907
+ metadata: { booking_id: booking.id }
908
+ )
455
909
 
456
- # Transaction history for accounting
457
- seller.wallet(:usd_cents).history.each do |tx|
458
- puts "#{tx.created_at}: #{tx.category} #{tx.amount} cents"
459
- puts " Balance: #{tx.balance_before} → #{tx.balance_after}"
910
+ sara.wallet(:usd_cents).balance # => 3000
911
+ sara.wallet(:usd_cents).held_balance # => 5000
912
+
913
+ # The cleaning took 1.5 hours: charge $37.50, and the rest goes back to Sara
914
+ ActiveRecord::Base.transaction do
915
+ charge = reservation.capture!(3750, category: :booking_charge, metadata: { booking_id: booking.id })
916
+ marketplace.wallet(:usd_cents).credit(3750, category: :booking_charge, metadata: { booking_id: booking.id, charge_id: charge.id })
460
917
  end
918
+
919
+ sara.wallet(:usd_cents).balance # => 4250
920
+ sara.wallet(:usd_cents).held_balance # => 0
461
921
  ```
462
922
 
923
+ A capture is a debit, like a card charge, so the marketplace's credit is written next to it in one database transaction. From there the marketplace keeps its fee and pays Lucia out of the 3,750, the way the payout above pays her out of the full price. A cancelled booking calls `reservation.release!`, and a reservation nobody captures falls off on its own after 3 days (see [Reserving value you are about to charge](#reserving-value-you-are-about-to-charge)).
924
+
463
925
  ### Loyalty programs & Reward points
464
926
 
465
927
  Whether you're building a Starbucks-style loyalty program, credit card rewards, airline miles, or a Sweatcoin-style earn-from-actions app — it's the same pattern:
@@ -578,12 +1040,34 @@ user.wallet(:points).debit(
578
1040
  )
579
1041
  ```
580
1042
 
1043
+ Points from a purchase that can still be returned may stay pending until the return window closes. The member sees them, and redeems them once a return can no longer take them back:
1044
+
1045
+ ```ruby
1046
+ # 42 points for a $42.50 order, redeemable once the 30-day return window closes
1047
+ earned = user.wallet(:points).credit(
1048
+ order.total_cents / 100,
1049
+ category: :purchase,
1050
+ available_at: 30.days.from_now,
1051
+ metadata: { order_id: order.id }
1052
+ )
1053
+
1054
+ user.wallet(:points).held_balance # => 42, on the member's screen, not redeemable yet
1055
+
1056
+ # Day 30, no return: the points are redeemable
1057
+ user.wallet(:points).held_balance # => 0, the 42 points are in balance now
1058
+
1059
+ # Or the order comes back inside the window: the pending points go with it
1060
+ user.wallet(:points).debit(earned.amount, category: :refund, from: earned, metadata: { order_id: order.id })
1061
+ earned.reload.remaining_amount # => 0, and nothing else in the wallet was touched
1062
+ ```
1063
+
581
1064
  **Loyalty-specific patterns:**
582
1065
 
583
1066
  | Pattern | Implementation |
584
1067
  |---------|----------------|
585
1068
  | **Tiered earning** | `credit(amount * tier_multiplier, ...)` |
586
1069
  | **Points expiration** | `expires_at: 1.year.from_now` |
1070
+ | **Points pending until the return window closes** | `credit(..., available_at: 30.days.from_now)`, cancelled on a return with `from:` |
587
1071
  | **Family pooling** | `transfer_to` family wallet |
588
1072
  | **Gifting** | `transfer_to` friend's wallet |
589
1073
  | **Earn + burn in one transaction** | `debit` points, `credit` new promo points |
@@ -601,35 +1085,242 @@ user.wallet(:points).debit(
601
1085
 
602
1086
  ### Gig economy / Driver earnings
603
1087
 
604
- An Uber/DoorDash-style app with earnings and tips:
1088
+ An Uber/DoorDash-style app with earnings and tips. A fare stays pending while the rider can still dispute it, and the weekly payout pays only what has cleared:
605
1089
 
606
1090
  ```ruby
607
1091
  class Driver < ApplicationRecord
608
1092
  has_wallets default_asset: :usd_cents
609
1093
  end
610
1094
 
611
- # Ride completed
1095
+ # Ride completed: the fare is pending for 3 days, while the rider can still dispute it
612
1096
  driver.wallet(:usd_cents).credit(
613
1097
  1250, # $12.50 base fare
614
1098
  category: :ride_fare,
1099
+ available_at: 3.days.from_now,
615
1100
  metadata: { ride_id: ride.id, distance_miles: 5.2 }
616
1101
  )
617
1102
 
618
1103
  # Tip added later
619
- driver.wallet(:usd_cents).credit(
1104
+ tip = driver.wallet(:usd_cents).credit(
620
1105
  300, # $3.00 tip
621
1106
  category: :tip,
622
1107
  metadata: { ride_id: ride.id, rider_id: rider.id }
623
1108
  )
624
1109
 
625
- # Weekly payout
1110
+ # Weekly payout: `balance` is what has cleared, here the $3.00 tip
626
1111
  driver.wallet(:usd_cents).debit(
627
1112
  driver.wallet(:usd_cents).balance,
628
1113
  category: :weekly_payout,
629
1114
  metadata: { payout_date: Date.current, bank_account: "****1234" }
630
1115
  )
1116
+
1117
+ driver.wallet(:usd_cents).held_balance # => 1250, paid out next week
631
1118
  ```
632
1119
 
1120
+ If the rider disputes the ride before the payout, retain the tip until support decides. The payout leaves it out, because it pays `balance`:
1121
+
1122
+ ```ruby
1123
+ dispute = driver.wallet(:usd_cents).hold(tip, category: :dispute, metadata: { ride_id: ride.id })
1124
+ driver.wallet(:usd_cents).balance # => 0
1125
+
1126
+ # Support sides with the driver: the tip is spendable again
1127
+ dispute.release!(reason: "resolved_for_driver")
1128
+ driver.wallet(:usd_cents).balance # => 300
1129
+
1130
+ # Or support sides with the rider: the tip goes back to them
1131
+ driver.wallet(:usd_cents).transfer_to(rider.wallet(:usd_cents), 300, category: :tip_refund, from: tip)
1132
+ ```
1133
+
1134
+ ### Marketplace seller earnings
1135
+
1136
+ An Etsy-style marketplace pays a seller when an order ships, but the buyer can still return it for 14 days. The earnings arrive held for that window, so the seller sees them but cannot cash out what a return could still take back:
1137
+
1138
+ ```ruby
1139
+ class Seller < ApplicationRecord
1140
+ has_wallets default_asset: :eur_cents
1141
+ end
1142
+
1143
+ # The order shipped: €45.00 of earnings, held until the return window closes
1144
+ earnings = seller.wallet(:eur_cents).credit(
1145
+ 4_500,
1146
+ category: :sale,
1147
+ hold: { category: :return_window, until: 14.days.from_now },
1148
+ metadata: { order_id: order.id }
1149
+ )
1150
+
1151
+ seller.wallet(:eur_cents).balance # => 0
1152
+ seller.wallet(:eur_cents).held_balance # => 4500
1153
+
1154
+ # Day 14, no return: the earnings are available on their own
1155
+ seller.wallet(:eur_cents).balance # => 4500
1156
+ ```
1157
+
1158
+ If the buyer returns the order inside the window, cancel the earnings. `from:` takes that sale's held value and never touches the seller's other money:
1159
+
1160
+ ```ruby
1161
+ seller.wallet(:eur_cents).debit(4_500, category: :refund, from: earnings, metadata: { order_id: order.id, reason: "returned" })
1162
+ ```
1163
+
1164
+ The earnings are a `credit` with `hold:` rather than a `transfer_to`, because a transfer cannot deliver its credits under a hold yet (see [Not in this release](#not-in-this-release)).
1165
+
1166
+ ### Bank-style accounts and fintech
1167
+
1168
+ A neobank-style account reads like any bank statement: an **available** balance you can spend now, and a **current** balance that also counts what is still pending. In `wallets` they are `balance` and `total_balance`, and the table in [Holds and reservations](#holds-and-reservations) maps the rest.
1169
+
1170
+ A day of activity on an account that starts with €500.00, and how it settles over the next few days:
1171
+
1172
+ ```ruby
1173
+ class Account < ApplicationRecord
1174
+ has_wallets default_asset: :eur_cents
1175
+ end
1176
+
1177
+ wallet = account.wallet(:eur_cents) # €500.00, all of it available
1178
+
1179
+ # Monday 08:00: the salary arrives. It clears tomorrow.
1180
+ wallet.credit(200_000, category: :deposit, available_at: 1.day.from_now)
1181
+ wallet.balance # => 50000 available €500.00
1182
+ wallet.total_balance # => 250000 current €2,500.00
1183
+
1184
+ # 09:00: the fuel pump pre-authorises €100.00 on the card
1185
+ fuel = wallet.reserve(10_000, category: :card_authorization, until: 7.days.from_now, metadata: { merchant: "Fuel station" })
1186
+ wallet.balance # => 40000 available €400.00
1187
+ wallet.total_balance # => 250000 current €2,500.00
1188
+
1189
+ # 09:05: the pump charges what was really filled, €62.40, and releases the rest
1190
+ fuel.capture!(6_240, category: :card_payment)
1191
+ wallet.balance # => 43760 available €437.60
1192
+ wallet.total_balance # => 243760 current €2,437.60
1193
+
1194
+ # 15:00: hotel check-in, a €150.00 hold for extras that the hotel never captures
1195
+ wallet.reserve(15_000, category: :card_authorization, until: 3.days.from_now, metadata: { merchant: "Hotel" })
1196
+ wallet.balance # => 28760 available €287.60
1197
+ wallet.total_balance # => 243760 current €2,437.60
1198
+
1199
+ # Tuesday 08:00: the salary has cleared
1200
+ wallet.balance # => 228760 available €2,287.60
1201
+ wallet.total_balance # => 243760 current €2,437.60
1202
+
1203
+ # Thursday 15:00: the hotel's hold has fallen off. Nothing is pending, so the two agree.
1204
+ wallet.balance # => 243760 available €2,437.60
1205
+ wallet.total_balance # => 243760 current €2,437.60
1206
+ ```
1207
+
1208
+ This is the ledger behind the statement inside your app. Moving real money over bank rails, and the licences that come with holding it, stay with your banking partner (see [When NOT to use `wallets`](#when-not-to-use-wallets)).
1209
+
1210
+ ### Vesting rewards
1211
+
1212
+ Value granted now that unlocks later. Vesting on a date is `available_at:`. Vesting on an event is a hold your app releases when the event happens. Either way the reward shows in `total_balance` from day one, and in `balance` once it may be spent. The three examples below are independent: each starts from an empty wallet.
1213
+
1214
+ ```ruby
1215
+ points = user.wallet(:points)
1216
+
1217
+ # A referral bonus that unlocks in 14 days
1218
+ points.credit(500, category: :referral, available_at: 14.days.from_now)
1219
+
1220
+ points.balance # => 0
1221
+ points.held_balance # => 500
1222
+ points.total_balance # => 500
1223
+
1224
+ # 14 days later
1225
+ points.balance # => 500
1226
+ ```
1227
+
1228
+ A reward that vests on a schedule is one credit per step, each with its own date:
1229
+
1230
+ ```ruby
1231
+ # A creator reward of 1,200 points that vests 400 a month for three months
1232
+ [1, 2, 3].each do |month|
1233
+ points.credit(400, category: :creator_reward, available_at: month.months.from_now)
1234
+ end
1235
+
1236
+ points.balance # => 0
1237
+ points.held_balance # => 1200
1238
+
1239
+ # Two months later
1240
+ points.balance # => 800
1241
+ points.held_balance # => 400
1242
+ ```
1243
+
1244
+ A bonus that vests on an event arrives held, and your app releases the hold when the event happens:
1245
+
1246
+ ```ruby
1247
+ # A signup bonus that vests when the member completes their first order
1248
+ bonus = points.credit(1_000, category: :signup_bonus, hold: :first_order_pending)
1249
+
1250
+ points.balance # => 0
1251
+ points.total_balance # => 1000
1252
+
1253
+ # The first order is completed
1254
+ bonus.holds.find_by(category: :first_order_pending).release!(reason: "first_order_completed")
1255
+ points.balance # => 1000
1256
+ ```
1257
+
1258
+ If the condition is never met, take the bonus back with `from:` (see [Ending a hold by taking the value](#ending-a-hold-by-taking-the-value)).
1259
+
1260
+ ### Retained rewards and reviews
1261
+
1262
+ Some value is the user's the moment it is earned, yet the platform keeps it back until something outside the app happens: a partner programme reimburses the reward months later, or a fraud or compliance review clears. Credit it now and retain it with a hold. Every retention then ends one of two ways: `release!` gives the value to the user, and `debit` or `transfer_to` with `from:` takes it away (see [Holds and reservations](#holds-and-reservations) for the mechanics).
1263
+
1264
+ ```ruby
1265
+ wallet = user.wallet(:eur_cents)
1266
+
1267
+ # A €15.00 reward earned today, which a partner programme reimburses months from now
1268
+ reward = wallet.credit(
1269
+ 1_500,
1270
+ category: :reward,
1271
+ hold: { category: :awaiting_reimbursement, metadata: { programme: "partner_cashback", claim_id: 88 } }
1272
+ )
1273
+
1274
+ wallet.balance # => 0, not spendable yet
1275
+ wallet.total_balance # => 1500, but already the user's
1276
+
1277
+ # The programme pays: give the reward to the user
1278
+ reward.holds.find_by(category: :awaiting_reimbursement).release!(reason: "reimbursed")
1279
+ wallet.balance # => 1500
1280
+
1281
+ # Or the programme rejects the claim: take the reward back
1282
+ wallet.debit(1_500, category: :reversal, from: reward, metadata: { reason: "claim_rejected" })
1283
+ wallet.total_balance # => 0
1284
+ ```
1285
+
1286
+ A review retains as much as it needs: one credit, several, or the whole account. Keep the holds it places, so the review can later end exactly those:
1287
+
1288
+ ```ruby
1289
+ # One credit
1290
+ wallet.hold(reward, category: :fraud_review, metadata: { case_id: 42 })
1291
+
1292
+ # Several credits, such as the rewards from a batch of suspicious referrals
1293
+ review = suspicious_rewards.map do |credit|
1294
+ wallet.hold(credit, category: :fraud_review, metadata: { case_id: 42 })
1295
+ end
1296
+
1297
+ # The whole account, credits that arrive during the review included
1298
+ account_review = wallet.hold(category: :compliance_review, metadata: { case_id: 43 })
1299
+ ```
1300
+
1301
+ To tell the user why their money is pending, read the reasons. A credit lists its own holds, and a hold on the whole account answers `wallet.on_hold?`. Map each category to your own words, such as "Pending until our partner pays" or "Under review":
1302
+
1303
+ ```ruby
1304
+ # A reward still awaiting reimbursement, now under fraud review too
1305
+ reward.held? # => true
1306
+ reward.holds.active.order(:id).map(&:category) # => ["awaiting_reimbursement", "fraud_review"]
1307
+ wallet.on_hold? # => true, the compliance review covers the whole account
1308
+ ```
1309
+
1310
+ End the review with the holds it placed. Cleared, they are released together. Confirmed, the value is taken, here into the platform's wallet; `from:` takes value and leaves every hold row where it is, so the account hold is released by hand once the value is gone:
1311
+
1312
+ ```ruby
1313
+ # Cleared: release exactly the holds this review placed
1314
+ wallet.with_lock { review.each { |hold| hold.release!(reason: "cleared") } }
1315
+ account_review.release!(reason: "cleared")
1316
+
1317
+ # Or confirmed: move the retained value to the platform, then end the account hold
1318
+ wallet.transfer_to(platform.wallet(:eur_cents), suspicious_rewards.sum(&:remaining_amount), category: :reversal, from: suspicious_rewards)
1319
+ account_review.release!(reason: "confirmed")
1320
+ ```
1321
+
1322
+ A hold keeps value from being spent, and nothing more. If withdrawals must stop during a review, check `wallet.on_hold?` or the credit's holds in your app before allowing one (see [How holds change the rules you already know](#how-holds-change-the-rules-you-already-know)).
1323
+
633
1324
  ## Perfect use cases
634
1325
 
635
1326
  `wallets` is best for **closed-loop value** inside your app — where the app itself is the source of truth.
@@ -637,10 +1328,12 @@ driver.wallet(:usd_cents).debit(
637
1328
  | Use case | Example | Why `wallets` fits |
638
1329
  |----------|---------|-------------------|
639
1330
  | **Telecom / data plans** | Mobile data that users can share | Multi-asset (`:data_mb`, `:sms`, `:minutes`), transfers, expiration |
640
- | **Game economies** | FarmVille, Fortnite, OGame | Multiple resources, trading between players |
641
- | **Marketplaces** | Etsy, Fiverr, Airbnb | Seller earnings, buyer credits, platform settlements |
642
- | **Rewards / loyalty** | Sweatcoin, credit card points | Points from actions, expiring promos, redemptions |
643
- | **Gig economy** | Uber, DoorDash | Driver earnings, tips, scheduled payouts |
1331
+ | **Game economies** | FarmVille, Fortnite, OGame | Multiple resources, trading between players, prizes held during an anti-cheat review |
1332
+ | **Marketplaces** | Etsy, Fiverr, Airbnb | Seller earnings held through the return window, buyer credits, platform settlements |
1333
+ | **Escrow-style bookings** | Home cleaning, repairs, rentals | The price reserved at booking, the real cost captured at delivery, unclaimed reservations falling off |
1334
+ | **Rewards / loyalty** | Sweatcoin, credit card points | Points from actions, bonuses that vest, rewards retained until a partner pays, expiring promos, redemptions |
1335
+ | **Gig economy** | Uber, DoorDash | Driver earnings that clear before the payout, tips retained during a dispute, scheduled payouts |
1336
+ | **Fintech-style accounts** | Neobank-style apps, prepaid and expense cards | Available and current balances, pending deposits, card pre-authorisations, all inside your app |
644
1337
  | **Multi-currency** | Travel apps, international platforms | Per-currency wallets (`:eur`, `:usd`, `:gbp`) |
645
1338
  | **Store credit** | Gift cards, refund credits | Simple balance with full audit trail |
646
1339
 
@@ -648,6 +1341,7 @@ driver.wallet(:usd_cents).debit(
648
1341
  - Users hold **multiple types of value** (not just one "credits" balance)
649
1342
  - Users **transfer value to each other** (gifts, trades, P2P payments)
650
1343
  - Value **expires** (promotional credits, seasonal currencies, data rollovers)
1344
+ - Value must **exist before it may be spent** (pending deposits, bonuses that vest, earnings in a return window, a booking price set aside)
651
1345
  - You need a **full audit trail** (not just a cached integer)
652
1346
  - The app is the **source of truth** (not syncing with external ledgers)
653
1347
 
@@ -668,7 +1362,6 @@ See [usage_credits](https://github.com/rameerez/usage_credits) — it uses `wall
668
1362
 
669
1363
  - **Banking infrastructure** — transfers to/from bank rails, cards, ACH, SEPA
670
1364
  - **Regulated stored-value** — KYC, AML, licensing, custody requirements
671
- - **Escrow systems** — pending, available, reserved, delayed-release states
672
1365
  - **FX conversion** — multi-currency conversion with exchange rates
673
1366
  - **Full accounting** — charts of accounts, journal entries, financial reporting
674
1367
  - **Blockchain/crypto** — consensus, custody, cryptographic guarantees
@@ -685,21 +1378,23 @@ See [usage_credits](https://github.com/rameerez/usage_credits) — it uses `wall
685
1378
 
686
1379
  ## Is this production-ready?
687
1380
 
688
- Yes, this is production-ready for internal app balances and user-to-user value transfer inside your product. It is substantially more trustworthy than a single integer column because it gives you an append-only ledger, FIFO allocation, linked transfer records, balance snapshots, and row-level locking.
1381
+ Yes, this is production-ready for internal app balances and user-to-user value transfer inside your product. It is substantially more trustworthy than a single integer column because it gives you an append-oriented transaction ledger, expiration-aware allocation, linked transfer records, balance snapshots, and row-level locking.
689
1382
 
690
1383
  In practice, that means you get:
691
1384
 
692
1385
  - a full transaction history instead of just a cached balance
693
- - FIFO consumption of the oldest available balance buckets
1386
+ - FEFO consumption of the soonest-expiring balance buckets, with oldest-first ties
694
1387
  - linked debit/credit records for transfers between users
695
1388
  - concurrency protection when multiple writes hit the same wallet
696
1389
  - enough structure to support marketplace balances, peer payments, rewards, and in-game assets inside a real production app
697
1390
 
698
1391
  If your product needs users to hold value, earn value, spend value, or transmit value to other users inside your own app, this is the sort of foundation you want instead of `users.balance += 1`.
699
1392
 
1393
+ The wallet API is append-oriented, but the gem does not turn Active Record or your database into a tamper-proof store: application code with model/SQL access can still update rows, and destroying an owner intentionally cascades through that owner's wallet history. Use soft deletion or an application-level destroy restriction when records must be retained; also restrict write access, retain backups, reconcile against external processors, and use database/audit controls appropriate to your risk model. If you need legally immutable records, regulated custody, or accounting-grade journals, use purpose-built infrastructure.
1394
+
700
1395
  ## Can it support payments between users?
701
1396
 
702
- Yes. `transfer_to` lets you move value between users while keeping both sides of the movement linked in the ledger. That makes it suitable for peer payments, marketplace payouts, seller balances, rewards, and in-game trades inside your own app.
1397
+ Yes. `transfer_to` lets you move value between users while keeping both sides of the movement linked in the ledger. That makes it suitable for peer payments, marketplace payouts, seller balances, rewards, and in-game trades inside your own app. It can also keep value pending, such as a seller's earnings until the return window closes, or set an amount aside until a charge is captured (see [Holds and reservations](#holds-and-reservations)).
703
1398
 
704
1399
  But it is not a blockchain and not a full payments stack.
705
1400
 
@@ -707,17 +1402,63 @@ What it does not do for you:
707
1402
 
708
1403
  - external settlement to banks or cards
709
1404
  - KYC/AML/compliance
710
- - escrow, reserves, or held balances
711
1405
  - FX conversion between assets
712
1406
  - disputes, chargebacks, or processor reconciliation
713
1407
  - cryptographic consensus or custody guarantees
714
1408
 
715
1409
  So the right framing is: strong internal wallet/accounting primitive, not money infrastructure by itself.
716
1410
 
1411
+ ## Embedding `wallets` in your own gem
1412
+
1413
+ `wallets` is built to be embeddable: other gems can reuse the ledger core (expiration-aware allocation, locking, transfers, callbacks) on top of their **own** tables, config, and event names. This is exactly how [`usage_credits`](https://github.com/rameerez/usage_credits) works — its `UsageCredits::Wallet` is a `Wallets::WalletBase` subclass living in `usage_credits_*` tables.
1414
+
1415
+ > [!IMPORTANT]
1416
+ > **Subclass the abstract `*Base` classes** (`Wallets::WalletBase`, `Wallets::TransactionBase`, `Wallets::AllocationBase`, `Wallets::TransferBase`, `Wallets::HoldBase`) — **not** the concrete `Wallets::Wallet` etc. ActiveRecord builds a subclass's attribute methods on its parent's, so subclassing a concrete model forces a schema load of the `wallets_*` tables — which don't exist in apps that only install your gem. The abstract parents make each embedded model its own `base_class`, so the base tables are never consulted.
1417
+
1418
+ These class-level hooks are a supported, stable contract (covered by tests — breaking them is a breaking change for downstream gems):
1419
+
1420
+ ```ruby
1421
+ class MyGem::Wallet < Wallets::WalletBase
1422
+ self.embedded_table_name = "my_gem_wallets" # your table, not wallets_wallets
1423
+ self.config_provider = -> { MyGem.configuration } # your config object
1424
+ self.callbacks_module = MyGem::Callbacks # your callback dispatcher
1425
+ self.transaction_class_name = "MyGem::Transaction" # your subclasses
1426
+ self.allocation_class_name = "MyGem::Allocation"
1427
+ self.transfer_class_name = "MyGem::Transfer"
1428
+
1429
+ # Rename (or silence, with nil) core events for your domain:
1430
+ self.callback_event_map = {
1431
+ credited: :coins_added,
1432
+ debited: :coins_spent,
1433
+ insufficient: :not_enough_coins,
1434
+ low_balance: :low_balance,
1435
+ depleted: :out_of_coins,
1436
+ transfer_completed: nil # nil = don't dispatch this event
1437
+ }.freeze
1438
+
1439
+ class << self
1440
+ private
1441
+
1442
+ # Customize the ledger entry recorded for `create_for_owner!(initial_balance:)`
1443
+ def initial_balance_credit_attributes
1444
+ { category: :starting_coins, metadata: { reason: "initial_balance" } }
1445
+ end
1446
+ end
1447
+ end
1448
+ ```
1449
+
1450
+ Your `Transaction`, `Allocation`, and `Transfer` subclasses (of `Wallets::TransactionBase`, `Wallets::AllocationBase`, `Wallets::TransferBase`) set `embedded_table_name` (and `config_provider` / `transaction_class_name` where relevant) the same way.
1451
+
1452
+ One current limitation to be aware of: the core models declare their associations against the `Wallets::*` class names, so your subclasses should **re-declare associations** with your own classes (`belongs_to :wallet, class_name: "MyGem::Wallet"`, etc.) so that records load as your subclasses rather than the core ones. See `usage_credits`' models for the canonical embedding pattern.
1453
+
1454
+ Holds are opt-in for embedded ledgers. See [Embedding holds in your own ledger](#embedding-holds-in-your-own-ledger).
1455
+
717
1456
  ## TODO
718
1457
 
1458
+ - Dynamic association class resolution for embedded subclasses (so embedders don't need to re-declare associations)
719
1459
  - First-class transfer reversal/refund API built on compensating ledger entries
720
- - Optional pending/held balance primitives for escrow-like flows
1460
+ - Holds on incoming transfers (`transfer_to(..., hold:)`), so a recipient's credits can arrive held in the same step
1461
+ - Partial credit holds, once a real use case shows whether two holds on one credit should overlap or add up
721
1462
  - Multi-step transfer policies beyond `:preserve`, `:none`, and fixed `expires_at`
722
1463
 
723
1464
  ## Development