# Frontend Phase 10 — Cancellation & refund status (customer) > **Mission:** give the family an honest, trust-first picture of *what cancelling costs* and *where their > money is*. Before a customer confirms a cancellation, the screen must **resolve the applicable > cancellation policy by lead time** and **disclose the fee / refund percentage up front** — no surprise > charges. After a cancellation, the customer follows a read-only **refund status** (pending → on-its-way > with an expected ETA → completed) that tells the truth about the asynchronous BNPL window (~7–10 > business days). Refunds themselves are admin-approved — the customer can *request* a cancellation and > *see* the refund's progress, but never self-issues money. This is the customer half of the refund story; > the admin refund console is built later. > > **Track:** frontend · **Depends on:** [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md) (checkout, > payment, invoice + the `services/payment` + booking-detail surfaces) and the **backend-phase-11** > contract ([`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md)) · **Unlocks:** nothing > downstream depends on it; it completes the post-payment customer flow before BNPL checkout (f11-b12). > **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional. --- ## 1. Context — where this sits The customer can now search, request, book, pay, and view a booking with its sessions and invoice. The one thing missing from the money lifecycle on the customer side is the **exit**: cancelling a booking and watching the refund land. Balinyaar is a *trust-first* marketplace — the cancellation screen exists precisely so a family is never charged a fee they didn't see coming, and the refund-status screen exists so they're never left wondering whether a card or (especially) a BNPL refund is actually moving. This phase builds those two read-heavy, decision-critical screens against the `refunds` contract from b11. **What already exists (do not rebuild) — link the prior phases:** - **App shells, the `services/{domain}` + TanStack Query caching pattern, the contracts→types pattern, the money/format util (`formatIrrToToman`, integer-safe IRR parse, Shamsi date display), the shared composites (status chip, stepper/progress header, price-breakdown), the i18n namespaces and RTL baseline** — [`frontend-phase-0.md`](./frontend-phase-0.md). Reuse all of it; do not re-create a money util, a status chip, or a service skeleton. - **Booking detail, sessions, EVV status timeline, the `services/booking` domain and its query keys** — [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md). The cancel entry point hangs off the booking-detail screen; per-session cancellability comes from the session rows you already render there. - **Checkout, card payment (mock redirect), confirmation, invoice view, the `services/payment` domain, the commission/tax/escrow breakdown component** — [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md). The refund-status screen reuses that domain's money-rendering and links back to the same booking/invoice. - **The published b11 contract** — [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md): the refund read shape (status, `refund_channel`, fee-leg decomposition, `expected_customer_refund_eta`, `refund_percentage_applied`, `cancellation_policy_code`), the resolve-cancellation-policy query, and the customer-initiated cancel command. **Types come from this contract, not from guesses.** > **Admin side is out of scope.** The admin refund console (create/approve refunds, leg-split editor, > ticket linkage, clawback banner, retry) is **(DEFERRED)** to **f15-b15** — see > [the roadmap](../README.md). This phase is strictly the **customer** read + cancel-request surface. ## 2. Required reading (do this first) - [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md). - [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary, layouts, i18n, theme/tokens, cookies, `clientFetch`/`serverFetch` services, the anti-patterns). Non-negotiable. - **Invoke the `frontend-designer` skill** — every screen, banner, ETA card, fee-disclosure dialog, and status step in this phase is visual work and must go through it (palette, tokens, typography, the `App*` library, RTL mirroring, dark-mode, the layout shells). Do not hand-roll styling. - **Product — the business + money rules you must encode in the UI (read both fully):** - [`../../../product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md) — tiered policy by lead time + actor (free >24h, partial <24h, customer no-show up to 100%, nurse no-show full refund), the **policy is snapshotted on the booking**, refunds are **admin-only + ticket-linked**, refunds **decompose across the two fee legs**, and **per-remaining-session** cancellation for multi-session engagements. - [`../../../product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) — the BNPL refund truth: money flows `customer ↔ provider ↔ Balinyaar` only, the provider unwinds asynchronously, **already-paid installments return to the customer's bank in ~7–10 business days**, and the UI must **surface that window honestly** (never imply instant). - **Contract to consume:** [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md) (b11) + the conventions it assumes — [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, `snake_case` routes, status codes, pagination) and [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (**IRR as a string of digits, Toman display-only, no floats; `refund_channel` = `psp_card` | `bnpl_revert` | `manual`; UTC ISO-8601, Shamsi display is a client concern**). - **Code to mirror:** the existing `src/services/auth/*` skeleton (`types.ts`/`keys.ts`/`apis/clientApi.ts`/ `hooks/use*.ts`/`index.ts`) and the `services/booking` + `services/payment` domains from f8/f9 — copy their caching, key-factory, and mock-seam shape exactly. The shared composites in `src/components/` and the money util in `src/utils/` from f0. - **Handoff:** skim the latest backend handoff `dev/shared-working-context/backend/handoff/after-backend-phase-11.md` and the prior reports in `dev/shared-working-context/reports/` for what b11 actually shipped and any contract caveats (e.g. the `provider_commission_reversed_amount` nullable note). ## 3. Scope — build this A vertical slice: **service → hooks → screens**, all customer-scoped, all RTL/both-locales, all money via the util. Build the two wireframe screens — **cancellation flow (policy-fee disclosure)** and **customer refund status (BNPL ETA)**. ### 3.1 `services/refunds` domain (read + cancel) A new domain folder `src/services/refunds/` mirroring `auth`/`booking`/`payment`: - `types.ts` — string-literal unions + DTOs **derived from [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md)**, not invented. At minimum: - `RefundStatus` = `pending` | `processing` | `completed` | `failed` (mirror the contract's exact set; the customer-facing wording maps these to *pending → on-its-way → completed*). - `RefundChannel` = `psp_card` | `bnpl_revert` | `manual`. - `RefundSummary` — `{ id, booking_id, refund_status, refund_channel, refund_percentage_applied, cancellation_policy_code, platform_fee_refunded_irr, nurse_payout_refunded_irr, total_refunded_irr, expected_customer_refund_eta, external_revert_reference, created_at, completed_at }` (IRR fields are **strings of digits**; timestamps UTC ISO-8601; treat `external_revert_reference` as opaque). - `CancellationPolicyPreview` — `{ cancellation_policy_code, refund_percentage_applied, fee_percentage, refund_amount_irr, fee_amount_irr, applies_to, lead_time_label }` plus, for multi-session bookings, a `sessions: { booking_session_id, refundable, reason_code }[]` breakdown (refundable = un-started; locked = completed-and-verified). - `keys.ts` — a query-key factory: `refundKeys.policyPreview(bookingId)`, `refundKeys.byBooking(bookingId)`, `refundKeys.detail(refundId)`. Deliberate `staleTime` (policy preview is short-lived because it depends on `now` vs the booking start — keep it fresh; refund status polls — see 3.3). - `apis/clientApi.ts` wrapping `clientFetch` (no raw `fetch`): `resolveCancellationPolicy(bookingId)`, `cancelBooking(bookingId, { sessionIds?, reason })`, `getRefundByBooking(bookingId)`, `getRefund(refundId)`. Add `serverApi.ts` only if an RSC needs to prefetch refund status for SSR. - `hooks/` — one hook per file: `useCancellationPolicyPreview.ts` (`useQuery`), `useCancelBooking.ts` (`useMutation`; on success **invalidate** `bookingKeys.detail`/`bookingKeys.list` from f8 *and* `refundKeys.byBooking` so the detail screen reflects the new cancelled/refund state without a manual refetch), `useRefundStatus.ts` (`useQuery` with polling — see 3.3). - `index.ts` barrel. ### 3.2 Cancellation flow (policy-fee disclosure) Entry point: a **"Cancel booking"** action on the customer booking-detail screen (built in f8). The flow: 1. **Disclosure step — built from `resolveCancellationPolicy`.** Before any confirm, fetch and show the resolved tier: the human policy label (free / partial / under-24h, by i18n key off `cancellation_policy_code` — **never** render a label off the raw code), the **refund %** and the **fee/penalty %**, and the concrete **amount you'll get back** vs **amount kept** (via the money util, in Toman, integer-safe from the IRR strings). Reuse the f0 **price-breakdown** composite for the refund-vs-fee split. If the booking is **multi-session**, render the per-session breakdown: which sessions are **refundable** (un-started) and which are **locked** (completed-and-verified, stay payout-eligible) — disabled rows with a reason chip. 2. **Confirm step — only after disclosure.** A confirm dialog/screen that restates "you will be refunded X, a fee of Y applies" and a reason field, wired to `useCancelBooking`. On success, route to / reveal the **refund status** for this booking. Surface the **admin-approval reality**: the copy makes clear the cancellation request is submitted and the refund is processed by the team (the customer does not self-issue money) — match the product doc's admin-only, ticket-linked rule. 3. **States:** loading (resolving policy), the disclosure itself, submitting, success→refund-status, error (e.g. `409` outside-policy/state-machine, already-cancelled, payment-not-captured). Build a small **`CancellationPolicyDisclosure`** composite in `src/components/` (reused by the dialog and any future per-session cancel) with a co-located `*.test.tsx`; keep page-only glue in the page. ### 3.3 Customer refund status (BNPL ETA) A customer-facing **read-only** refund-status surface for a booking (a section on booking-detail and/or a dedicated `.../refund_status` screen): - A **status stepper** mapping the contract status to the three customer-facing steps: **pending → on-its-way → completed** (reuse the f0 **stepper/progress header**; map `failed` to a distinct error state, not a 4th happy step). - The refunded amount (total, via the money util) and, where the design calls for it, the fee-leg split for transparency. - **The BNPL ETA, surfaced honestly.** When `refund_channel === 'bnpl_revert'`, render an **ETA card** built from `expected_customer_refund_eta` that states the **~7–10 business-day** window in plain language (Shamsi-formatted date via the util) and explains the refund returns through the BNPL provider — never imply it's instant. For `psp_card` show the card-refund wording; for `manual` show the manual-transfer wording. Drive all three off the same component branching on `refund_channel`. - **Polling without over-fetching.** `useRefundStatus` polls (`refetchInterval`) **only while the status is non-terminal** (`pending`/`processing`); stop polling (interval `false`) once `completed`/`failed`. Set a sane `staleTime`/`gcTime` so re-entering the screen doesn't re-hit the network needlessly. Invalidate / `setQueryData` from the cancel mutation so the first render is warm. - **States:** loading, no-refund/empty (booking has no refund — e.g. not cancelled), pending, on-its-way (with ETA), completed, failed/needs-attention (contact support copy — *not* a retry button; retry is admin-only, DEFERRED to f15). Build a shared **`RefundStatusCard`** / **`RefundEtaBanner`** composite in `src/components/` with co-located tests. ### 3.4 i18n Add a `refunds` namespace (and any `cancellation` keys) to **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first: policy-tier labels keyed by `cancellation_policy_code`, the three refund-status step labels, the per-channel ETA copy (`bnpl_revert` 7–10-day window, `psp_card`, `manual`), the fee-disclosure strings, the admin-approval explainer, and the failed/contact-support copy. **Never** hardcode a label off an enum code — codes map to keys. **(DEFERRED) — explicitly out of scope this phase:** admin refund create/approve console, leg-split editor, ticket linkage, clawback "nurse already paid" banner, refund retry, self-service *partial* refund UI, and holiday-specific policy overrides — all to **f15-b15** (admin) per [the roadmap](../README.md). Build the read + cancel-request customer surface only. ## 4. Mocks & seams in this phase This phase **introduces no new cross-cutting seam** — it reuses the established frontend mock pattern. Per [operating-rules §6](../_shared/agent-operating-rules.md): the moment a needed shape is missing or ambiguous in [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md), **append the gap to** [`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and **mock behind the `services/refunds` seam** meanwhile: - Put the mock behind the same `apis/clientApi.ts` interface the real calls use (a mock `clientApi` selected by config/env, **never** an `if (mock)` scattered in a hook). The mock returns contract-shaped data that exercises every UI state: a card refund walking `pending → processing → completed`, a `bnpl_revert` refund with a future `expected_customer_refund_eta` (so the 7–10-day banner renders), a `failed` refund, a multi-session policy preview with mixed refundable/locked sessions, and an outside-policy `409` on cancel. - Record the mock in your **report** and in [`dev/shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so it's swapped cleanly for the real b11 endpoints once they're confirmed live. Reuse the auth/payment service for the live-vs-mock selection convention; do not invent a new one. ## 5. Critical rules you must not get wrong - **Disclose the fee/refund *before* confirm.** The applicable cancellation policy (resolved by lead time and actor) and its **refund % + fee %** must be on screen and acknowledged **before** the user can submit the cancellation — an outside-policy fee is never a surprise. This is the whole point of the screen. - **Refunds are admin-approved; the customer cannot self-refund.** The UI lets the customer *request* a cancellation and *track* the refund — it must **never** present a "refund yourself" / "issue refund" action. Reflect the admin-only, ticket-linked reality in the copy. - **Surface the BNPL async window honestly.** For `refund_channel === 'bnpl_revert'`, show the `expected_customer_refund_eta` and the **~7–10 business-day** window in plain language; never imply the money is back instantly. Money flows back **through the provider** — don't imply Balinyaar pays the customer directly. - **Money is IRR `BIGINT` on the wire as a string of digits — no floats, ever.** Parse and format **only** through the f0 money util (`formatIrrToToman`, integer-safe IRR parse); Toman is **display-only**; never do client-side arithmetic that coerces an IRR string to a JS `number`. The refund is the **decomposition of `gross = balinyaar_commission + nurse_payout`** — render the fee leg and the payout leg from the contract's `platform_fee_refunded_irr` / `nurse_payout_refunded_irr`; do not recompute the split client-side. - **Per-session, not all-or-nothing.** For multi-session bookings, only **un-started** sessions are refundable; **completed-and-verified** sessions stay payout-eligible and must render as locked — never offer to refund a session the contract marks non-refundable. - **Never render a label off a raw enum code.** `cancellation_policy_code`, `refund_status`, `refund_channel` map to **i18n keys** in both locales; treat `external_revert_reference`/IDs as opaque strings. - **Caching is a feature.** Poll refund status **only** while non-terminal, stop at `completed`/`failed`; invalidate booking + refund queries on the cancel mutation so nothing stale lingers; don't re-fetch the policy preview on every keystroke. Respect the RSC/client boundary; MUI primitives stay MUI; shareable composites (`CancellationPolicyDisclosure`, `RefundStatusCard`, `RefundEtaBanner`) live shared, not in a page. Colours from tokens, MUI v9 API only, both locales in sync, RTL-correct. ## 6. Definition of Done The shared [definition-of-done.md](../_shared/definition-of-done.md), plus: - [ ] `services/refunds` exists (`types.ts`/`keys.ts`/`apis`/`hooks`/`index.ts`) with types **derived from the b11 contract**; live calls go through `clientFetch`, with the documented mock `clientApi` behind the same seam until b11 endpoints are confirmed live. - [ ] **Cancellation flow** resolves and **discloses the applicable policy fee/refund % before confirm**, handles the multi-session refundable/locked breakdown, and submits via `useCancelBooking` (which invalidates booking + refund caches). - [ ] **Refund-status** screen renders pending → on-its-way → completed, plus failed; the **BNPL channel shows the ~7–10-day window** from `expected_customer_refund_eta`; polling runs only while non-terminal. - [ ] All money rendered via the f0 util (Toman display, integer-safe), no floats; the fee-leg split comes from the contract, not client math. - [ ] New shared composites have co-located `*.test.tsx`; `en.json`/`fa.json` in sync; RTL verified; colours from tokens; `npm run check` green and `npm run test:ci` green for the shared components added. - [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/refunds` domain, any new route segment, and the new shared components; any doc drift you touched corrected. - [ ] Every contract gap hit was appended to [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md); the mock is recorded in the registry; the phase report is written. ## 7. How to test (what a human can verify after this phase) Run `npm run dev` (with the `services/refunds` mock active until the b11 endpoints are confirmed live): 1. **Policy disclosure before confirm.** Open a booking → **Cancel** → the screen shows the resolved tier (label from the i18n key, not the raw code), the **refund %** and **fee %**, and the concrete Toman amounts refunded vs kept — **before** any confirm button is enabled. Mock a >24h lead time → free/100% refund; mock a <24h lead time → partial refund + fee. Confirm → routes to refund status. 2. **Multi-session breakdown.** Open a multi-session booking's cancel flow → un-started sessions show as refundable, completed-and-verified sessions show as **locked** with a reason chip and cannot be selected. 3. **Refund status progression.** On the refund-status screen, the mock walks `pending → processing → completed`; the stepper advances pending → on-its-way → completed; polling stops once completed. 4. **BNPL ETA.** A `bnpl_revert` mock refund shows the **~7–10 business-day** window with a Shamsi date from `expected_customer_refund_eta` and provider-routed wording — not "instant". 5. **No self-refund.** There is **no** customer-facing "issue/approve refund" control anywhere; `failed` shows contact-support copy, **not** a retry button. 6. **Locale + RTL.** Toggle `fa`/`en` → every string flips and is present in both files; layout mirrors correctly in RTL; dark mode intact. 7. **Caching.** In React Query Devtools: the cancel mutation invalidates `bookingKeys.detail` and `refundKeys.byBooking`; refund-status polling is active only while non-terminal; re-entering the screen doesn't trigger a needless refetch. 8. `npm run check` and `npm run test:ci` pass. ## 8. Hand off & document (close the phase) - **Docs:** update the *Project Structure* tree in [`client/CLAUDE.md`](../../../client/CLAUDE.md) for the new `services/refunds` domain, the new shared composites (`CancellationPolicyDisclosure`, `RefundStatusCard`, `RefundEtaBanner`), and any new route segment; note the refund-status polling convention if it's a new reusable pattern. Fix any drift you touched. If you discovered a refund/ cancellation business rule the `product/` docs don't capture, record it there (don't invent rules). - **Contracts:** this phase **consumes** [`refunds-invoices.md`](../../contracts/domains/refunds-invoices.md) (b11) — derive `services/refunds/types.ts` from it (and the published `swagger.json` snapshot for exact casing); produce no contract. Append any missing/ambiguous shape (e.g. the customer-cancel command's body, the per-session refundability flags, `provider_commission_reversed_amount` nullability) to [`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) — never edit a backend-owned file. - **Handoff & report:** append a summary to [`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write `dev/shared-working-context/reports/frontend-phase-10-report.md` (what was built, **what is now testable and exactly how**, what is mocked behind the `services/refunds` seam and how it's swapped for the real b11 endpoints, the contract consumed + any gaps filed, follow-ups for f15-b15 admin). Update the [mock registry](../../shared-working-context/reports/mocks-registry.md) for the `services/refunds` mock. - **Memory:** save a `project` memory note for any non-obvious decision (the refund-status step mapping pending→on-its-way→completed, the polling-only-while-non-terminal rule, the BNPL-ETA honesty rule, the admin-only refund constraint reflected in UI), with a one-line `MEMORY.md` pointer.