128 lines
7.6 KiB
Markdown
128 lines
7.6 KiB
Markdown
# 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)`; posts the balanced ledger reversal 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. `ticketId` is required only when `refund_ticket_required` config is
|
||
on (off until b15). Notifies the customer.
|
||
|
||
### `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<RefundListItem>` (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).
|