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

23 KiB
Raw Permalink Blame History

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 (checkout, payment, invoice + the services/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 baselinefrontend-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 keysfrontend-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 componentfrontend-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 contractrefunds-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.md and ../_shared/frontend-conventions-checklist.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 — 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 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 (b11) + the conventions it assumes — ../../contracts/conventions/api-conventions.md (envelope, snake_case routes, 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 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, 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_codenever 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. 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.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 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, 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; 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 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 (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 — never edit a backend-owned file.
  • Handoff & report: append a summary to dev/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 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.