23 KiB
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(checkout, payment, invoice + theservices/payment+ booking-detail surfaces) and the backend-phase-11 contract (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. 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. 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/bookingdomain and its query keys —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/paymentdomain, the commission/tax/escrow breakdown component —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: 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. This phase is strictly the customer read + cancel-request surface.
2. Required reading (do this first)
../_shared/agent-operating-rules.mdand../_shared/frontend-conventions-checklist.md.client/CLAUDE.md— the engineering contract (RSC/client boundary, layouts, i18n, theme/tokens, cookies,clientFetch/serverFetchservices, the anti-patterns). Non-negotiable.- Invoke the
frontend-designerskill — 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, theApp*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— 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— the BNPL refund truth: money flowscustomer ↔ provider ↔ Balinyaaronly, 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(b11) + the conventions it assumes —../../contracts/conventions/api-conventions.md(envelope,snake_caseroutes, status codes, pagination) and../../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 theservices/booking+services/paymentdomains from f8/f9 — copy their caching, key-factory, and mock-seam shape exactly. The shared composites insrc/components/and the money util insrc/utils/from f0. - Handoff: skim the latest backend handoff
dev/shared-working-context/backend/handoff/after-backend-phase-11.mdand the prior reports indev/shared-working-context/reports/for what b11 actually shipped and any contract caveats (e.g. theprovider_commission_reversed_amountnullable 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 fromrefunds-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; treatexternal_revert_referenceas 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, asessions: { 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). DeliberatestaleTime(policy preview is short-lived because it depends onnowvs the booking start — keep it fresh; refund status polls — see 3.3).apis/clientApi.tswrappingclientFetch(no rawfetch):resolveCancellationPolicy(bookingId),cancelBooking(bookingId, { sessionIds?, reason }),getRefundByBooking(bookingId),getRefund(refundId). AddserverApi.tsonly if an RSC needs to prefetch refund status for SSR.hooks/— one hook per file:useCancellationPolicyPreview.ts(useQuery),useCancelBooking.ts(useMutation; on success invalidatebookingKeys.detail/bookingKeys.listfrom f8 andrefundKeys.byBookingso the detail screen reflects the new cancelled/refund state without a manual refetch),useRefundStatus.ts(useQuerywith polling — see 3.3).index.tsbarrel.
3.2 Cancellation flow (policy-fee disclosure)
Entry point: a "Cancel booking" action on the customer booking-detail screen (built in f8). The flow:
- 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 offcancellation_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. - 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. - States: loading (resolving policy), the disclosure itself, submitting, success→refund-status,
error (e.g.
409outside-policy/state-machine, already-cancelled, payment-not-captured). Build a smallCancellationPolicyDisclosurecomposite insrc/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
failedto 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 fromexpected_customer_refund_etathat 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. Forpsp_cardshow the card-refund wording; formanualshow the manual-transfer wording. Drive all three off the same component branching onrefund_channel. - Polling without over-fetching.
useRefundStatuspolls (refetchInterval) only while the status is non-terminal (pending/processing); stop polling (intervalfalse) oncecompleted/failed. Set a sanestaleTime/gcTimeso re-entering the screen doesn't re-hit the network needlessly. Invalidate /setQueryDatafrom 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/RefundEtaBannercomposite insrc/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. 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: the moment a needed shape is missing or ambiguous
in refunds-invoices.md, append the gap to
dev/shared-working-context/frontend/requests/for-backend.md
and mock behind the services/refunds seam meanwhile:
- Put the mock behind the same
apis/clientApi.tsinterface the real calls use (a mockclientApiselected by config/env, never anif (mock)scattered in a hook). The mock returns contract-shaped data that exercises every UI state: a card refund walkingpending → processing → completed, abnpl_revertrefund with a futureexpected_customer_refund_eta(so the 7–10-day banner renders), afailedrefund, a multi-session policy preview with mixed refundable/locked sessions, and an outside-policy409on cancel. - Record the mock in your report and in
dev/shared-working-context/reports/mocks-registry.mdso 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 theexpected_customer_refund_etaand 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
BIGINTon 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 JSnumber. The refund is the decomposition ofgross = balinyaar_commission + nurse_payout— render the fee leg and the payout leg from the contract'splatform_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_channelmap to i18n keys in both locales; treatexternal_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, plus:
services/refundsexists (types.ts/keys.ts/apis/hooks/index.ts) with types derived from the b11 contract; live calls go throughclientFetch, with the documented mockclientApibehind 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.jsonin sync; RTL verified; colours from tokens;npm run checkgreen andnpm run test:cigreen for the shared components added. client/CLAUDE.mdProject Structure updated for the newservices/refundsdomain, 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; 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):
- 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.
- 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.
- 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. - BNPL ETA. A
bnpl_revertmock refund shows the ~7–10 business-day window with a Shamsi date fromexpected_customer_refund_etaand provider-routed wording — not "instant". - No self-refund. There is no customer-facing "issue/approve refund" control anywhere;
failedshows contact-support copy, not a retry button. - Locale + RTL. Toggle
fa/en→ every string flips and is present in both files; layout mirrors correctly in RTL; dark mode intact. - Caching. In React Query Devtools: the cancel mutation invalidates
bookingKeys.detailandrefundKeys.byBooking; refund-status polling is active only while non-terminal; re-entering the screen doesn't trigger a needless refetch. npm run checkandnpm run test:cipass.
8. Hand off & document (close the phase)
- Docs: update the Project Structure tree in
client/CLAUDE.mdfor the newservices/refundsdomain, 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 theproduct/docs don't capture, record it there (don't invent rules). - Contracts: this phase consumes
refunds-invoices.md(b11) — deriveservices/refunds/types.tsfrom it (and the publishedswagger.jsonsnapshot 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_amountnullability) todev/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; writedev/shared-working-context/reports/frontend-phase-10-report.md(what was built, what is now testable and exactly how, what is mocked behind theservices/refundsseam 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 for theservices/refundsmock. - Memory: save a
projectmemory 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-lineMEMORY.mdpointer.