280 lines
23 KiB
Markdown
280 lines
23 KiB
Markdown
# 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.
|