Skip to main content
The wallet is the money side of the universal account. Every person has exactly one wallet, keyed to their account, holding a single balance in integer kobo — the same person, the same balance, at every merchant they subscribe to. It exists for one reason: a customer can put money in ahead of time so a renewal draws from a funded balance instead of racing a card that might decline.

One balance, keyed to the account

The Wallet 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-supplied reference, and WalletTransaction is @@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 with alreadyApplied: 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 own reason 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.
Both land in the same balance. A debit happens in one place — a wallet-first renewal — with 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 a wallet_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.