100 lines
8.9 KiB
Markdown
100 lines
8.9 KiB
Markdown
# Contract — Payouts (backend phase b13)
|
|
|
|
> The weekly nurse-payout engine: an admin previews eligible earnings, opens a draft batch, submits it to the
|
|
> (mocked) PAYA/SATNA bank rail, retries/marks failed payouts, and reads batches; a nurse reads their own payout
|
|
> history. Assumes [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
|
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Machine schema:
|
|
> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json).
|
|
|
|
**Status:** live as of backend-phase-b13 · **Frontend consumer:** frontend-phase-f12-b13
|
|
|
|
All money is IRR `BIGINT` and crosses the wire as a **digit string** (`"8500000"`). Dates are `yyyy-MM-dd`.
|
|
List query params are **camelCase** (`page`, `pageSize`, `status`, `periodStart`, `periodEnd`) — not snake_case.
|
|
The response envelope is the standard `{ data, … }`; the shapes below are the `data`.
|
|
|
|
## Enums used
|
|
- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed` — the batch lifecycle.
|
|
A draft is materialized but unsubmitted; `partially_failed` has some paid + some failed (retryable).
|
|
- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed` — the per-payout lifecycle (forward-only; `paid` is an
|
|
irreversible transfer with no outgoing edge; `failed` re-submits on retry).
|
|
|
|
## Endpoints
|
|
|
|
### `GET api/v1/admin_payouts/eligible`
|
|
- **Purpose:** Preview the payout-eligible, unpaid earnings for a window, grouped by nurse (the dry-run before a batch).
|
|
- **Auth:** admin (dynamic-permission policy) · **Rate-limited:** yes · **Idempotency key:** n/a (read).
|
|
- **Query params:** `periodStart` (date, required), `periodEnd` (date, required, ≤ today, ≥ periodStart), `page` (default 1), `pageSize` (default 20, max 100).
|
|
- **Success `200` (`data`):** `PagedResult<EligibleNurseEarningsDto>`.
|
|
- **Failure cases:** `400` periodStart > periodEnd or periodEnd in the future; `401` unauthenticated; `403` non-admin.
|
|
- **Notes:** Eligible = booking `status='completed'` AND `dispute_window_ends_at < now` AND no active refund AND not already paid. The `periodEnd` is holiday-shifted the same way a generate would shift it. A nurse without a verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban=false`), not dropped. Pending clawbacks are netted into the preview.
|
|
|
|
### `POST api/v1/admin_payouts/batches`
|
|
- **Purpose:** Open a `draft` batch: select eligible bookings, materialize one payout per nurse (net of clawbacks), link each booking under the UNIQUE guard, snapshot the verified primary IBAN. **No money moves.**
|
|
- **Auth:** admin · **Rate-limited:** yes · **Idempotency:** the `booking_id` UNIQUE link makes a re-run over an overlapping window unable to re-select an already-paid booking.
|
|
- **Request body:** `{ "periodStart": "2026-06-01", "periodEnd": "2026-06-30" }`
|
|
- **Success `200` (`data`):** `GeneratePayoutBatchResult` — the draft batch, its materialized payouts, and the nurses skipped (with reasons).
|
|
- **Failure cases:** `400` invalid period; `401`/`403`; a plain failure when **no eligible bookings** in the window or **no eligible nurse has a verified primary IBAN**; `409` a concurrent run already claimed one of the bookings (the UNIQUE backstop).
|
|
- **Notes:** `period_end`/`processing_date` are shifted off bank-closed days via `IHolidayCalendar`. `total_amount = Σ net_amount_irr`, `payout_count = COUNT(payouts)`.
|
|
|
|
### `POST api/v1/admin_payouts/batches/{id}/process`
|
|
- **Purpose:** Submit a draft (or partially-failed) batch to the bank rail — the one irreversible money-out step.
|
|
- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout-batch:{id}`; a retried process never re-sends a paid payout or re-posts the ledger).
|
|
- **Path params:** `id` (long) — the batch id. **Body:** none.
|
|
- **Success `200` (`data`):** `ExecutePayoutBatchResult`.
|
|
- **Failure cases:** `401`/`403`; `404` batch not found; `409` the batch already `failed` (open a new one). A re-process of a `completed` batch is an idempotent `200`.
|
|
- **Notes:** Per accepted transfer it posts `DEBIT nurse_payable / CREDIT escrow_held` (paid net) and, for a netted clawback, `DEBIT nurse_payable / CREDIT nurse_clawback_receivable` + marks the `nurse_clawbacks` row `recovered`. Batch ends `completed` (all paid) or `partially_failed` (some failed). PAYA vs SATNA is chosen by `payout_satna_threshold_irr`.
|
|
|
|
### `POST api/v1/admin_payouts/{payoutId}/retry`
|
|
- **Purpose:** Re-submit a single `failed` payout (holiday-aware).
|
|
- **Auth:** admin · **Rate-limited:** yes · **Idempotency key:** yes (`payout:{id}:retry`).
|
|
- **Path params:** `payoutId` (long). **Body:** none.
|
|
- **Success `200` (`data`):** `true`.
|
|
- **Failure cases:** `400` a `processing_date` failure when banks are closed today, or a `channel` failure when the rail declines again; `401`/`403`; `404` payout not found; `409` the payout is not `failed`. An already-`paid` payout returns an idempotent `200`.
|
|
- **Notes:** On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (`partially_failed → completed` when it was the last failure).
|
|
|
|
### `POST api/v1/admin_payouts/{payoutId}/mark_failed`
|
|
- **Purpose:** Record a reconciled bank rejection on a payout — no ledger movement (no money left).
|
|
- **Auth:** admin · **Rate-limited:** yes.
|
|
- **Path params:** `payoutId` (long). **Request body:** `{ "failureReason": "invalid_sheba" }`
|
|
- **Success `200` (`data`):** `true`.
|
|
- **Failure cases:** `400` empty reason; `401`/`403`; `404` not found; `409` the payout is `paid` (a confirmed transfer can't be failed). An already-`failed` payout is an idempotent `200`.
|
|
|
|
### `GET api/v1/admin_payouts/batches/{id}`
|
|
- **Purpose:** Batch header + its paginated payouts (status, net, masked IBAN, transfer reference) + the bookings each covers.
|
|
- **Auth:** admin · **Rate-limited:** yes.
|
|
- **Path params:** `id` (long). **Query:** `page` (default 1), `pageSize` (default 50, max 200).
|
|
- **Success `200` (`data`):** `PayoutBatchDetailDto`.
|
|
- **Failure cases:** `401`/`403`; `404` not found.
|
|
|
|
### `GET api/v1/admin_payouts/batches`
|
|
- **Purpose:** Admin reconciliation list of batches.
|
|
- **Auth:** admin · **Rate-limited:** yes.
|
|
- **Query:** `status` (optional `PayoutBatchStatus`), `page` (default 1), `pageSize` (default 20, max 100).
|
|
- **Success `200` (`data`):** `PagedResult<PayoutBatchDto>`.
|
|
|
|
### `GET api/v1/nurse_payouts/history`
|
|
- **Purpose:** The signed-in nurse's own payouts (tenancy-scoped) — status, net, masked IBAN + transfer reference, clawback applied, the batch window.
|
|
- **Auth:** authenticated (nurse) · **Rate-limited:** no.
|
|
- **Query:** `page` (default 1), `pageSize` (default 20, max 100).
|
|
- **Success `200` (`data`):** `PagedResult<NursePayoutHistoryDto>`.
|
|
- **Failure cases:** `401` unauthenticated. A caller who is not a nurse gets an empty page (never another nurse's data).
|
|
|
|
## Shared shapes
|
|
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
|
|
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
|
|
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
|
|
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
|
|
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
|
|
- `SkippedNurseDto`: `nurseId` (long), `nurseName` (string?), `grossEarningsIrr` (string), `reason` (string, e.g. `no_verified_primary_iban`).
|
|
- `GeneratePayoutBatchResult`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `skipped` (`SkippedNurseDto[]`).
|
|
- `ExecutePayoutBatchResult`: `batchId` (long), `status` (`PayoutBatchStatus`), `paidCount` (int), `failedCount` (int), `totalPaid` (string).
|
|
- `NursePayoutHistoryDto`: `id` (long), `batchId` (long), `status` (`PayoutStatus`), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr` (string), `maskedIban` (string), `transferReference` (string?), `paidAt` (datetime?), `periodStart`/`periodEnd` (date).
|
|
|
|
## Side effects
|
|
- **Ledger:** process/retry post balanced groups out of `nurse_payable` (payout + clawback-recovery). Never a `payout_released` boolean — paid-ness derives from a link row + the ledger.
|
|
- **One payout per booking, forever** via the `nurse_payout_booking_links.booking_id` UNIQUE.
|
|
- **Bank rail** is mocked behind `IBankTransferProvider` (PAYA/SATNA) — no real transfer.
|
|
|
|
## Changelog
|
|
- b13 — initial contract.
|