Files
baya-monorepo/dev/contracts/domains/refunds-invoices.md
T
2026-07-09 02:13:30 +03:30

128 lines
7.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` (01 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` ~710 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).