wallets 0.2.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +683 -27
- data/lib/generators/wallets/install_generator.rb +1 -1
- data/lib/generators/wallets/templates/add_wallets_holds.rb.erb +150 -0
- data/lib/generators/wallets/templates/create_wallets_tables.rb.erb +72 -3
- data/lib/generators/wallets/templates/initializer.rb +51 -18
- data/lib/generators/wallets/upgrade_generator.rb +76 -0
- data/lib/wallets/callback_context.rb +1 -0
- data/lib/wallets/callback_dispatcher.rb +51 -0
- data/lib/wallets/callbacks.rb +9 -40
- data/lib/wallets/configuration.rb +19 -11
- data/lib/wallets/engine.rb +5 -9
- data/lib/wallets/models/allocation.rb +48 -16
- data/lib/wallets/models/concerns/embeddable.rb +36 -0
- data/lib/wallets/models/concerns/has_metadata.rb +73 -0
- data/lib/wallets/models/concerns/has_wallets.rb +25 -18
- data/lib/wallets/models/hold.rb +196 -0
- data/lib/wallets/models/transaction.rb +102 -54
- data/lib/wallets/models/transfer.rb +38 -46
- data/lib/wallets/models/wallet.rb +1444 -298
- data/lib/wallets/railtie.rb +6 -3
- data/lib/wallets/version.rb +1 -1
- data/lib/wallets/whole_number.rb +37 -0
- data/lib/wallets.rb +14 -2
- data/sig/wallets.rbs +98 -0
- metadata +17 -17
- data/.gitignore +0 -36
- data/.rubocop.yml +0 -8
- data/.simplecov +0 -37
- data/AGENTS.md +0 -5
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -30
- data/context7.json +0 -4
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
|
|
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
|

|
|
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-
|
|
54
|
-
| **
|
|
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
|
|
@@ -249,7 +253,7 @@ Transfers require both wallets to use the same asset and the same wallet class.
|
|
|
249
253
|
|
|
250
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.
|
|
251
255
|
|
|
252
|
-
Flip one flag and it applies consistently to
|
|
256
|
+
Flip one flag and it applies consistently to `wallet.debit`, `wallet.transfer_to`, `wallet.reserve` and `reservation.capture!`:
|
|
253
257
|
|
|
254
258
|
```ruby
|
|
255
259
|
Wallets.configure do |config|
|
|
@@ -267,13 +271,13 @@ A few things to know:
|
|
|
267
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`).
|
|
268
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.
|
|
269
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.
|
|
270
|
-
- **`: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`
|
|
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.
|
|
271
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.
|
|
272
|
-
- **`:insufficient_balance` callback.** Fires only when a debit or
|
|
273
|
-
- **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
|
|
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).
|
|
274
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.
|
|
275
|
-
- **
|
|
276
|
-
- **
|
|
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`.
|
|
277
281
|
|
|
278
282
|
### Expiring balances
|
|
279
283
|
|
|
@@ -287,7 +291,332 @@ user.wallet(:coins).credit(
|
|
|
287
291
|
)
|
|
288
292
|
```
|
|
289
293
|
|
|
290
|
-
Debits
|
|
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.
|
|
291
620
|
|
|
292
621
|
## Configuration
|
|
293
622
|
|
|
@@ -316,6 +645,8 @@ end
|
|
|
316
645
|
|
|
317
646
|
`wallets` ships with lifecycle callbacks you can use for notifications, analytics, or product logic.
|
|
318
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
|
+
|
|
319
650
|
```ruby
|
|
320
651
|
Wallets.configure do |config|
|
|
321
652
|
config.on_balance_credited do |ctx|
|
|
@@ -348,6 +679,7 @@ Useful fields on `ctx` include:
|
|
|
348
679
|
- `ctx.previous_balance`
|
|
349
680
|
- `ctx.new_balance`
|
|
350
681
|
- `ctx.transaction`
|
|
682
|
+
- `ctx.hold` (hold events, see [Hold callbacks](#hold-callbacks))
|
|
351
683
|
- `ctx.category`
|
|
352
684
|
- `ctx.metadata`
|
|
353
685
|
|
|
@@ -359,14 +691,14 @@ Both gems handle balances, but they solve different problems:
|
|
|
359
691
|
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
360
692
|
β usage_credits β
|
|
361
693
|
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
|
362
|
-
β β Subscriptions, Credit Packs, Pay
|
|
694
|
+
β β Subscriptions, Credit Packs, Pay Integration, Fulfillmentβ β
|
|
363
695
|
β β Operations DSL, Pricing, Refunds, Webhook Handling β β
|
|
364
696
|
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
|
365
697
|
β β β
|
|
366
698
|
β βΌ β
|
|
367
699
|
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
|
368
700
|
β β wallets β β
|
|
369
|
-
β β Balance, Credit, Debit, Transfer, Expiration,
|
|
701
|
+
β β Balance, Credit, Debit, Transfer, Expiration, FEFO, β β
|
|
370
702
|
β β Audit Trail, Row-Level Locking, Multi-Asset β β
|
|
371
703
|
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
|
|
372
704
|
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -489,6 +821,25 @@ player.wallet(:gold).transfer_to(
|
|
|
489
821
|
)
|
|
490
822
|
```
|
|
491
823
|
|
|
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
|
+
)
|
|
832
|
+
|
|
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
|
+
|
|
492
843
|
### Home services booking app
|
|
493
844
|
|
|
494
845
|
A home-services app where the platform collects the customer payment, keeps its fee, and later pays the provider:
|
|
@@ -545,6 +896,32 @@ lucia.wallet(:usd_cents).debit(
|
|
|
545
896
|
)
|
|
546
897
|
```
|
|
547
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
|
+
)
|
|
909
|
+
|
|
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 })
|
|
917
|
+
end
|
|
918
|
+
|
|
919
|
+
sara.wallet(:usd_cents).balance # => 4250
|
|
920
|
+
sara.wallet(:usd_cents).held_balance # => 0
|
|
921
|
+
```
|
|
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
|
+
|
|
548
925
|
### Loyalty programs & Reward points
|
|
549
926
|
|
|
550
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:
|
|
@@ -663,12 +1040,34 @@ user.wallet(:points).debit(
|
|
|
663
1040
|
)
|
|
664
1041
|
```
|
|
665
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
|
+
|
|
666
1064
|
**Loyalty-specific patterns:**
|
|
667
1065
|
|
|
668
1066
|
| Pattern | Implementation |
|
|
669
1067
|
|---------|----------------|
|
|
670
1068
|
| **Tiered earning** | `credit(amount * tier_multiplier, ...)` |
|
|
671
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:` |
|
|
672
1071
|
| **Family pooling** | `transfer_to` family wallet |
|
|
673
1072
|
| **Gifting** | `transfer_to` friend's wallet |
|
|
674
1073
|
| **Earn + burn in one transaction** | `debit` points, `credit` new promo points |
|
|
@@ -686,35 +1085,242 @@ user.wallet(:points).debit(
|
|
|
686
1085
|
|
|
687
1086
|
### Gig economy / Driver earnings
|
|
688
1087
|
|
|
689
|
-
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:
|
|
690
1089
|
|
|
691
1090
|
```ruby
|
|
692
1091
|
class Driver < ApplicationRecord
|
|
693
1092
|
has_wallets default_asset: :usd_cents
|
|
694
1093
|
end
|
|
695
1094
|
|
|
696
|
-
# Ride completed
|
|
1095
|
+
# Ride completed: the fare is pending for 3 days, while the rider can still dispute it
|
|
697
1096
|
driver.wallet(:usd_cents).credit(
|
|
698
1097
|
1250, # $12.50 base fare
|
|
699
1098
|
category: :ride_fare,
|
|
1099
|
+
available_at: 3.days.from_now,
|
|
700
1100
|
metadata: { ride_id: ride.id, distance_miles: 5.2 }
|
|
701
1101
|
)
|
|
702
1102
|
|
|
703
1103
|
# Tip added later
|
|
704
|
-
driver.wallet(:usd_cents).credit(
|
|
1104
|
+
tip = driver.wallet(:usd_cents).credit(
|
|
705
1105
|
300, # $3.00 tip
|
|
706
1106
|
category: :tip,
|
|
707
1107
|
metadata: { ride_id: ride.id, rider_id: rider.id }
|
|
708
1108
|
)
|
|
709
1109
|
|
|
710
|
-
# Weekly payout
|
|
1110
|
+
# Weekly payout: `balance` is what has cleared, here the $3.00 tip
|
|
711
1111
|
driver.wallet(:usd_cents).debit(
|
|
712
1112
|
driver.wallet(:usd_cents).balance,
|
|
713
1113
|
category: :weekly_payout,
|
|
714
1114
|
metadata: { payout_date: Date.current, bank_account: "****1234" }
|
|
715
1115
|
)
|
|
1116
|
+
|
|
1117
|
+
driver.wallet(:usd_cents).held_balance # => 1250, paid out next week
|
|
1118
|
+
```
|
|
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")
|
|
716
1320
|
```
|
|
717
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
|
+
|
|
718
1324
|
## Perfect use cases
|
|
719
1325
|
|
|
720
1326
|
`wallets` is best for **closed-loop value** inside your app β where the app itself is the source of truth.
|
|
@@ -722,10 +1328,12 @@ driver.wallet(:usd_cents).debit(
|
|
|
722
1328
|
| Use case | Example | Why `wallets` fits |
|
|
723
1329
|
|----------|---------|-------------------|
|
|
724
1330
|
| **Telecom / data plans** | Mobile data that users can share | Multi-asset (`:data_mb`, `:sms`, `:minutes`), transfers, expiration |
|
|
725
|
-
| **Game economies** | FarmVille, Fortnite, OGame | Multiple resources, trading between players |
|
|
726
|
-
| **Marketplaces** | Etsy, Fiverr, Airbnb | Seller earnings, buyer credits, platform settlements |
|
|
727
|
-
| **
|
|
728
|
-
| **
|
|
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 |
|
|
729
1337
|
| **Multi-currency** | Travel apps, international platforms | Per-currency wallets (`:eur`, `:usd`, `:gbp`) |
|
|
730
1338
|
| **Store credit** | Gift cards, refund credits | Simple balance with full audit trail |
|
|
731
1339
|
|
|
@@ -733,6 +1341,7 @@ driver.wallet(:usd_cents).debit(
|
|
|
733
1341
|
- Users hold **multiple types of value** (not just one "credits" balance)
|
|
734
1342
|
- Users **transfer value to each other** (gifts, trades, P2P payments)
|
|
735
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)
|
|
736
1345
|
- You need a **full audit trail** (not just a cached integer)
|
|
737
1346
|
- The app is the **source of truth** (not syncing with external ledgers)
|
|
738
1347
|
|
|
@@ -753,7 +1362,6 @@ See [usage_credits](https://github.com/rameerez/usage_credits) β it uses `wall
|
|
|
753
1362
|
|
|
754
1363
|
- **Banking infrastructure** β transfers to/from bank rails, cards, ACH, SEPA
|
|
755
1364
|
- **Regulated stored-value** β KYC, AML, licensing, custody requirements
|
|
756
|
-
- **Escrow systems** β pending, available, reserved, delayed-release states
|
|
757
1365
|
- **FX conversion** β multi-currency conversion with exchange rates
|
|
758
1366
|
- **Full accounting** β charts of accounts, journal entries, financial reporting
|
|
759
1367
|
- **Blockchain/crypto** β consensus, custody, cryptographic guarantees
|
|
@@ -770,21 +1378,23 @@ See [usage_credits](https://github.com/rameerez/usage_credits) β it uses `wall
|
|
|
770
1378
|
|
|
771
1379
|
## Is this production-ready?
|
|
772
1380
|
|
|
773
|
-
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-
|
|
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.
|
|
774
1382
|
|
|
775
1383
|
In practice, that means you get:
|
|
776
1384
|
|
|
777
1385
|
- a full transaction history instead of just a cached balance
|
|
778
|
-
-
|
|
1386
|
+
- FEFO consumption of the soonest-expiring balance buckets, with oldest-first ties
|
|
779
1387
|
- linked debit/credit records for transfers between users
|
|
780
1388
|
- concurrency protection when multiple writes hit the same wallet
|
|
781
1389
|
- enough structure to support marketplace balances, peer payments, rewards, and in-game assets inside a real production app
|
|
782
1390
|
|
|
783
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`.
|
|
784
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
|
+
|
|
785
1395
|
## Can it support payments between users?
|
|
786
1396
|
|
|
787
|
-
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)).
|
|
788
1398
|
|
|
789
1399
|
But it is not a blockchain and not a full payments stack.
|
|
790
1400
|
|
|
@@ -792,17 +1402,63 @@ What it does not do for you:
|
|
|
792
1402
|
|
|
793
1403
|
- external settlement to banks or cards
|
|
794
1404
|
- KYC/AML/compliance
|
|
795
|
-
- escrow, reserves, or held balances
|
|
796
1405
|
- FX conversion between assets
|
|
797
1406
|
- disputes, chargebacks, or processor reconciliation
|
|
798
1407
|
- cryptographic consensus or custody guarantees
|
|
799
1408
|
|
|
800
1409
|
So the right framing is: strong internal wallet/accounting primitive, not money infrastructure by itself.
|
|
801
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
|
+
|
|
802
1456
|
## TODO
|
|
803
1457
|
|
|
1458
|
+
- Dynamic association class resolution for embedded subclasses (so embedders don't need to re-declare associations)
|
|
804
1459
|
- First-class transfer reversal/refund API built on compensating ledger entries
|
|
805
|
-
-
|
|
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
|
|
806
1462
|
- Multi-step transfer policies beyond `:preserve`, `:none`, and fixed `expires_at`
|
|
807
1463
|
|
|
808
1464
|
## Development
|