A ledger first, a service second.
walletflow is small on purpose. The interesting complexity lives in configuration — the chart of accounts, the GL mappings, the Numscript templates — not in business code. The code's job is to translate an event into a balanced ledger transaction and refuse to do anything that wouldn't balance.
Every event is a balanced transaction
Ports & adapters
The domain sits in the middle and knows nothing of the outside world. It speaks only through ports — interfaces — and the outside world plugs in through adapters. Swap Formance for an in-memory ledger in a test; the domain never notices.
Three rings. At the centre, domain: money, currency, postings, account naming, Numscript builders, the chart of accounts. Pure functions and value objects — no I/O, no fetch, no SQL. Around it, the application layer: use cases that orchestrate the domain and the ports. On the outside, infrastructure: the adapters that actually talk to Formance, payout rails, Postgres and AWS.
The rule is one-directional: dependencies point inward. Infrastructure imports application and domain; the domain imports nothing of theirs. That is what makes the system testable — and what let a real Formance ledger be dropped into the suite without touching a line of business logic.
The Formance split
walletflow does not implement a ledger. It orchestrates one. Formance Ledger — an open-source Go service — owns the immutable postings; walletflow owns everything a ledger shouldn't: idempotency, product flows, read models, reconciliation.
Walletflow owns
DDD use cases and API contracts; business idempotency and request replay; provider callback normalization and payout orchestration; the Postgres read models, outbox, template catalog, chart of accounts and feature flags; and the Formance HTTP adapter.
Formance owns
The immutable double-entry postings, its own internal Postgres, and its deployment lifecycle. Walletflow never writes to Formance's database — only through its HTTP API. The two are deployed and scaled independently.
A correct ledger is a knowledge problem, not an engineering one. Formance already solves immutability, atomic multi-leg transactions, Numscript safety and bi-temporality. Re-implementing that is where the old system's bugs lived.
The double-entry model
Money is never created or destroyed — only moved. Every transaction is a set of postings, each moving an amount from one account to another. Accounts are hierarchical, namespaced addresses; the chart of accounts maps Finance's GL codes onto them.
Account addresses are colon-separated and meaningful: merchants:12345:ngn, bank:providus:collections:ngn, revenue:payin:ngn, liabilities:vat:ngn. Each maps to a COA row with a class, and each class has a normal balance — the side it naturally sits on.
| Class | Examples | Normal balance |
|---|---|---|
| ASSET | bank:* · gps_pool:* | debit |
| LIABILITY | merchants:* · holds:* · liabilities:vat:* | credit |
| INCOME | revenue:* | credit |
| EXPENSE | expenses:* | debit |
| COST | costs:* | debit |
| CAPITAL | capital:* | credit |
A chart of accounts row carries the GL code, the class, and a parameterized account pattern like merchants:{merchantId}:{currency}. The resolver substitutes context to produce the concrete address — this is the linking layer between Finance's GL codes and the operational ledger.
There is no balance column
The single most important design choice: walletflow stores no wallet balances. A balance is a query over immutable postings, not a row you mutate. This is the strongest possible answer to double-crediting.
The classic wallet bug is two concurrent operations reading the same balance and both writing back — a lost update. walletflow has no balance to race on. To pay out, the ledger executes send $amount (source = $merchant …); if the merchant's derived balance is insufficient, Formance's Numscript rejects the whole transaction atomically. There is no check-then-act window.
Two more guards sit in front of the ledger. Every command claims an idempotency key with a request hash, so retries replay rather than re-execute. And provider webhooks dedupe on (provider, reference), so a re-delivered notification can never post twice.
The overdraft guard only holds in script mode (the default), where Numscript runs. Raw postings mode bypasses it — Formance will let an account go negative — so it's reserved for the balance-enforcing in-memory test double.