# Frontend Phase 10 — Cancellation & refund status (customer) — report

**Consumes:** [`dev/contracts/domains/refunds-invoices.md`](../../contracts/domains/refunds-invoices.md) (b11).
**Depends on:** f8 (`services/bookings`, booking detail), f9 (`services/payment`, checkout/invoice, the
`PriceBreakdown`/`EscrowNotice` composites, the money util). **Gate:** `npm run check` green ·
`npm run test:ci` green (**214 tests, +10**). **Mock-primary** behind `USE_REFUNDS_MOCK` (default `true`).

---

## What was built

A vertical slice — the **customer** half of the refund story: *what cancelling costs* (disclosed before
confirm) and *where the money is* (a read-only refund status that tells the truth about the BNPL window).

### `services/refunds` domain (new)
`types.ts` / `constants.ts` / `keys.ts` / `invalidations.ts` / `apis/{clientApi,mockApi,index}.ts` /
`hooks/{useCancellationPolicyPreview,useCancelBooking,useRefundStatus}.ts` / `index.ts`, mirroring the
`services/payment` + `services/bookings` shape exactly.
- **Contract-derived enums:** `RefundStatus` = `requested|approved|processing|succeeded|failed|rejected`
  (the b11 set, forward-only), `RefundChannel` = `psp_card|bnpl_revert|manual`. The six statuses collapse
  onto three customer steps via `refundCustomerStep` (*submitted → on its way → completed*, `failed`/
  `rejected` → a distinct error state). `isTerminalRefundStatus` gates the poll.
- **Money:** the refund is the decomposition of `gross = commission + payout`. All IRR math is **BigInt**
  (integer parts-per-10000), never a float; `refundAmount + fee = refundableGross` and
  `platformFeeRefunded + nursePayoutRefunded = totalRefunded` reconcile to the rial (so `PriceBreakdown`'s
  dev-guard never trips).
- **Caching:** `useRefundStatus` polls (`refetchInterval`) **only while non-terminal** and stops at
  `succeeded`/`failed`/`rejected` (and never polls the `null` no-refund empty state); the cancel mutation
  `setQueryData`-primes the fresh refund into `refundKeys.byBooking` and invalidates the booking detail +
  lists + the policy preview (`invalidations.ts`) — no refetch storm.

### Screens
- **Cancellation flow** `/bookings/[id]/cancel` — step 1 **discloses** the resolved tier (label off the
  `cancellation_policy_code` i18n key, never the raw code), the **refund % + fee %**, the concrete Toman
  split (refunded vs kept, via the money util), and the multi-session refundable/locked breakdown; the
  confirm button is gated behind an explicit acknowledgement. Step 2 restates the amounts and submits via
  `useCancelBooking` → routes to the refund status. Copy makes the **admin-approved, never-self-issued**
  reality explicit.
- **Refund status** `/bookings/[id]/refund_status` — the three-step stepper, the refunded amount, the
  per-channel ETA (BNPL's honest ~7–10-business-day window from `expected_customer_refund_eta`), the
  optional fee-leg split, and a `failed`/`rejected` **contact-support** state (**no retry** — retry is
  admin-only, DEFERRED to f15). An empty state renders when the booking has no refund.
- **Booking detail** `/bookings/[id]` now composes `CustomerBookingActions` (page-only glue): the **Cancel
  booking** CTA while cancellable, or the **refund section** once cancelled — reusing the cached booking
  query key (no extra fetch); the refund read is enabled only after the booking is cancelled.

### Shared composites (each with a co-located `*.test.tsx`)
- **`CancellationPolicyDisclosure`** — the pre-confirm disclosure block (policy tier + %/fee + reconciling
  `PriceBreakdown` refund-vs-fee split + per-session refundable/locked + admin-approval explainer + ETA).
- **`RefundStatusCard`** — the 3-step stepper + amount + masked reference + optional fee-leg split +
  failed/contact-support state. Reused on the refund-status page and the booking-detail refund section.
- **`RefundEtaBanner`** — one branch on `refund_channel`: `bnpl_revert` surfaces the ~7–10-business-day
  window honestly (never instant), `psp_card`/`manual` their wording.

### i18n
A new **`refunds`** namespace (69 keys) added to **both** `messages/en.json` and `messages/fa.json` in
sync (fa brand `بالین‌یار`, RTL-first). Policy-tier, refund-status, per-channel-ETA, per-session-reason,
and failed/contact-support strings are all i18n keys off the enum codes — never hardcoded off a raw code.

---

## What is now testable and exactly how

Run `npm run dev` (the `services/refunds` mock is primary). Seeded bookings live in the f8 store:

| Booking | State | Demonstrates |
| --- | --- | --- |
| **5001** | confirmed, 3 sessions (today) | multi-session cancel at the **partial** tier (<24h) |
| **5002** | confirmed, single (today) | **BNPL** cancel — `processing` refund + the ~7–10-day ETA banner, walks to completed over polls |
| **5003** | in_progress, 3 sessions (1 completed, 2 un-started >24h) | the **mixed refundable/locked** breakdown at the **free** tier |
| **5004** | already cancelled | the **failed** refund → contact-support state (no retry) |

1. **Disclosure before confirm.** Open booking 5003 → *Cancel booking* → the resolved tier label, refund %
   + fee %, and the Toman refund-vs-fee split render **before** confirm is enabled (acknowledgement gate).
2. **Multi-session breakdown.** 5003 shows session 1 **locked** (completed) with a reason chip and sessions
   2–3 **refundable**. 5001 shows all three refundable (partial tier).
3. **Refund progression.** Cancel 5002 (BNPL) → refund status walks *submitted → on its way → completed*;
   polling stops at completed. Cancel 5001/5003 (card) → **completed** immediately.
4. **BNPL ETA.** 5002's refund shows the ~7–10-business-day window + a Shamsi ETA + provider-routed wording.
5. **No self-refund.** There is **no** issue/approve/retry control anywhere; 5004 shows contact-support copy.
6. **Locale + RTL.** Toggle `fa`/`en` → every string flips and is present in both files.
7. **Caching (Devtools).** The cancel mutation invalidates `bookingKeys.bookingDetail`/`lists` and primes
   `refundKeys.byBooking`; the refund poll is active only while non-terminal; re-entry doesn't refetch.

---

## What is mocked (and how to make it real)

The whole customer cancel + refund surface is mocked behind `RefundsApi` (`USE_REFUNDS_MOCK = true`) because
b11 shipped refunds **admin-only** — there is **no** customer cancel command, policy preview,
refund-by-booking lookup, or fee-leg decomposition on the customer status. The mock reads the shared f8
bookings store (tier by lead time + per-session refundability), flips the booking to `cancelled`, and drives
card-immediate / BNPL-`processing` refunds. See the [mock registry](./mocks-registry.md) `RefundsApi` row.
The real `refundsClientApi` maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for
the gaps; when REQ-019/020/021 land, flip the flag — no hook/component change.

**Contract gaps filed** ([`for-backend.md`](../frontend/requests/for-backend.md)):
- **REQ-019** — customer-initiated cancellation command (`POST bookings/{id}/cancel`).
- **REQ-020** — pre-cancel cancellation-policy preview + per-session refundability + the canonical
  `cancellation_policy_code` set (the tier codes `free_24h`/`partial_under_24h`/`customer_no_show` are
  client placeholders mapped to i18n keys).
- **REQ-021** — `GET refunds/by_booking/{id}` + the fee-leg decomposition / policy / timestamps on the
  customer `refunds/{id}/status` (today admin-only); confirm `provider_commission_reversed_amount` nullable.

## Prior-phase files touched (in place, noted per operating-rules §0.3)
- `services/bookings/apis/mockApi.ts` — added two **non-seam** exports (`mockGetBookingForRefund`,
  `mockMarkBookingCancelled`) alongside `mockInsertConvertedBooking`, and two additive seeds (5003
  mid-engagement, 5004 cancelled). Dated so they don't disturb the f8 *today* check-in demo; f8 tests build
  their own fixtures and don't read the store, so unaffected.
- `constants/routes.ts` — `bookingCancelPath` / `bookingRefundStatusPath` helpers (id-keyed, like
  `bookingInvoicePath`).
- `app/.../bookings/[id]/page.tsx` — composes `CustomerBookingActions`.

## Follow-ups for f15-b15 (admin)
The admin refund console (create/approve, leg-split editor, ticket linkage, the clawback "nurse already
paid" banner, **refund retry**), self-service *partial* refund UI, and holiday-specific policy overrides
remain DEFERRED. This phase is strictly the customer read + cancel-request surface.
