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