# 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.