Files
2026-06-28 21:59:59 +03:30

280 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (~710
> 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 ~710 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 **~710 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` 710-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 710-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 **~710 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 ~710-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 **~710 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.