A Wallet and Its Ledger
Customers of the shop keep money in it: they top up, pay for orders from the balance, and get refunds back into it. Finance wants every movement as a line that is never rewritten, and the lines must add up to the balance — always, not “after the nightly job”.
Three naive designs, and what breaks each:
- Compute the balance by adding up the lines. A payment reads the lines, sees 5000, and inserts “−4000”. Two payments at the same moment both read 5000 and both insert. The customer spent 8000 of 5000. Each insert is a new object, so nothing conflicts and nothing stops it.
- Change the balance, then call the ledger to write the line. The process stops between the two. The balance moved and no line says why; the books never add up again.
- Just subtract. The request that tops up is retried by a client that saw no answer, or the event that pays is delivered twice. The money moves twice.
§The wallet
One object per customer — the customer’s wallet in billing.wallets — holds the number, and
every change of it is one write of that object. This is the whole component:
# components/billing.wallets/manifest.toa.yaml
name: wallets
namespace: billing
entity:
associated: true
properties:
balance: &amount
type: integer # minor units: cents, never a fraction
minimum: 0
held: *amount # set aside for orders not yet shipped
paid: # what was taken, under what it was for
type: object
additionalProperties: { type: integer, minimum: 1 }
required: [balance, held]
blank:
balance: 0
held: 0
paid: {}
operations:
credit:
concurrency: retry
once: true
query: true
input:
type: object
properties:
amount: { type: integer, minimum: 1 }
type: { type: string, enum: [topup, refund, goodwill] }
ref: { type: string } # what it was for: a checkout, an order
required: [amount, type, ref]
debit:
concurrency: retry
query: true
errors: [INSUFFICIENT_FUNDS]
input:
type: object
properties:
amount: { type: integer, minimum: 1 }
ref: { type: string } # what it is for, and one debit per ref
required: [amount, ref]
receivers:
billing.topups.paid: credit // components/billing.wallets/operations/credit.ts
import type { CreditInput, State } from '../types/index.d.ts'
export async function transition (input: CreditInput, entry: State) {
entry.balance += input.amount
entry.TRAILERS.amount = input.amount
entry.TRAILERS.type = input.type
entry.TRAILERS.ref = input.ref
return entry
} // components/billing.wallets/operations/debit.ts
import type { DebitInput, State } from '../types/index.d.ts'
export async function transition (input: DebitInput, entry: State) {
if (entry.paid[input.ref] !== undefined) {
entry.DISCARD = true // asked again for the same thing: the money is taken
return entry
}
if (entry.balance - entry.held < input.amount) return new Error('INSUFFICIENT_FUNDS')
entry.balance -= input.amount
entry.paid[input.ref] = input.amount
entry.TRAILERS.amount = -input.amount
entry.TRAILERS.type = 'payment'
entry.TRAILERS.ref = input.ref
return entry
} // components/billing.wallets/guards/held.ts
import type { Entity } from '../types/index.d.ts'
export function guard (wallet: Entity) {
return wallet.held <= wallet.balance
} billing.topups.paid, which the receiver binds to credit, announces a payment recorded once;
the event and the receiver’s file are in Receiving Webhooks.
Each line of the manifest answers one of the failures:
associated, with ablank. The wallet’s id is the customer’s id. A customer who never topped up has a wallet that readsbalance: 0; there is no “create a wallet” step to forget or to fail halfway (Keep Data About a Customer without Creating It). The wallet with abalanceand nothing else is that of Describe State.- The balance is stored, and changed by a transition with
concurrency: retry. The check “is there enough” and the subtraction happen against one object in one write. Of two payments at once, one is stored; the other is run again against the balance the first one left, and is refused if that is too little (Two Writers at Once). once: trueoncredit. The same call arriving again — a redelivered event, a client’s retry — moves nothing and is answered as the first time (Handle Repeated Calls).paid, and noonceondebit. A debit is for something, and says what inref. The wallet keeps what it took under it, and a second debit for the same thing stores nothing and answers the wallet, as the first did. Why the two differ is below.minimum: 0keeps the balance from going negative whatever operation writes it, and the guard keeps reserved money inside the balance.heldis changed by the hold and release operations of Reservations; here it only matters thatdebitspends frombalance - held.- Integers. An amount is a whole number of the smallest unit. A float drifts by a cent sooner or later.
§One debit per ref
debit is called by store.orders.approve (Refuse a Request), with the order’s id as ref. approve is a transition declared concurrency: retry: when the order changes under it,
it is run again, and calls debit again. once cannot cover that. It recognises a call delivered again, and an attempt made because a write was lost makes a new call; the runtime
refuses a call from such a transition to an operation that declares once (Two Writers at Once).
So debit is safe to repeat by what it is. “The payment for order X” can be asked for by a
second attempt, a redelivery, a second click or another operation altogether, a minute or a month
later, and is taken once. credit has no such name to go by — goodwill money granted twice on
purpose is two credits, and its ref says what it was for, not which one it is — and nothing
that retries calls it: it is reached by an event and by a staff route. There once is right.
The key has a cost: the wallet remembers every ref it has taken money for, and an object
that only grows is read and written whole on every debit. What is remembered, and for how long,
is the wallet’s decision, not the runtime’s. A ref has to stay while a repeat of its debit can
still arrive — here, while the order can still be approved. A wallet that cannot keep them all
gets an operation that deletes the key and a receiver that binds it
to the events after which nobody asks again, as Reservations removes a
hold’s key when the hold is settled or released. A key removed too early makes the late repeat a
second debit.
TRAILERS is where an operation says what happened in this write. The wallet stores what it
has; what moved, of what kind and for what, is not part of the wallet — it is a fact about one
write, and it travels with the event.
§The ledger
The wallet announces every write that moved money, and the payload is the ledger line:
// components/billing.wallets/events/moved.ts
import type { Event } from '@toa.io/core/types'
import type { Entity } from '../types/index.d.ts'
interface Movement {
amount: number
type: string
ref: string
}
export function condition (event: Event<Entity, Partial<Movement>>) {
return event.trailers?.amount !== undefined
}
export function payload (event: Event<Entity, Movement>) {
const { id, balance, VERSION } = event.state
const { amount, type, ref } = event.trailers
return {
owner: id,
amount, // signed: what the balance moved by
type,
ref,
balance, // what the move left behind
version: VERSION,
key: `${id}:${VERSION}` // names this one write of this one wallet
}
} # components/billing.ledger/manifest.toa.yaml
name: ledger
namespace: billing
entity:
properties:
owner: { type: string }
amount: { type: integer }
type: { type: string, enum: [topup, refund, goodwill, payment] }
ref: { type: string }
balance: { type: integer, minimum: 0 }
version: { type: integer }
key: { type: string }
required: [owner, amount, type, ref, balance, version, key]
operations:
record:
query: true
receivers:
billing.wallets.moved: record
exposition:
/:owner:
auth:id: owner
io:output: [id, amount, type, ref, balance, CREATED]
GET:
endpoint: enumerate
query:
sort: version:desc // components/billing.ledger/receivers/billing.wallets.moved.ts
export function request (line: { key: string }) {
return {
query: { criteria: `key=='${line.key}'` },
entity: line
}
} // components/billing.ledger/operations/record.ts
import type { Entity } from '../types/index.d.ts'
export async function effect (input: null, entry: Entity) {
return entry
} # components/billing.ledger/migrations/0001-indexes.yaml
- index:
name: unique_key
keys: { key: asc }
unique: true
- index:
name: index_owner
keys: { owner: asc, version: desc } auth:id lets a customer
list the lines of their own wallet and nobody else’s.
The receiver’s request carries entity: the effect is
given the line stored under that key, or, if there is none, the line is created from what was
handed over. record has nothing left to do. A second arrival of the same event finds the line
and creates nothing, and the unique index holds that
even for two arrivals at the same instant. A ledger line is never deleted, so the index carries
no partial filter: a key stays taken for good.
billing.wallets.credit / debit one write: the balance, and the event with the line
~ billing.wallets.moved key = <wallet id>:<VERSION>
v
billing.ledger.record creates the line under the key, or finds it §Try it
$ npm run restart
$ npx toa call billing.wallets.credit
"{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 5000, type: goodwill, ref: welcome } }"
$ npx toa call billing.wallets.debit
"{ query: { id: '8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f' }, input: { amount: 1800, ref: order-1 } }"
$ npx toa call billing.ledger.enumerate
"{ query: { criteria: 'owner==8d1f0c2e5a7b4c3d9e6f1a2b3c4d5e6f', sort: ['version:desc'], limit: 10 }, output: [amount, type, balance] }" [
{ amount: -1800, type: 'payment', balance: 3200 },
{ amount: 5000, type: 'goodwill', balance: 5000 }
] A debit of more than is there answers the error INSUFFICIENT_FUNDS, changes nothing, and
leaves no line. The invariant is worth a scenario of its own in the project’s tests: after any
sequence of credits and debits, the amounts of a wallet’s lines add up to its balance.
§Why the line is an event of the wallet
The event is recorded in the same write as the balance. Either both exist or neither does; there is no moment in which a process can stop and leave one without the other (Transactional Outbox). The ledger may be down for an hour — the lines are written when it is back, none missing.
When two records must never disagree, make one of them the stored fact, and derive the other from the event of the write that changed it — under a key that names that write.
id:VERSION is such a key. Every stored change of an object raises its VERSION by one, so the
pair names one write, and a line under it can exist once.
The balance is the stored fact, and not the lines, because the balance is what has to be checked and changed in one step. The lines are for reading.
§What can still go wrong
The same credit twice, soon. once answers the repeat with the first reply; one write, one
line.
The same payment reported again after days. once remembers a call for an hour by default,
and a repeat from outside is not the same call anyway. Such repeats are stopped before the
wallet, by a record of what was paid: Receiving Webhooks.
Do not point anything that can repeat for days straight at credit.
A client retries a credit made by staff. A staff route that grants goodwill money is called
with an idempotency-key header; the retry
under the same key is the same call, and once covers it.
The moved event is delivered twice. The second arrival finds the line under its key.
Lines are written out of order. Events may overtake each other, so the line of write 7 can be
stored before the line of write 6. Each line carries the wallet’s version; order by it, not by
when the line was created.
A line is wrong. It stays. A correction is another movement — a refund, a goodwill credit
— and so another line. Expose no operation that changes or deletes a line.
The order service runs approve twice. It calls debit twice with the order’s id as ref.
The second finds the key in paid, moves nothing and leaves no line.
Two debits use one ref for different things. The second is taken for a repeat and takes
nothing. A ref names one payment: use an id that is its own, the order’s, not the customer’s.
A debit that cannot be refused. debit is asked before something is handed over, so it may
say no. A charge that arrives after the work is done has nothing to refuse; what to do then —
take what is there and record the shortfall — is a business decision, discussed in Reservations and Saying No.
once needs a storage that supports it. credit declares it, and a component that declares once starts only on
MongoDB running as a replica set or a sharded cluster
(Handle Repeated Calls).
§Rules of thumb
- Store the balance; never compute it from the lines when deciding.
- One wallet per owner,
associated, with a blank of zero. - Every operation that moves money is a transition with
concurrency: retry, and safe to repeat: keyed by what it is for where it has such a name,oncewhere it has none. - A
onceoperation is never called from a transition that retries. - Amounts are integers in the smallest unit.
- Say what moved in
TRAILERS; a write with no trailer has no line. - The line is the payload of the wallet’s event, keyed
id:VERSION, created at most once by a unique index. - Every movement names what it was for in
ref. - Nothing rewrites a line; a correction is another movement.
- Keep far duplicates away from the wallet with a record of their own.