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.
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.
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.
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.
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.
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.
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.
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:
| Layer | Keyed on | Catches |
|---|---|---|
| API idempotency | (scope, idempotency-key, hash) | client retries |
| Provider events | (provider, reference) | re-delivered webhooks |
| Formance header | Idempotency-Key | ledger-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.