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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +129 -0
- data/README.md +803 -62
- 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 +52 -10
- 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 +1445 -250
- 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 -16
- 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/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
|
|
@@ -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: :
|
|
107
|
+
has_wallets default_asset: :eur_cents
|
|
102
108
|
end
|
|
103
109
|
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
seller_earnings = order_total - platform_fee
|
|
852
|
+
class Marketplace < ApplicationRecord
|
|
853
|
+
has_wallets default_asset: :usd_cents
|
|
854
|
+
end
|
|
430
855
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
#
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
#
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
#
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
| **
|
|
643
|
-
| **
|
|
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-
|
|
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
|
-
-
|
|
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
|
-
-
|
|
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
|