One balance, keyed to the account
TheWallet hangs off CustomerAccount, not off any tenant — like the account itself, it is deliberately mode-agnostic and merchant-agnostic. Every movement writes a WalletTransaction row carrying the type (credit/debit), a reason, the amount, and the running balanceAfter, so the balance is always reconstructable from an append-only history.
Credit and debit are atomic and idempotent
Money movement is the one place you cannot afford a double-apply or a race. Two properties make it safe:- Idempotent by
(account, reference). Every mutation carries a caller-suppliedreference, andWalletTransactionis@@unique([accountId, reference]). A webhook that fires twice, a retried job, a replayed request — the second insert is rejected by the database, and the caller gets back the same transaction withalreadyApplied: true. Value is granted once, ever. - Overspend-proof debits. A debit is a single conditional
UPDATE … WHERE balance >= amount. If the guarded update touches zero rows the balance was insufficient and the debit returns{ ok: false, reason: 'insufficient_balance' }— the balance can never go negative, even under concurrent debits.
A
P2002 unique-violation on the reference isn’t an error — it’s the idempotency guard doing its job. The service catches it and recovers the already-written transaction, so concurrent callers converge on one result.Filling the wallet — two ways in
Money reaches the balance through exactly two credit paths, each with its ownreason and its own idempotency reference:
Top-up
A
topup checkout charges an arbitrary ₦ amount on Nomba; the webhook credits the wallet with reason: topup, keyed to the Nomba transaction id. See top-up →Virtual account
A bank transfer into the customer’s dedicated virtual account fires a webhook that credits the wallet with
reason: virtual_account_funding, keyed to the Nomba transaction id.reason: subscription_charge, referenced by the invoice. That is the whole point of the balance: wallet-first billing.
In the ledger
Wallet movement is also double-entered into the finance ledger: a top-up posts awallet_topup entry, and a wallet-funded renewal posts a wallet_debit. The wallet is its own LedgerAccount, so the money held on behalf of customers is a first-class balance in Duro’s books, not an off-ledger side-effect.
Next: wallet-first billing — how a renewal spends this balance before it ever touches a card.