walletflow· 02 how it works
Chapter 02 · How it works

Money moves in states, not steps.

A synchronous request posts to the ledger and returns. But a payout leaves the building — it talks to a bank that might time out, succeed silently, or fail. So payouts run as a state machine that never retries an ambiguous submission as a fresh payout. Walk it below.

sync pathHTTP → ledger
async pathoutbox → SNS / SFN
payoutStep Functions
requery cap10
Fig. 1 · walk the machine

The payout saga

Reserve holds the merchant's funds, then this state machine drives the money out. Click an event to advance — the active state glows and live transitions light up.

Submit after submit Wait + requery Requery after requery Settle hold Release hold Dead-letter Complete Manual review
02.1

The request lifecycle

A synchronous command takes the same shape every time: validate, claim idempotency, build a domain draft, post to the ledger, derive the result. The use case is orchestration; the domain builds the transaction; the adapter talks to Formance.

The controller does almost nothing — it parses HTTP, checks auth and the idempotency key, and calls a use case. Inside, every command runs through one spine: executeIdempotent(scope, key, …) claims a Postgres row; on a fresh claim it builds the draft, posts it, and records the response; on a replay it returns the stored result; on a hash mismatch it 409s.

APIServicePostgresDomainFormancecommandclaim idempotencybuild draftpostings + Numscripttxid + balances
Fig. 2One command, five participants. The idempotency claim happens before any ledger write, so a duplicate never reaches Formance twice.
02.2

Pay-in and payout

Pay-in is synchronous: a provider webhook credits the merchant net of fee and VAT in a single balanced transaction. Payout is two-phase: reserve the funds into a hold, then let the saga settle or release them based on what the bank actually did.

Pay-in

The gross arrives at a settlement bank from @world; the merchant is credited the net, fee books to revenue:payin, VAT to liabilities:vat. The provider event is recorded first — a re-delivered webhook is rejected as already processed.

Payout — reserve, then saga

reservePayout moves amount + fee + vat from the merchant into a holds: account and enqueues PAYOUT_SAGA_REQUESTED to the outbox. The relay starts a Step Functions execution — the machine you walked above. On confirmed success the hold settles (funds leave to the bank, then @world); on confirmed failure it releases back to the merchant.

The non-negotiable

An ambiguous submission (timeout, 5xx) is never retried as a new payout. It goes to bounded requery; if that exhausts, the hold lands in manual_review and operations inspect the real provider state before settling or releasing. Money is never moved on a guess.

02.3

COA → GL → Numscript

How an abstract event becomes concrete ledger accounts. This pipeline is the spec's "critical linking layer": an event type selects a GL mapping, the mapping's GL codes resolve through the chart of accounts into Formance addresses, and a Numscript template moves the money.

event type PAYIN_COLLECTION GL mapping debit 1000 · credit 2004 COA resolver {merchantId},{currency} Numscript send …
Fig. 318 templates ↔ 18 GL mappings, a bijection verified by a drift check that returns zero. Finance can supply a granular CSV; the bundled seed is the baseline.

The same metadata that drives this pipeline — account class, GL code, workflow view — is written onto every Formance account, which is what lets the trial balance and ALM views in chapter 03 be derived from postings rather than maintained separately.

02.4

The async backbone

Side effects don't happen inside the ledger write — they're recorded as outbox events in the same transaction, then dispatched by a relay. At-least-once delivery meets Postgres idempotency, and duplicates dissolve.

When a command posts, it also writes an outbox_events row. A separate relay worker claims pending events with row locks (SKIP LOCKED) and dispatches them: PAYOUT_SAGA_REQUESTED starts a Step Functions execution; everything else publishes to SNS. A failed dispatch backs off via available_at; an ExecutionAlreadyExists is treated as success.

Three idempotency layers stack so that no duplicate ever moves money twice:

LayerKeyed onCatches
API idempotency(scope, idempotency-key, hash)client retries
Provider events(provider, reference)re-delivered webhooks
Formance headerIdempotency-Keyledger-level replays

The notes that informed this chose Postgres conditional claims over a DynamoDB idempotency store — one relational source of truth, equally auditable, simpler to operate.