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

173 lines
12 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)`; 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<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).
- 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.