Reservations
The shop starts selling made-to-order items. The money is taken when the order ships, days after it was approved — but an order must not be approved unless the money is there, and that money must not be spent on something else in the meantime. If the order is cancelled, the customer gets it back.
Two simple designs do not hold:
- Check now, take later.
approvereads the balance, sees enough, and approves. By the time the order ships, the customer has spent the same money on another order. The check was true when it was made and says nothing about later. - Take now, give back on failure.
approvedebits, a cancellation credits. It works, but the customer sees money leave and return for an order that never shipped, and the history shows two movements for nothing.
A reservation — a hold — is the third way: set the amount aside now, and later either take it or let it go.
§A hold is part of the wallet
billing.wallets owns the balance, so it owns the holds: a limit lives with its counter. A hold moves no money. It
is a note in the wallet, under a key the caller chooses, that so much is spoken for. The wallet
is the one of Describe State, one
per customer under the customer’s id, with a balance; its entity gains held and holds, and
the component three operations and two receivers:
# components/billing.wallets/manifest.toa.yaml
entity:
associated: true
properties:
balance: { type: integer, minimum: 0 }
held: { type: integer, minimum: 0 }
holds:
type: object
additionalProperties: { type: integer, minimum: 1 }
blank:
balance: 0
held: 0
holds: {}
operations:
hold:
concurrency: retry
query: true
input:
type: object
properties:
ref: { type: string }
amount: { type: integer, minimum: 1 }
required: [ref, amount]
errors: [INSUFFICIENT_FUNDS]
settle:
concurrency: retry
query: true
input: &ref
type: object
properties:
ref: { type: string }
required: [ref]
release:
concurrency: retry
query: true
input: *ref
receivers:
store.orders.shipped: settle
store.orders.cancelled: release Three operations, each a few lines. Where there is nothing to change they set DISCARD, which stores nothing:
// components/billing.wallets/operations/hold.ts
import type { HoldInput, State } from '../types/index.d.ts'
export async function transition (input: HoldInput, entry: State) {
if (entry.holds[input.ref] !== undefined) {
entry.DISCARD = true // asked again for the same order: it is held already
return entry
}
if (entry.balance - entry.held < input.amount) return new Error('INSUFFICIENT_FUNDS')
entry.holds[input.ref] = input.amount
entry.held += input.amount
return entry
} // components/billing.wallets/operations/settle.ts
import type { SettleInput, State } from '../types/index.d.ts'
export async function transition (input: SettleInput, entry: State) {
const amount = entry.holds[input.ref]
if (amount === undefined) {
entry.DISCARD = true // settled or released before
return entry
}
delete entry.holds[input.ref]
entry.held -= amount
entry.balance -= amount
entry.TRAILERS.amount = -amount // for the wallet's history
entry.TRAILERS.type = 'payment'
entry.TRAILERS.ref = input.ref
return entry
} // components/billing.wallets/operations/release.ts
import type { ReleaseInput, State } from '../types/index.d.ts'
export async function transition (input: ReleaseInput, entry: State) {
const amount = entry.holds[input.ref]
if (amount === undefined) {
entry.DISCARD = true // nothing is held under this key
return entry
}
delete entry.holds[input.ref]
entry.held -= amount
return entry
} What a customer can spend is balance - held. Every operation that takes money checks that, not
the balance: debit (Refuse a Request) refuses when entry.balance - entry.held < input.amount. What settle leaves in TRAILERS is what the wallet’s ledger is written from.
§Ask before the work, settle after it
client ──call──> store.orders.approve
└─call─> billing.wallets.hold yes, or INSUFFICIENT_FUNDS
status: approved
… days pass …
staff ───call──> store.orders.ship status: shipped
~ store.orders.shipped ────> billing.wallets.settle takes what was held
customer ─call─> store.orders.cancel status: cancelled
~ store.orders.cancelled ──> billing.wallets.release lets it go The hold is a call: approve cannot answer the customer without knowing
whether the money is there. It is the approve of store.orders (Create a Component), written again, with INSUFFICIENT_FUNDS added to its errors:
// components/store.orders/operations/approve.ts
import type { Context, State } from '../types/index.d.ts'
export async function transition (input: null, entry: State, context: Context) {
if (entry.status === 'approved') {
entry.DISCARD = true
return entry
}
if (entry.status !== 'pending') {
await context.remote.billing.wallets.release({
query: { id: entry.customer },
input: { ref: entry.id }
})
return new Error('NOT_PENDING')
}
const held = await context.remote.billing.wallets.hold({
query: { id: entry.customer },
input: { ref: entry.id, amount: entry.total }
})
if (held instanceof Error) return held
entry.status = 'approved'
return entry
} The release in the middle is explained below.
Settling and releasing are reactions to events: the order shipped or was
cancelled, that is stored, and the wallet follows. ship and cancel are the steps of A Process Across Components; the events shipped and cancelled are declared like approved, by the status the order reaches. Order is the type generated from the entity of store.orders.
// components/billing.wallets/receivers/store.orders.shipped.ts
// components/billing.wallets/receivers/store.orders.cancelled.ts
import type { Entity as Order } from '@components/store.orders'
export function request (order: Order) {
return { query: { id: order.customer }, input: { ref: order.id } }
} ship does not wait for the wallet and cannot be failed by it. That is safe only because of the
hold: settle takes exactly what was set aside, so there is no “not enough money” left to
answer. The question that can be refused was asked earlier, when a refusal could still stop the
order.
Ask before the work, where “no” can still stop it — that is a call that may refuse. Settle after the work, where “no” would come too late — that is a reaction that never refuses.
Try it with a wallet that holds 5000, credited as in A Wallet and Its Ledger:
$ npm run restart
$ npx toa call billing.wallets.hold "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { ref: 'o1', amount: 3000 } }"
$ npx toa call billing.wallets.hold "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { ref: 'o2', amount: 3000 } }"
$ npx toa call billing.wallets.settle "{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { ref: 'o1' } }" The first answers the wallet with held: 3000. The second answers the error INSUFFICIENT_FUNDS: 5000 is there, but only 2000 of it is free. The third leaves { balance: 2000, held: 0, holds: {} }.
§The key does the work
Every hold is stored under a ref — here the id of the order — and all three operations are
written around it. That one decision answers most of what can go wrong.
The same request arrives twice. A second hold for the same order finds the key and changes
nothing. A second settle or release finds no key and changes nothing. None of them adds
blindly, so none needs to remember calls; the hold itself is the record. This is the “set, don’t
add” of The Same Message Twice, made possible by a key from the business: an
operation is idempotent when running it again leaves what the first run left.
Settle and release both arrive. Whichever comes first removes the hold; the other finds nothing. An order cannot be both charged and refunded.
Two orders reach for the same money. Two hold calls on one wallet at the same moment are
two transitions of one entity with concurrency: retry: one is stored, the other runs again on
the state that includes the first hold and is refused if the rest is not enough
(Two Writers at Once).
§The late hold
approve asks for the hold before its own change is stored, and a transition can run again.
Suppose the customer cancels in the same instant:
approvereads the order aspendingand gets its hold.cancelis stored first. Its event releases the hold — or finds none yet, if it is quicker than step 1.- The write of
approveis refused, because the order changed under it, andapproveruns again. It now readscancelled.
If approve just answered NOT_PENDING here, the hold from step 1 could stay in the wallet
with no order that will ever settle or release it. So an approve that finds an order it cannot
approve lets go of whatever is held for it. Where nothing is held, release changes nothing.
The rule is general: whatever a transition does outside its own state, before that state is
stored, must be safe to do again and possible to undo — a key takes care of the first, an
operation like release of the second.
§A guard for what must always hold
Three operations keep held equal to the sum of the holds, and a fourth, debit, has to respect
it. The next operation somebody adds may forget. A guard holds the rule
for all of them:
// components/billing.wallets/guards/holds.ts
import type { Entity } from '../types/index.d.ts'
export function guard (wallet: Entity) {
const sum = Object.values(wallet.holds).reduce((total, amount) => total + amount, 0)
return wallet.held === sum && wallet.held <= wallet.balance
} A write that would leave more held than the wallet has is not stored, whichever operation made it.
§A hold that nobody ends
Nothing above makes a hold expire. It ends when its order ends, so every way an order can end
must lead to settle or release — here both do, by an event. An order that never ends keeps its
hold for good. Give such orders a deadline and cancel the ones past it, and the hold follows by
the same event as any other cancellation: Work on the Clock.
If the wallet is unreachable when the order ships, the event waits and settle runs when it is
back; the customer’s free money was already reduced on the day of the approval, so nothing can be
spent twice in between.
§Is a hold needed at all
A reservation costs three operations and a race to think through. Two simpler arrangements are right more often than it seems:
| Arrangement | Fits when | Costs |
|---|---|---|
Take now, give back on failure: debit in approve, credit on cancelled | the work follows at once and rarely fails | money visibly leaves and returns |
| Hold, then settle or release | time passes between the promise and the work | the code above |
| Take afterwards, reserve nothing | the price is known only after the work, and a small debt is acceptable | some charges find too little |
The third is how usage is billed: the charge arrives after the work is done, by an event, and it cannot refuse — the work cannot be taken back. So it takes what is there and records the rest as owed. That is a business decision; make it knowingly, and keep the number that measures what it costs.
The first is in A Process Across Components, with the refund as a compensation. How the wallet keeps a history of every movement is in A Wallet and Its Ledger.
The same three operations reserve anything that is counted: stock for an order, a seat, one of a
voucher’s uses. fulfilment.stock.reserve under the order’s id, commit when it ships, release when it is cancelled.
§Checklist
- A hold lives in the component that owns what is being held, next to the number it reduces.
- Every hold has a key from the business — the order’s id — and one key means one hold.
- Asking for a hold is a call and may refuse; settling and releasing react to events and never do.
hold,settleandreleaseeach look for the key first, so a repeat changes nothing.- Whoever asks for a hold before storing its own change releases it when that change is not made.
- Everything that spends checks
balance - held; a guard holds the rule for all writers. - Every way the work can end reaches
settleorrelease, and stuck work is ended by a deadline.