---
feature: Amounts due and payout management
slug: payouts
surface: admin
consumer: react
version: 1.0.0
status: ready
brd_version: "1.3"
brd_ids: [ADMIN-017]
business_rules: [BR-24, BR-25, BR-26, BR-29]
decisions: [DEC-11, DEC-25, TD-98, TD-151, TD-155, TD-158]
schema_tables: [payouts, earnings, payout_accounts, app_configs, audit_logs]
related_docs: [../provider/earnings.md, ../delivery/earnings.md, cancellations-refunds.md, settings.md]
last_updated: 2026-11-02
---

# Amounts due and payout management — React Dashboard Integration Guide

> **Base URL:** `{HOST}/api/v1/admin` · Shared rules: [API_CONVENTIONS.md](../API_CONVENTIONS.md)

## Version history

| Version | Date | Type | Summary | App action required |
|---------|------|------|---------|---------------------|
| 1.0.0 | 2026-11-02 | Initial | Transfer-request desk, audited bank-account read, the four decisions (processing, paid, cancel, fail), per-beneficiary balances and the `payout_min_amount` setting. | Build the Payouts section and the balances table. |

## Changelog

### 1.0.0 — 2026-11-02

- **Added:** `GET /payouts`, `GET /payouts/{payout}`, `GET /payouts/{payout}/bank-account`, `POST /payouts/{payout}/processing|paid|cancel|fail`, `GET /payout-balances`; setting `payout_min_amount` in `PUT /settings`; new `payouts` section actions in the permission matrix.
- **Changed:** — · **Fixed:** — · **Removed / Deprecated:** —
- **Breaking:** No.
- **App action required:** add the Payouts section; run `php artisan rbac:sync-permissions` (and grant `payouts.view` / `payouts.approve` to the roles that need them) on deploy.
- **BRD / decisions:** ADMIN-017, BR-26, DEC-11 (answered 2026-11-02), TD-158.

---

## 1. Business summary

A provider or driver sends a transfer request from her app. The request carries every amount she can be paid now (her `available` earnings, including a negative featured-plan fee). The admin reviews it, sends the money **by hand, outside the system**, to the IBAN saved in her profile, then records the bank reference and marks it **Transferred**. Nothing is ever transferred automatically (BR-26). Providers and drivers are always shown separately (ADMIN-017). Balances per beneficiary show pending / available / in payout / paid and the totals before and after commission.

## 2. Pages and permissions

| Page / button | Route name | Section | Action |
|---------------|------------|---------|--------|
| Requests list | `admin.payouts.index` | `payouts` | view |
| Request details | `admin.payouts.show` | `payouts` | view |
| Balances table | `admin.payout-balances.index` | `payouts` | view |
| Show full IBAN | `admin.payouts.bank-account` | `payouts` | **approve** (audited) |
| Mark processing | `admin.payouts.processing` | `payouts` | approve |
| Mark transferred | `admin.payouts.paid` | `payouts` | approve |
| Reject | `admin.payouts.cancel` | `payouts` | approve |
| Mark failed | `admin.payouts.fail` | `payouts` | approve |
| Minimum amount | `admin.settings.update` (key `payout_min_amount`) | `settings` | update |

## 3. List / table

`GET /api/v1/admin/payouts` — paginated under `data.payouts` (or `paginate=false`, capped at 500 rows).

| Param | Description |
|-------|-------------|
| `filters[role]` | `provider` or `driver` — keep the two lists separate. |
| `filters[status]` | `requested`, `processing`, `paid`, `failed`, `cancelled`. |
| `filters[provider_id]` / `filters[driver_id]` | Profile uuid: all requests of one beneficiary. |
| `filters[requested_from]`, `filters[requested_to]` | `YYYY-MM-DD` on the request time. |
| `search` | Beneficiary name or phone, or bank reference. |
| `sort[requested_at\|amount\|paid_at]` | `asc` / `desc`; default newest request first. |
| `page`, `per_page` | Standard. |

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "payouts": [
      {
        "id": "01a19b8c-3dd7-7351-a7d6-3aec7aec6c4c",
        "beneficiary": {
          "id": "01a19b8c-3d95-735a-a8c4-eba6e94e2fd1",
          "role": {
            "value": "provider",
            "label": "Provider"
          },
          "full_name": "Carrie Buckridge",
          "phone": "592311944"
        },
        "amount": "200.00",
        "status": {
          "value": "requested",
          "label": "Requested"
        },
        "earnings_count": 2,
        "account_status": {
          "value": "pending",
          "label": "Pending"
        },
        "bank_reference": null,
        "requested_at": "2026-11-02T09:17:01+00:00",
        "paid_at": null,
        "processed_by": null
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/payouts?page=1",
      "last": "http://localhost/api/v1/admin/payouts?page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "path": "http://localhost/api/v1/admin/payouts",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

Postman: `admin.payouts.index` › "200 Payouts List", "200 Payouts List Providers Only", "422 Payouts List Invalid Role".

## 4. Details view

`GET /api/v1/admin/payouts/{payout}` — `{payout}` is the uuid (an integer id is a 404). Returns the beneficiary, the payout account **snapshot with the IBAN masked**, every linked earning with its order or trip and gross / commission / net, the totals, and `allowed_statuses` (the moves the buttons may offer — do not re-implement the state machine).

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "id": "01a19b8c-44a0-72cb-844b-83cb738a95a7",
    "beneficiary": {
      "id": "01a19b8c-4455-7393-8502-12121391d623",
      "role": {
        "value": "provider",
        "label": "Provider"
      },
      "full_name": "Alanna Corkery",
      "phone": "560061884"
    },
    "amount": "200.00",
    "status": {
      "value": "requested",
      "label": "Requested"
    },
    "earnings_count": 2,
    "account_status": {
      "value": "pending",
      "label": "Pending"
    },
    "bank_reference": null,
    "requested_at": "2026-11-02T09:17:03+00:00",
    "paid_at": null,
    "processed_by": null,
    "allowed_statuses": [
      {
        "value": "processing",
        "label": "Processing"
      },
      {
        "value": "paid",
        "label": "Transferred"
      },
      {
        "value": "cancelled",
        "label": "Rejected"
      }
    ],
    "note": null,
    "payout_account": {
      "id": "01a19b8c-445a-7253-bcda-a1bde18b43dc",
      "holder_name": "ausfzoytxdme",
      "bank_name": "tjmkquzfkepq",
      "iban": "****7519",
      "status": {
        "value": "pending",
        "label": "Pending"
      },
      "created_at": "2026-11-02T09:17:03+00:00",
      "verified_at": null,
      "verified_by": null
    },
    "totals": {
      "gross_amount": "250.00",
      "commission_amount": "50.00",
      "delivery_fee_amount": "0.00",
      "net_amount": "200.00"
    },
    "earnings": [
      {
        "id": "01a19b8c-4471-7178-95c5-c35470e6b88e",
        "source_type": {
          "value": "order",
          "label": "Service"
        },
        "order": {
          "id": "01a19b8c-445c-712d-b8fb-644311c348fe",
          "code": "JSH-fcap13206195"
        },
        "trip": null,
        "subscription_id": null,
        "gross_amount": "100.00",
        "commission_rate": "0.00",
        "commission_amount": "20.00",
        "delivery_fee_amount": "0.00",
        "net_amount": "80.00",
        "status": {
          "value": "in_payout",
          "label": "Awaiting transfer"
        }
      },
      {
        "id": "01a19b8c-4487-7045-8096-ec1786b45210",
        "source_type": {
          "value": "order",
          "label": "Service"
        },
        "order": {
          "id": "01a19b8c-4476-718b-bdca-ce944ce0c3b4",
          "code": "JSH-ppht85685806"
        },
        "trip": null,
        "subscription_id": null,
        "gross_amount": "150.00",
        "commission_rate": "0.00",
        "commission_amount": "30.00",
        "delivery_fee_amount": "0.00",
        "net_amount": "120.00",
        "status": {
          "value": "in_payout",
          "label": "Awaiting transfer"
        }
      }
    ],
    "created_at": "2026-11-02T09:17:03+00:00"
  }
}
```

### Full IBAN (audited read)

`GET /api/v1/admin/payouts/{payout}/bank-account` — needs `payouts.approve`; responds `Cache-Control: no-store, private`; the read is written to the audit log (`payouts.bank-account.viewed`, ids only — the IBAN is never logged). Show it only behind an explicit "Show IBAN" click.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "payout_id": "01a19b8c-4ac9-7013-b96b-07fb19b01cab",
    "id": "01a19b8c-4a82-728f-8ed3-d55e90eca0fb",
    "holder_name": "cqlybkqexymp",
    "bank_name": "ajvixlxkexjz",
    "iban": "SA0380000000608010167519",
    "status": {
      "value": "pending",
      "label": "Pending"
    }
  }
}
```

Postman: `admin.payouts.show` › "200 Payout Details"; `admin.payouts.bank-account` › "200 Payout Bank Account", "403 Payout Bank Account Forbidden".

## 5. Forms (create / edit)

N/A — requests are created by the beneficiary. The only editable value is the setting `payout_min_amount` (SAR string, `>= 0`, at most two decimals; `INVALID_PAYOUT_SETTINGS` 422 otherwise) through `PUT /settings`; default `0.00` means no minimum. Postman: `admin.settings.update` › "200 Payout Min Amount Updated", "422 Payout Min Amount Invalid".

## 6. Actions (non-CRUD)

All four are `POST`, need `payouts.approve`, are audited inside the same transaction (action, from / to status, amount and the uuids of the earnings that moved) and answer the **details payload** of §4. A move not in `allowed_statuses` is **409 `INVALID_STATE_TRANSITION`** and changes nothing (decided under the row lock, so a double click is safe). Unknown uuid: 404.

| Action | Route | Body | From | Effect |
|--------|-------|------|------|--------|
| Processing | `POST /payouts/{payout}/processing` | `note?` (≤ 500) | `requested` | Status only. |
| **Transferred** | `POST /payouts/{payout}/paid` | `bank_reference` (required, ≤ 100), `note?` (≤ 500) | `requested`, `processing` | Sets `paid_at`, `processed_by`; earnings → `paid`; a `pending` payout account becomes `verified` (`verified_by`, `verified_at`) — a completed transfer proves the account. Notifies her. |
| Reject | `POST /payouts/{payout}/cancel` | `note` (required, ≤ 500), `reject_account?` (bool) | `requested`, `processing` | Earnings → `available`, `payout_id` cleared. `reject_account: true` also marks her account `rejected`: her next request is refused until she saves new bank details. Notifies her. |
| Failed | `POST /payouts/{payout}/fail` | `note` (required, ≤ 500) | `processing`, `paid` | The bank returned the transfer: earnings (even if already `paid`) → `available`, `payout_id` cleared. If this request's `paid` is what verified the bank account, the account goes back to `pending` (a bounced transfer proves nothing); an account verified earlier stays `verified`. Notifies her. |

Failed or rejected earnings can be requested again by the beneficiary; the link to the old payout is kept only in the audit entry (`earning_ids`).

```json
{
  "status": "success",
  "message": "Transfer recorded.",
  "data": {
    "id": "01a19b8c-4f16-7326-9790-75cccf6da094",
    "beneficiary": {
      "id": "01a19b8c-4ed7-71bb-9d91-37494affb48e",
      "role": {
        "value": "provider",
        "label": "Provider"
      },
      "full_name": "Corene Jast",
      "phone": "509123094"
    },
    "amount": "200.00",
    "status": {
      "value": "paid",
      "label": "Transferred"
    },
    "earnings_count": 2,
    "account_status": {
      "value": "verified",
      "label": "Verified"
    },
    "bank_reference": "TRX-0042",
    "requested_at": "2026-11-02T09:17:06+00:00",
    "paid_at": "2026-11-02T09:17:06+00:00",
    "processed_by": {
      "id": "01a19b8c-4ed3-7307-b345-a34819b83cb2",
      "name": "Jamel Nolan"
    },
    "allowed_statuses": [
      {
        "value": "failed",
        "label": "Failed"
      }
    ],
    "note": "Main account",
    "payout_account": {
      "id": "01a19b8c-4edb-73ec-bbac-19e9607ffd96",
      "holder_name": "vbgulilpilpi",
      "bank_name": "gtrpshlzqquw",
      "iban": "****7519",
      "status": {
        "value": "verified",
        "label": "Verified"
      },
      "created_at": "2026-11-02T09:17:06+00:00",
      "verified_at": "2026-11-02T09:17:06+00:00",
      "verified_by": {
        "id": "01a19b8c-4ed3-7307-b345-a34819b83cb2",
        "name": "Jamel Nolan"
      }
    },
    "totals": {
      "gross_amount": "250.00",
      "commission_amount": "50.00",
      "delivery_fee_amount": "0.00",
      "net_amount": "200.00"
    },
    "earnings": [
      {
        "id": "01a19b8c-4eef-7004-9bc6-be31221deb27",
        "source_type": {
          "value": "order",
          "label": "Service"
        },
        "order": {
          "id": "01a19b8c-4edd-7363-a548-7da874e84370",
          "code": "JSH-icxe56051750"
        },
        "trip": null,
        "subscription_id": null,
        "gross_amount": "100.00",
        "commission_rate": "0.00",
        "commission_amount": "20.00",
        "delivery_fee_amount": "0.00",
        "net_amount": "80.00",
        "status": {
          "value": "paid",
          "label": "Transferred"
        }
      },
      {
        "id": "01a19b8c-4efd-718c-9215-ec8e0d87a726",
        "source_type": {
          "value": "order",
          "label": "Service"
        },
        "order": {
          "id": "01a19b8c-4ef1-72a8-a62b-ed0f39dc9a96",
          "code": "JSH-szey17975873"
        },
        "trip": null,
        "subscription_id": null,
        "gross_amount": "150.00",
        "commission_rate": "0.00",
        "commission_amount": "30.00",
        "delivery_fee_amount": "0.00",
        "net_amount": "120.00",
        "status": {
          "value": "paid",
          "label": "Transferred"
        }
      }
    ],
    "created_at": "2026-11-02T09:17:06+00:00"
  }
}
```

```json
{
  "status": "fail",
  "message": "This request cannot move to that status from its current one.",
  "data": {
    "error_code": "INVALID_STATE_TRANSITION",
    "correlation_id": "7e62ae3d-ac0a-482d-a4e8-0748335354e9"
  }
}
```

```json
{
  "status": "fail",
  "message": "The bank reference field is required.",
  "data": {
    "errors": {
      "bank_reference": [
        "The bank reference field is required."
      ]
    }
  }
}
```

Postman: `admin.payouts.processing` › "200 Payout Processing", "422 Payout Processing Note Too Long"; `admin.payouts.paid` › "200 Payout Paid", "409 Payout Paid Twice", "422 Payout Paid Reference Missing"; `admin.payouts.cancel` › "200 Payout Cancelled With Account Rejected", "422 Payout Cancel Note Missing"; `admin.payouts.fail` › "200 Payout Failed", "409 Payout Fail Before Processing", "422 Payout Fail Note Missing".

### Balances per beneficiary

`GET /api/v1/admin/payout-balances?filters[role]=provider|driver` — `filters[role]` is **required** (422 without it). Optional `search`, `sort[available|in_payout|paid|net_total|gross_total]` (default `available` desc), `page`, `per_page`. Paginated under `data.balances`.

`balances` splits the **net** by earning status; `totals` are over every live (not reversed) earning: `gross_amount` before commission, `commission_amount`, `delivery_fee_amount` (self-travelling providers only) and `net_amount` after commission.

```json
{
  "status": "success",
  "message": "Data retrieved successfully",
  "data": {
    "balances": [
      {
        "beneficiary": {
          "id": "01a19b8c-6150-701d-892e-06675ed3cd5a",
          "role": {
            "value": "provider",
            "label": "Provider"
          },
          "full_name": "Enos Goldner",
          "phone": "569808212"
        },
        "balances": {
          "pending": "10.00",
          "available": "80.00",
          "in_payout": "45.00",
          "paid": "30.00"
        },
        "totals": {
          "gross_amount": "190.00",
          "commission_amount": "25.00",
          "delivery_fee_amount": "0.00",
          "net_amount": "165.00"
        }
      }
    ],
    "links": {
      "first": "http://localhost/api/v1/admin/payout-balances?page=1",
      "last": "http://localhost/api/v1/admin/payout-balances?page=1",
      "prev": null,
      "next": null
    },
    "meta": {
      "current_page": 1,
      "from": 1,
      "last_page": 1,
      "path": "http://localhost/api/v1/admin/payout-balances",
      "per_page": 15,
      "to": 1,
      "total": 1
    }
  }
}
```

Postman: `admin.payout-balances.index` › "200 Payout Balances Providers", "422 Payout Balances Role Required".

## 7. Statuses and enums

| Enum | `value` | Label (en) | Next moves |
|------|---------|------------|------------|
| payout `status` | `requested` | Requested | processing, paid, cancelled |
| | `processing` | Processing | paid, cancelled, failed |
| | `paid` | Transferred | failed |
| | `failed` | Failed | — |
| | `cancelled` | Rejected | — |
| | `scheduled` | Scheduled | not used by this flow |
| earning `status` | `pending` / `available` / `in_payout` / `paid` / `reversed` | Pending / Available / Awaiting transfer / Transferred / Reversed | |
| payout account `status` | `pending` / `verified` / `rejected` / `inactive` | | `pending` → `verified` on the first `paid`; `rejected` only via Reject with `reject_account`. |

## Data model

| Table | Read / write | Notes |
|-------|--------------|-------|
| `payouts` | read + write | One row per request; `processed_by`, `paid_at`, `bank_reference`, `note`. |
| `earnings` | read + status writes | `in_payout` ↔ `available` ↔ `paid` with `payout_id`. |
| `payout_accounts` | read; status writes | Verified / rejected as described above. |
| `app_configs` | read + write | `payout_min_amount`. |
| `audit_logs` | write | One admin-action entry per decision and per IBAN read. |

## 8. TypeScript types

```ts
type EnumValue<T extends string = string> = { value: T; label: string };
type Money = string; // "170.00"
type PayoutStatus = 'requested' | 'processing' | 'paid' | 'failed' | 'cancelled' | 'scheduled';

interface PayoutRow {
  id: string;
  beneficiary: { id: string | null; role: EnumValue<'provider' | 'driver'>; full_name: string; phone: string };
  amount: Money; status: EnumValue<PayoutStatus>; earnings_count: number;
  account_status: EnumValue<'pending' | 'verified' | 'rejected' | 'inactive'>;
  bank_reference: string | null; requested_at: string | null; paid_at: string | null;
  processed_by: { id: string; name: string } | null;
}
interface PayoutDetails extends PayoutRow {
  allowed_statuses: EnumValue<PayoutStatus>[]; note: string | null;
  payout_account: { id: string; holder_name: string; bank_name: string; iban: string /* masked */; status: EnumValue;
    created_at: string; verified_at: string | null; verified_by: { id: string; name: string } | null };
  totals: { gross_amount: Money; commission_amount: Money; delivery_fee_amount: Money; net_amount: Money };
  earnings: Array<{ id: string; source_type: EnumValue; order: { id: string; code: string } | null; trip: { id: string; code: string } | null;
    subscription_id: string | null; gross_amount: Money; commission_rate: string; commission_amount: Money;
    delivery_fee_amount: Money; net_amount: Money; status: EnumValue }>;
}
interface PayoutBalance {
  beneficiary: PayoutRow['beneficiary'];
  balances: { pending: Money; available: Money; in_payout: Money; paid: Money };
  totals: PayoutDetails['totals'];
}
```

## 9. Business rules enforced by the backend

| Rule | What the backend does | What the admin sees |
|------|-----------------------|---------------------|
| BR-24 / BR-25 | Provider commission on service / package value; her delivery fee only when she travelled herself; the driver's fee earning belongs to the driver. | Per-earning gross / commission / delivery fee / net. |
| BR-26 | Nothing is transferred by the system. | You send the money, then record it. |
| DEC-11 | Earnings are `available` at once; the request claims all of them under a lock; one open request; minimum from settings. | Requests arrive complete; there is nothing to approve per earning. |
| TD-98 | A negative `subscription_deduction` is netted into the request. | A negative line in `earnings`; a request whose net is ≤ 0 is refused to the beneficiary. |
| TD-155 | A cancelled order reverses its `pending` / `available` earning; an `in_payout` or `paid` one is left alone. | Adjust by hand if needed. |
| BR-29 | Sections / actions are permission-gated; the full IBAN needs `approve`. | 403 otherwise. |

## 10. Notifications triggered

| Event | To | Channel |
|-------|----|---------|
| `payout_paid`, `payout_cancelled` (note in the body), `payout_failed` | The provider or driver | Inbox + FCM push |

Nothing is sent to admins when a request arrives (the desk is the list filtered by `requested`).

## 11. Integration checklist

- [ ] Role tabs (providers / drivers) on the list and on the balances table; `filters[role]` always sent to balances.
- [ ] Buttons built from `allowed_statuses`; 409 handled by reloading the details.
- [ ] "Show IBAN" only behind a click and only for `payouts.approve`.
- [ ] `bank_reference` and `note` validated with the limits above.
- [ ] Permissions granted: `payouts.view`, `payouts.approve`.

## 12. Acceptance scenarios

| AC ID | Scenario | Expected |
|-------|----------|----------|
| AC-ADMIN-017 | Review eligible amounts and record transfers | Requests list per role with linked orders / trips and gross / commission / net; `paid` stores the reference and moves earnings to `paid`. |
| AC-ADMIN-017 | Reject with `reject_account` | Earnings return to `available`; the account is `rejected`; her next request is refused. |
| AC-ADMIN-017 | Transfer bounced after it was recorded | `fail` from `paid` returns the earnings to `available`. |
| AC-ADMIN-017 | Wrong status | 409 `INVALID_STATE_TRANSITION`. |

## 13. Open decisions and known limitations

| ID | Impact | Current behavior |
|----|--------|------------------|
| DEC-11 | Cycle | Answered 2026-11-02: no cycle, no hold period; the admin reviews each request. |
| — | Recording a transfer without a request | Not built (owner answer to question d): every transfer goes through a request. |
| — | Earnings of orders completed before 2026-11-02 | Not back-filled. |
| — | Exporting payouts to PDF / Excel | Not built. |
