/v1/payouts and /v1/balance; the transfer itself runs in the billing worker.
The balance, and why it splits
The balance is a single integer-kobo figure per tenant (MerchantBalance), moved only through an append-only MerchantBalanceTransaction ledger. A successful renewal or checkout posts an invoice_settlement credit; a payout posts a payout debit; a failed transfer posts a reversal credit. Every row carries a reference and is @@unique([tenantId, reference]), so the same settlement or payout is only ever applied once.
But the raw balance is not what a merchant can withdraw. Card money settles on T+1, so today’s earnings are not yet spendable. MerchantBalanceService.breakdown() splits the balance in two:
pendingKobois the sum ofinvoice_settlementcredits withcreatedAton or after the start of today in Lagos (Africa/Lagos, UTC+1, computed instartOfDayLagos()). This is money earned today that has not yet cleared T+1.availableKoboisbalance - pendingKobo, floored at zero. This is the only figure a payout can draw against.
The split is date-boundary based, not a per-transaction clock. Everything credited today (Lagos) counts as pending; at the next midnight it rolls into available. This is deliberately simple and predictable for the merchant, at day-granularity.
GET /v1/balance - the wallet view
Returns the merchant’s current balance, currency, and the last 30 balance transactions (each with type, reason, amount, balanceAfter, memo, createdAt). The reasons a merchant will see:
Bank accounts (added behind an email OTP)
A payout destination can be an ad-hoc bank account on a single payout, or a saved account. Saving (or removing) an account is a sensitive action, so it is gated by a 6-digit code emailed to the business owner (the same second factor is required to remove one). The flow, endpoint by endpoint:POST /v1/payouts/accounts/otpemails a 6-digit code to the tenant owner. The code is stored hashed (SHA-256) in the cache with a 10-minute TTL and a 5-attempt ceiling; a fresh request resets the attempt counter. (Requiresinvoice:write.)POST /v1/payouts/accountstakes a body{ bankCode, accountNumber (10 digits), code (6 digits), makeDefault? }. Before saving, Duro resolves the account name against the bank (so a saved account always has a verified name), rejects a bank it doesn’t support, and rejects a duplicate(tenant, bankCode, accountNumber). The first account saved becomes the default automatically;makeDefault: truere-points the default. (Requiresinvoice:write.)GET /v1/payouts/accountslists saved accounts, default first, then newest, each withbankCode,bankName,accountNumber,accountName,isDefault.POST /v1/payouts/accounts/{id}/removetakes a body{ code }. Removing also requires the OTP; if the removed account was the default, the next most recent account is promoted to default. (Requiresinvoice:write.)
Account-name resolution & the bank list
Two read endpoints back the “who owns this account?” step, and can be used on their own:GET /v1/payouts/banksreturns the supported bank list ({ code, name }). In live mode it pulls the list from Nomba and falls back to a built-in list of Nigerian banks; in test mode it is always the built-in list.POST /v1/payouts/banks/resolvetakes{ bankCode, accountNumber }and returns{ accountName }for a 10-digit account. In live mode this is a real Nomba bank-account lookup; in test mode it returns a deterministic mock name derived from the account number (so the same test number always resolves to the same name). A number that can’t be resolved returns a400.
Initiating a payout
POST /v1/payouts moves available balance to a bank account. You provide either a saved accountId or an ad-hoc { bankCode, accountNumber } (with an optional accountName; if omitted, Duro resolves it). (Requires invoice:write.)
The amount is validated as a positive integer, then checked against availableKobo; over-drawing is rejected with a message that names the reason (money still settling). The balance is debited immediately at initiation (not at transfer time), under the same overspend-proof guard used everywhere: a conditional UPDATE ... WHERE balance >= amount, so two concurrent payouts can’t both spend the same naira. The Payout row is created processing, a payout_initiated event is recorded, and the owner is emailed a “payout requested” notice.
The transfer, and its safety net
A worker scans forprocessing payouts and runs each one exactly once (deduped on payout_<id>). PayoutService.process() performs the bank transfer and drives the row to a terminal state:
- On success, the row becomes
completedwith acompletedAtand the Nomba reference (nombaReference), and apayout_completedevent fires. The owner is emailed a confirmation. - On failure, the balance is credited back with
reason: reversal(reference<original>:rev), the row becomesfailedwith afailureReason, and apayout_failedevent fires. The owner is emailed the failure. Because the reversal reuses the idempotency ledger, a retry can’t double-refund.
MOCK-POUT-… reference) so the full lifecycle is exercisable end to end without moving real money; live mode calls Nomba’s bank-transfer API.
Payout events
Three events mark a payout’s life, delivered to any subscribed webhook endpoint and visible in the events stream:Listing payouts
GET /v1/payouts returns the balance split and the payout history in one call: availableKobo, pendingKobo, currency, and items (newest first). Each item carries the full payout view: amount, status, bankName, accountNumber, accountName, reference, nombaReference, failureReason, createdAt, completedAt.
Next: the queue & worker topology that runs the transfer, or the webhook catalog for the payout events.