# Contract — Refunds, clawbacks & invoices (backend phase b11) > One-line: the outbound money leg — an admin reverses a captured booking payment across both fee legs (posting > the balanced ledger reversal, forking on whether the nurse was already paid), and issues the commission > invoice with VAT. Customers can only **read** their refund status + invoice. 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/README.md). **Status:** live as of backend-phase-b11 · **Frontend consumer:** frontend-phase-f10-b11 All money is **IRR Rials, integer, on the wire as a string of digits** (`"10000000"`). Refunds are **admin-only** — there is no customer refund-initiation path. Internal `account_type`s are never exposed. Timestamps are UTC ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`). ## Enums used - `refund_status` (`refunds.status`): `requested` | `approved` | `processing` | `succeeded` | `failed` | `rejected`. A card refund goes `approved → succeeded` immediately; a BNPL/manual refund sits in `processing` until the async customer cash-back reconciles. Forward-only. - `refund_channel` (`refunds.refund_channel`): `psp_card` | `bnpl_revert` | `manual`. (The data-model's `manual_bank` is stored/served as the canonical **`manual`**.) - `clawback_status` (`nurse_clawbacks.status`): `pending` | `recovered` | `written_off`. This phase only ever creates `pending` and supports `written_off`; `recovered` is set by b13 payout netting. - `moadian_status` (`invoices.moadian_status`): `pending` | `submitted` | `registered` | `failed`. Mock leaves a new invoice `pending`. ## Endpoints ### `POST api/v1/admin_refunds` - **Purpose:** create (and immediately execute) a refund on a booking with a captured payment. - **Auth:** admin (dynamic-permission) · **Rate-limited:** yes (sensitive) · **Idempotency key:** internal (booking + transaction + cumulative amount) — a retried channel call never double-refunds. - **Request body:** ```json { "bookingId": 42, "ticketId": null, "refundPercentage": 1.0, "platformFeeRefundedIrr": null, "nursePayoutRefundedIrr": null, "reasonCategory": "customer_request", "reasonNotes": "shortened visit", "adminNotes": null, "manualBankReference": null } ``` Supply **either** `refundPercentage` (0–1 fraction, pro-rata across the booking's commission/payout legs) **or** the explicit `platformFeeRefundedIrr` + `nursePayoutRefundedIrr` legs (both together). If neither is given the booking's b9 cancellation snapshot percentage is used. `manualBankReference` forces the `manual` channel. - **Success `200` payload (`data`):** ```json { "refundId": 7, "bookingId": 42, "status": "succeeded", "refundChannel": "psp_card", "amount": "10000000", "platformFeeRefundedIrr": "1500000", "nursePayoutRefundedIrr": "8500000", "expectedCustomerRefundEta": null, "clawbackId": null } ``` For BNPL: `status: "processing"`, `refundChannel: "bnpl_revert"`, `expectedCustomerRefundEta: "2026-08-24"`. Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created). - **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin · `404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused. - **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`) **before** the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is **deferred to reconciliation for BNPL/manual** (settled later via `confirm_settlement`, below). `ticketId` is optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer. ### `POST api/v1/admin_refunds/{id}/confirm_settlement` - **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held` clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.) - **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded` refund is a no-op success (the clearing never posts twice). - **Request body:** none (id in the route). - **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded", "completedAt": "2026-08-12T10:00:00Z" }`. - **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`). ### `POST api/v1/admin_refunds/{id}/mark_failed` - **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the `processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund). - **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op. - **Request body:** `{ "reason": "bank_rejected" }` (optional). - **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`. ### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=` - **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100). - **Auth:** admin · **Success `200` (`data`):** `PagedResult` (see shapes) — channel, decomposed legs, status, `expectedCustomerRefundEta`, the policy snapshot. ### `POST api/v1/admin_clawbacks/{id}/write_off` - **Purpose:** mark a `pending` nurse clawback uncollectable; posts the balancing `DEBIT bad_debt / CREDIT nurse_clawback_receivable` correction and sets `resolved_at`. - **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "reason": "uncollectable" }` - **Success `200` (`data`):** `true`. **Failure:** `404` not found · `409` not pending. ### `POST api/v1/admin_invoices` - **Purpose:** issue the booking's official commission invoice. Idempotent per booking (re-issue returns the same). - **Auth:** admin · **Rate-limited:** yes · **Request body:** `{ "bookingId": 42 }` - **Success `200` (`data`):** `Invoice` (see shapes) — sequential `invoiceNumber`, `vatIrr = round(commission × vat_rate)` on the **commission line only**, `moadianStatus: "pending"`, `moadianReferenceNumber: null`. - **Failure:** `404` booking not found. ### `GET api/v1/refunds/{id}/status` *(customer-visible)* - **Purpose:** the customer-facing status of **their own** refund. - **Auth:** authenticated; **tenancy-scoped** to the booking's customer — another customer's refund is a clean `404`. - **Success `200` (`data`):** ```json { "id": 7, "bookingId": 42, "status": "processing", "refundChannel": "bnpl_revert", "amount": "10000000", "expectedCustomerRefundEta": "2026-08-24", "reference": "••••••ab12" } ``` The external reference is **masked** (last 4 only). - **Failure:** `401` unauth · `404` not found / not the caller's. ### `GET api/v1/invoices/{booking_id}` *(customer/admin)* - **Purpose:** the booking's invoice. **Auth:** authenticated — the owning customer or an admin (else `404`). - **Success `200` (`data`):** `Invoice` (see shapes), with `pdfUrl` when a PDF is stored. ## Shared shapes - `RefundListItem`: `id` (int), `bookingId` (int), `paymentTransactionId` (int), `amount` / `platformFeeRefundedIrr` / `nursePayoutRefundedIrr` (IRR digit-strings), `refundChannel` (enum), `status` (enum), `refundPercentage` (decimal), `reasonCategory` (string?), `cancellationPolicyCode` (string?), `refundPercentageApplied` (decimal?), `expectedCustomerRefundEta` (date?), `gatewayRefundReference` (string?), `externalRevertReference` (string?), `processedAt` (datetime?), `createdAt` (datetime). - `Invoice`: `id` (int), `bookingId` (int), `invoiceNumber` (string, unique/sequential), `issuingEntityType` (`platform`|`partner_center`), `grossIrr` / `platformCommissionIrr` (IRR digit-strings), `bnplCommissionIrr` (digit-string?), `vatRate` (decimal), `vatIrr` (IRR digit-string), `moadianReferenceNumber` (string?), `moadianStatus` (enum?), `pdfUrl` (string?), `issuedAt` (datetime). ## Load-bearing rules the client must honour - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number for math. - **Refunds are admin-only.** The only customer-visible surface is `refunds/{id}/status` — there is no self-service refund initiation. - **A card refund is immediate** (`succeeded`, no ETA); **a BNPL refund is `processing`** with an `expectedCustomerRefundEta` ~7–10 business days out — surface it as "on its way, ~N days". - **VAT is on the platform commission only** — never the nurse payout. - **External references are masked** in the customer status view. ## Changelog - b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice). - refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual `processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔ escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The `refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`. --- ## Refinement phase 3 additions (REQ-019/020/021 — customer refunds) - **`POST api/v1/bookings/{id}/cancel`** (customer) — cancels the booking (freezing the policy snapshot) AND opens its refund in one call → `RefundStatusDto`. Body `{ reasonCategory, reasonNotes?, sessionIds? }` (MVP cancels all un-started sessions; `sessionIds` is accepted for forward-compat). - **`GET api/v1/bookings/{id}/cancellation_policy`** (customer) — pre-cancel disclosure: resolves the applicable policy by **current** lead time + per-session refundability → `{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage, refundAmountIrr, feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr, appliesTo, leadTimeLabel, refundChannel, expectedCustomerRefundEta (null in preview), sessions: [{ bookingSessionId, sessionIndex, scheduledDate, refundable, reasonCode }] }`. `refundAmountIrr + feeAmountIrr = refundableAmountIrr`. - **`GET api/v1/refunds/by_booking/{bookingId}`** (customer) — the booking's latest refund status (404 if none). - **`RefundStatusDto`** gains `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt` (the fee-leg transparency split). - **Canonical `cancellation_policy_code` set** (seeded, stable — the frontend's `free_24h`/`partial_under_24h`/ `customer_no_show` were invented): **`standard_24h`** (customer ≥24h → full refund), **`standard_inside_24h`** (customer <24h → partial), **`nurse_no_show`** (nurse-initiated → full refund + penalty), **`admin_cancellation`** (admin → full refund). Per-session `reasonCode`: **`un_started`** when refundable, else the blocking session status.