walletflow· 01 system
Chapter 01 · The system

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.

styleDDD · hexagonal
runtimeBun · Hono
ledgerFormance OSS
posting modescript (default)
Fig. 1 · play

Every event is a balanced transaction

template · payin_collection.num
    Σ debits0
    Σ credits0
    conserved per asset
    01.1

    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.

    DOMAIN money · ledger · coa pure · no I/O APPLICATION · use cases + ports INFRASTRUCTURE · adapters LedgerPortProviderPortCatalogPortCoaPortFormanceProvider HTTPPostgresAWS
    Fig. 2Three rings, dependencies pointing inward. Ports live on the application boundary; adapters plug in from outside. The domain never names a single infrastructure type.
    01.2

    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.

    Why split

    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.

    01.3

    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.

    ClassExamplesNormal balance
    ASSETbank:* · gps_pool:*debit
    LIABILITYmerchants:* · holds:* · liabilities:vat:*credit
    INCOMErevenue:*credit
    EXPENSEexpenses:*debit
    COSTcosts:*debit
    CAPITALcapital:*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.

    01.4

    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.

    Posting mode matters

    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.