walletflow· 05 operate the ui
Chapter 05 · Operating the UI

Drive the console without breaking the books.

A task-first field guide to /ops: every operator workflow walked end to end — what you click, what fields each form wants, what a success card looks like, and the exact rejection string you'll see when the ledger refuses. Built from a full click-through of the console (tests T0–T8), so the screens, results and gotchas here are the real ones, not idealized.

surface/ops · 8 pages
commands17 idempotent
verified onmemory adapter
authtoken → cookie 12h
basisT0–T8 run
Fig. 1 · the run-book, playable

Walk a task

Pick what you need to do. Each task steps through the actual console screens — form, submit, result — with a do / see / expect note under every step. Use the arrows, the progress bar, or your ← → keys. Nothing here moves real money; it mirrors what the live console does.

← → to step · click a bar to jump
05.1

Get in, get oriented

Sign in once, learn the seven counters on the overview, and know where the eight pages live. Two minutes here saves you reading the wrong number later.

Signing in

When AUTH_TOKEN is set, /ops redirects you to /ops/login. Paste the operator token and submit — the server hands back an HttpOnly session cookie good for 12 hours and drops you on the overview. A wrong token is refused in place with an inline invalid token message and no session is created — there's no way to half-authenticate. (Where Google sign-in is configured, the same page offers Continue with Google; the allow-list is re-checked on every request.)

If a form suddenly bounces you to /login

Your cookie lapsed (or you're on a shared browser session). Just sign in again — the ledger is untouched. The app does not restart when you do this; on the memory adapter a restart would wipe in-process balances while Postgres keeps the wallets, so never restart the server mid-session to "fix" a login.

Reading the overview

The landing page is read-only — a live pulse of the core. Seven things to read, in the order they matter:

  • Formance / ledger health — the chip that says the adapter answered. Anything but healthy and stop: commands will 500.
  • TX posted / TX failed — cumulative postings. Failed climbing is your first smell of trouble.
  • Outbox pending + oldest pending (s) — events awaiting the relay worker. In dev no relay runs, so a non-zero, ageing pending count is expected, not an incident.
  • Recon drift + template mismatch — both should read 0. These are the headline numbers from the four gates (chapter 03).

Beneath the counters is the recent-ledger-transactions list — every posting with its time, type, status, merchant, amount and reference. This is the fastest place to find a transaction id for a reversal.

05.2

Wallets & statements

A wallet is a merchant + currency. Create it first, read its four-part balance, and know why a freshly-funded wallet can show an empty statement.

Create before you fund

The single most common first-time stumble: a pay-in for a merchant that has no wallet yet fails with wallet <merchant>/<ccy> not found. Despite the name, ensureWallet does not create — it asserts the wallet exists and rejects if it doesn't. So on Wallets, use the create-wallet form (merchant id + currency) first, then fund. One wallet per currency: the same merchant in NGN and USD is two rows.

The four-part balance

Open a merchant and the snapshot reads live from the ledger — balances are derived, never stored:

  • Available — what the merchant can actually spend right now.
  • Ledger — the gross book balance before holds are subtracted.
  • Locked — funds tied up in reserved payout holds (amount + fee + VAT).
  • Reserve — rolling-reserve risk hold, separate from payout locks.

Fund a wallet and available reflects the net credit immediately (a 50,000 pay-in with a 500 fee lands 50,000 to the merchant, books the fee to revenue separately — the fee does not come out of the merchant's number).

"No statement entries" right after a successful pay-in

Expected. The statement is a read-model projected by the statement worker, which does not run in dev. The balance is correct instantly; the statement lags until projection. Click Recon → Rebuild statement read model and the entries appear (idempotent — safe to click repeatedly). In production the worker keeps it current.

05.3

Money-safety you can watch

Two invariants you can prove by hand in the UI: a duplicate never double-credits, and an overdraft never posts — not even one leg of it.

The idempotency key, and when it rotates

Every money form carries a hidden, auto-generated idempotency key. Watch it in the pay-in form: submit successfully and the key rotates to a fresh value, so your next submission is a new command. But after a failed response the key is kept on purpose — retrying a failure is a replay of that same attempt, never a second operation. This is by design; don't "fix" it.

Duplicate collections are deduped at the source

Pay-ins also dedupe on (provider, providerReference) independent of the idempotency key. Submit a second pay-in with the same provider reference (even with a brand-new key) and it is refused with provider event already processed. The wallet shows exactly one credit, never two, and Controls → provider events lists that reference once. This is the defence against a provider firing the same webhook twice.

The overdraft guard is the ledger, not a check

Transfer more than a wallet holds and the ledger rejects it atomically: merchants:<m>:<ccy> has insufficient NGN/2 balance. The source is unchanged, the destination is unchanged, and neither statement shows a partial posting. There is no check-then-act window to lose a race in — the Numscript send either moves the whole amount or nothing. If you ever see one leg post without the other, that is a critical incident; in normal operation it cannot happen.

The minor-unit gotcha in error strings

Rejections name the account in ledger form (NGN/2 = NGN at 2 decimal places). The number in the message is in minor units; the forms take major units. A "10" you typed is "1000" in the message — not a 100× discrepancy.

05.4

Reading what the console tells you

Three kinds of feedback, three meanings. Learn to tell a validation nudge from a ledger refusal from the rare genuine fault.

The console never navigates away to report a problem — results swap in place via HX-Trigger. What renders tells you which layer spoke:

you submit a command form Inline field error red text under the field Red result card the exact ledger reason internal error — 500 rare; check service logs fix the input — required / wrong shape the books said no — balance / currency / flag a real fault —capture it, escalate
Fig. 2Three response shapes. The first two are normal and self-explanatory; the third is the only one worth a log dive.

Worked examples from the console

  • Empty amount → Amount is required (inline). Negative amount → Amount must be a positive decimal (inline).
  • Empty correction reason → Reason is required (inline) — reason is mandatory on every correction.
  • Currency ZZZunsupported currency 'ZZZ': add it to CURRENCY_SCALES… (red card). Unknown currencies are fail-closed — never guessed, never given a default scale.
  • Over-balance transfer / unfunded offramp → … has insufficient … balance (red card). Expected, not a bug.
When you do see a raw 500

A couple of edge actions surface internal error — check service logs instead of a clean conflict — re-settling an already-settled hold, or reversing with a reference that was already used. The money invariant still holds (no double effect), but the message is unhelpful. Treat a 500 as "this command didn't change anything; find out why" rather than retrying blindly.

05.5

The payout hold lifecycle

Reserve locks funds; settle or release closes the hold — exactly one of them, ever. This is the manual half of the payout saga, and the page you'll live in most.

A payout starts as a reserve on Operations: it moves amount + fee + VAT out of available into locked and creates a hold in status reserved, which appears on Payouts with its reference and amounts. A PAYOUT_SAGA_REQUESTED event lands in the outbox (pending in dev — no relay). From there the hold reaches exactly one terminal state:

reserved funds locked manual_review requery exhausted settled provider paid · final released funds returned · final dead-lettersettlerelease
Fig. 3The hold state machine. Settle and release are mutually exclusive terminals; manual_review is a holding pen you resolve with the same two actions.
reserved available→lockedsettled locked→bank, fee/VAT bookreleased locked→availablemanual_review parked, awaiting you

Acting on a hold

On Payouts, the ACT button on a row opens the manual hold actions and prefills the Settle and Release forms with that hold's reference, merchant and amounts — you rarely type anything. Settle (provider confirmed payment) drains locked to the bank and books fee/VAT; locked drops to zero. Release (provider failed) returns the full reserved amount to available. Dead-letter parks the hold in manual_review with a reason; settle and release remain available from there, so you resolve it the same way.

Settle XOR release — proven

Once a hold is settled, attempting to release it is refused (holds:<m>:<ccy> has insufficient … balance — the locked funds are already gone). A second settle has no balance effect. A hold reaches one terminal state and stays there. The status-filter tabs (all / reserved / settled / released / manual_review) and their counts stay consistent as you work.

Holds are pooled per merchant + currency (DECCS WI-1.1)

There is one hold ledger account per merchant + currency, shared by all that merchant's payouts. A manual settle/release matches on amount against the pool, not a specific reserve — so settling with the wrong hold reference can drain funds another open hold was relying on and strand it (the holds gate will show drift). Match hold references and amounts carefully when acting by hand; this is a known design gap, not a per-action bug.

05.6

Adjustments: corrections & reversals

The two doors for fixing the book. A correction is a fresh, reasoned posting; a reversal unwinds a specific transaction. Reach for them in different situations — and know one current limitation.

Corrections — the everyday adjustment

Operations → Ledger correction posts a credit or debit with a mandatory reason (chargebacks, ops adjustments). The amount moves the merchant's balance immediately and the statement entry carries the correction reference. The optional effectiveAt field posts bi-temporally — back-date it to the day the event really occurred and the console accepts it. A delivered-then-disputed collection is a debit correction with reason chargeback …; this is the right tool the vast majority of the time.

Reversals — unwind a posting by id

Operations → Reverse transaction takes a walletflow transaction id (grab it from the wallet statement or the overview's recent-transactions list) and unwinds that posting. A wrong id is refused cleanly with ledger transaction not found — it will not guess.

Current limitation — reversing a multi-leg pay-in

On the memory adapter, reversing a pay-in is presently refused with bank:<provider>:collections:<ccy> has insufficient … balance. The reversal replays the original legs inverted but in original order, draining the pass-through collections account (which nets to zero after a pay-in) before the refilling legs run, so the guard trips. Nothing posts — the merchant balance is unchanged — but the reversal does not complete. Until this is fixed, undo an erroneous pay-in with an offsetting debit correction (same effect on the merchant, fully reasoned and audited). Reversal remains the right tool for single-leg postings. Verify behaviour against real Formance separately before relying on pay-in reversal there.

Replay safety

Both doors are idempotent. A correction re-submitted with the same key replays; a completed reversal is guarded against running twice. The balance changes exactly once — a double-reversal cannot happen by re-clicking.

05.7

The detective controls

Recon, Ledger and Controls are where you confirm the book is sound and where you stop the bleeding. Read them daily; reach for them when a number looks wrong.

Reconciliation — the four gates

Each panel on Recon runs its gate when the page loads (give the lazy-loaded panels a second; none should spin forever):

  • Gate 1 · statement drift — read-model vs ledger. Expect zero.
  • Gate 2 · trial balance — must foot per currency.
  • Gate 3 · reserved holds vs hold accounts — every reserved hold backed 1:1 by ledger hold balances.
  • Gate 4 · COA / GL / template coverage — no mapping gaps.

When wallet drift shows, click Rebuild statement read model first — projection lag is the usual cause and the rebuild is idempotent. Only if drift survives a rebuild do you investigate a real discrepancy. Beneath the gates, ALM views show obligations against balances.

Drift you caused yourself

A manual settle/release against the pooled hold account (see 05.5) can make Gate 3 report drift — that's the consequence of your action, not a spontaneous failure. When a gate goes red, first ask "did I just do something to this merchant's holds?"

Ledger & Controls

Ledger browses the chart of accounts, the event→GL mappings and the 18 Numscript templates, with rebuild buttons and a CSV export for Finance — the template count should match the bundled set. Controls (admin) is your freeze switch and audit window: set a core_wallet_enabled feature flag for a merchant / currency / provider scope to block new exposure (with CORE_WALLET_FLAG_MODE=fail_closed, only enabled scopes transact); watch the outbox dispatch with attempts and errors; and audit the provider-event log that blocks webhook replays. Saga completions still record by default — a true hard freeze needs CORE_WALLET_COMPLETION_GATING=gate (an env change, so a restart).

05.8

Quick reference

The command fields and the things that look broken but aren't — the two tables to keep open while you operate.

Command fields & what success looks like

Command (cmd=)Required fieldsSuccess / common rejection
Record pay-in
payin
merchantId, amount, currency, provider, providerReference (+fee, vat)posted + balances · dupe → provider event already processed
Wallet transfer
transfer
merchantId, destinationMerchantId, amount, currency both balances echo · over-balance → insufficient … balance
Reserve payout
payout-reserve
merchantId, amount, currency (+fee, vat, holdReference)reserved · available−(amt+fee+vat), locked+same
Settle / Release
(Payouts · ACT)
prefilled: holdReference, merchantId, amount, currency, fee, vatsettled / released · wrong state → insufficient … balance
Dead-letter
(Payouts)
holdReference, reasonstatus → manual_review
Ledger correction
correction
merchantId, direction, amount, currency, reason (+effectiveAt) balance moves · empty reason → Reason is required
Reverse transaction
reversal
txid (+reference) single-leg → reverted · pay-in → insufficient … balance (see 05.6)
FX quote
fx-quote
source ccy, dest ccy, amount, rateread-only — exact fixed-point, no idempotency key

Looks broken — isn't

What you seeWhy it's expected
Idempotency key unchanged after a failed submitDeliberate — retrying a failure replays it, never a new op.
"No statement entries" on a funded walletStatement worker doesn't run in dev — Rebuild on Recon.
Outbox pending count > 0 and ageingNo relay worker in dev; events queue and wait.
Pay-in fails "wallet … not found"Create the wallet first — funding does not auto-create.
Gate 3 drift after a manual hold actionPooled-holds gap (WI-1.1) — consequence of acting by hand.
No treasury/dashboard position viewNot built yet — Overview shows service metrics, not a position.