> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useduro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Merchant Payouts

> Money out. A merchant's settled balance, a T+1 available-vs-pending split, OTP-guarded bank accounts, and a queued bank transfer that reverses itself if the rail fails.

Every successful charge credits the merchant's **balance**. A payout moves that balance to the merchant's own bank account. The whole flow is built around one hard rule: a merchant can only pay out money that has actually **settled** (T+1), and a transfer that fails on the bank rail is **refunded to the balance atomically**, so a payout can never quietly lose money.

The merchant surface lives under `/v1/payouts` and `/v1/balance`; the transfer itself runs in the [billing worker](/platform/queues-and-workers).

## 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:

```mermaid theme={null}
flowchart TB
    BAL["MerchantBalance.balance<br/>(all credited money)"] --> SPLIT{"breakdown()"}
    TODAY["sum of invoice_settlement credits<br/>created since 00:00 Africa/Lagos"] --> SPLIT
    SPLIT --> PEND["pendingKobo<br/>= today's settlements"]
    SPLIT --> AVAIL["availableKobo<br/>= max(0, balance - pendingKobo)"]
```

* **`pendingKobo`** is the sum of `invoice_settlement` credits with `createdAt` on or after the start of **today in Lagos** (`Africa/Lagos`, UTC+1, computed in `startOfDayLagos()`). This is money earned today that has not yet cleared T+1.
* **`availableKobo`** is `balance - pendingKobo`, floored at zero. This is the only figure a payout can draw against.

<Note>
  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.
</Note>

Both figures ride on the payouts list response and on the balance endpoint.

## `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:

| Reason               | Direction | Meaning                                          |
| -------------------- | --------- | ------------------------------------------------ |
| `invoice_settlement` | credit    | A renewal or checkout paid out into the balance. |
| `bill_payment`       | debit     | A bill/VAS purchase spent from the balance.      |
| `payout`             | debit     | A withdrawal to a bank account.                  |
| `reversal`           | credit    | A failed payout refunded back.                   |
| `adjustment`         | either    | A manual correction.                             |

## 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).

```mermaid theme={null}
sequenceDiagram
    autonumber
    participant M as Merchant (dashboard)
    participant API as /v1/payouts
    participant MAIL as owner's inbox
    M->>API: POST /accounts/otp
    API->>MAIL: 6-digit code (10-min TTL, hashed at rest)
    M->>API: POST /accounts { bankCode, accountNumber, code }
    API->>API: resolve account name, verify OTP, dedupe
    API-->>M: saved account (name filled in)
```

The flow, endpoint by endpoint:

* **`POST /v1/payouts/accounts/otp`** emails 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. (Requires `invoice:write`.)
* **`POST /v1/payouts/accounts`** takes 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: true` re-points the default. (Requires `invoice:write`.)
* **`GET /v1/payouts/accounts`** lists saved accounts, default first, then newest, each with `bankCode`, `bankName`, `accountNumber`, `accountName`, `isDefault`.
* **`POST /v1/payouts/accounts/{id}/remove`** takes a body `{ code }`. Removing also requires the OTP; if the removed account was the default, the next most recent account is promoted to default. (Requires `invoice: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/banks`** returns 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/resolve`** takes `{ 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 a `400`.

## 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`.)

```mermaid theme={null}
flowchart TD
    REQ["POST /v1/payouts<br/>{ amount, accountId | bankCode+accountNumber }"] --> CHK{"amount ≤ availableKobo?"}
    CHK -->|"no"| REJ["400 (more than available);<br/>balance settling today isn't ready yet"]
    CHK -->|"yes"| DEBIT["debit balance now<br/>(reason: payout, ref: pout_…)"]
    DEBIT --> ROW["create Payout row<br/>status: processing"]
    ROW --> EV["emit payout_initiated"]
    EV --> MAIL["email owner: payout requested"]
```

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.

<Warning>
  `POST /v1/payouts` returns as soon as the balance is debited and the `processing` row is written. The bank transfer has **not** happened yet; a worker performs it. Watch the payout's `status` (or the payout events) for the terminal outcome.
</Warning>

## The transfer, and its safety net

A worker scans for `processing` 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:

```mermaid theme={null}
stateDiagram-v2
    [*] --> processing: initiate (balance already debited)
    processing --> completed: Nomba transfer accepted
    processing --> failed: transfer threw
    completed --> [*]
    failed --> [*]
```

* **On success**, the row becomes `completed` with a `completedAt` and the Nomba reference (`nombaReference`), and a `payout_completed` event fires. The owner is emailed a confirmation.
* **On failure**, the balance is **credited back** with `reason: reversal` (reference `<original>:rev`), the row becomes `failed` with a `failureReason`, and a `payout_failed` event fires. The owner is emailed the failure. Because the reversal reuses the idempotency ledger, a retry can't double-refund.

In test mode the transfer is mocked (a `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](/api-reference/webhook-events) mark a payout's life, delivered to any subscribed [webhook endpoint](/webhooks/delivery) and visible in the events stream:

| Event              | When                               | Key fields                                                                                                |
| ------------------ | ---------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `payout_initiated` | Balance debited, transfer queued.  | `payoutId`, `amount`, `bankCode`, `accountNumber`, `reference`                                            |
| `payout_completed` | Bank transfer accepted.            | `payoutId`, `amount`, `currency`, `bankCode`, `accountName`, `reference`, `nombaReference`, `completedAt` |
| `payout_failed`    | Transfer failed; balance refunded. | `payoutId`, `amount`, `reason`                                                                            |

## 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](/platform/queues-and-workers) that runs the transfer, or the [webhook catalog](/api-reference/webhook-events) for the payout events.
