# refunds — cancellation, reversal, clawbacks, invoices

> Client seam `client/src/services/refunds/` · `USE_REFUNDS_MOCK = true` (**mock is primary**) · 8 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).

Money going back. The customer half is thin and real; the admin half is largely deferred, which is why the
seam is mocked.

## Endpoints

| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/refunds/by_booking/{bookingId}` | `[Authorize]` | wired — the customer's refund for a booking |
| GET | `/api/v1/refunds/{id}/status` | `[Authorize]` | wired |
| GET | `/api/v1/admin_refunds` | admin · `sensitive` | wired · paginated |
| POST | `/api/v1/admin_refunds` | admin · `sensitive` | **unwired** — creates **and executes** in one call; the client wants preview → approve (REQ-035) |
| POST | `/api/v1/admin_refunds/{id}/confirm_settlement` | admin · `sensitive` | **unwired** — the manual-channel settlement confirm |
| POST | `/api/v1/admin_refunds/{id}/mark_failed` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/admin_clawbacks/{id}/write_off` | admin · `sensitive` | **unwired** — no console screen |
| POST | `/api/v1/admin_invoices` | admin · `sensitive` | **unwired** — owner-issue an invoice (REQ-018's fallback path) |

Also wired by this domain, documented with their owner ([bookings.md](bookings.md)):
`POST bookings/{id}/cancel` (cancel **and** refund, REQ-019) and
`GET bookings/{id}/cancellation_policy` (the pre-cancel preview, REQ-020).

### Phantom — 4

| Client call | REQ | Note |
| --- | --- | --- |
| `POST /api/v1/admin_refunds/preview` | REQ-035 | Deferred. Today's single `POST admin_refunds` creates+executes with no preview step |
| `POST /api/v1/admin_refunds/{id}/approve` | REQ-035 | Deferred |
| `POST /api/v1/admin_refunds/{id}/reject` | REQ-035 | Deferred |
| `GET /api/v1/refunds/my` | REQ-048 | The customer's "all my refunds" list. The wallet «استردادها» tab renders empty on the real path |

## The money rules

Four, and none of them are the client's to compute:

1. **A refund is a reversal leg, not a deletion.** `ledger_entries` is append-only; every posting group
   balances. Nothing is ever edited or removed.
2. **Fee-leg decomposition is served, not derived.** `RefundStatusDto` carries
   `platformFeeRefundedIrr` and `nursePayoutRefundedIrr` separately (REQ-021, delivered) — a partial refund
   does not necessarily refund the commission and the payout in the same proportion. Never split a total.
3. **Pre-payout and post-payout fork.** If the nurse has not been paid, the payout leg is simply reduced.
   If they have, the platform raises a **clawback**, which the payout engine nets against the nurse's next
   batch — whole-clawback greedy netting, never a partial. See [payouts.md](payouts.md).
4. **VAT is on commission only**, so a refund's VAT leg follows the commission leg, never the payout.

## Shape rules the JSON does not express

- **`refundChannel` decides the mechanics and is not a display detail**: `psp_card` reverses through the
  acquirer, `bnpl_revert` calls the BNPL provider's revert (see [bnpl.md](bnpl.md)), `manual` is a bank
  transfer an admin confirms with `confirm_settlement`. Each has a different ETA, and
  `expectedCustomerRefundEta` is server-computed per channel — the client displays it verbatim.
- **`CancellationPolicyPreviewDto` is the complete pre-cancel answer** (REQ-020, delivered):
  `cancellable`, `cancellationPolicyCode`, `refundPercentageApplied`, `feePercentage`, `refundAmountIrr`,
  `feeAmountIrr`, `refundableAmountIrr`, the two fee legs, `appliesTo`, `leadTimeLabel`, `refundChannel`,
  `expectedCustomerRefundEta`, and a **per-session** breakdown. The client shows it and asks for
  confirmation; it computes none of it.
- **Cancellation tiers are config rows**, not code — `cancellation_tier1/2/3_refund_rate` in
  [admin.md](admin.md) — and the applied rate is **snapshotted onto the refund** at compute time, so a
  later tier change is not retroactive.
- **`refundPercentage` on `CancellationPolicyDto` is a rate, `refundPercentageApplied` on the refund is the
  snapshot.** They can legitimately differ; that is the point.
- The crash-window fix in refinement-phase-6 wired the previously unreachable BNPL and manual settlement
  paths — a refund created against those channels now actually clears.

## Enums

| Vocabulary | Values |
| --- | --- |
| `RefundStatus` | `requested` `approved` `processing` `succeeded` `failed` `rejected` |
| `RefundChannel` | `psp_card` `bnpl_revert` `manual` |
| `ClawbackStatus` | `pending` `recovered` `written_off` |
| `CancellationPolicyCode` | `free_24h` `partial_under_24h` `customer_no_show` |
| `CancellationLeadTime` | `gt_24h` `lt_24h` `started` |
| `CancellationScope` | `whole_booking` `remaining_sessions` |
| `CancelReasonCategory` *(client)* | `changed_mind` `schedule_conflict` `found_other_care` `other` |

The first three are verified identical to `Entities/Refunds/RefundStatus.cs` and `ClawbackStatus.cs`.

## Open REQs

| REQ | Status | Effect |
| --- | --- | --- |
| REQ-035 | deferred | No admin preview / approve / reject → 3 phantom routes. The one live route creates and executes together, which the console will not call |
| REQ-048 | open | No customer "all my refunds" list → 1 phantom. The wallet tab renders empty on the real path |
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the partner per-booking commission invoice list is deferred. See [partner-center.md](partner-center.md) |
