8.4 KiB
Frontend Phase 10 — Cancellation & refund status (customer) — report
Consumes: dev/contracts/domains/refunds-invoices.md (b11).
Depends on: f8 (services/bookings, booking detail), f9 (services/payment, checkout/invoice, the
PriceBreakdown/EscrowNotice composites, the money util). Gate: npm run check green ·
npm run test:ci green (214 tests, +10). Mock-primary behind USE_REFUNDS_MOCK (default true).
What was built
A vertical slice — the customer half of the refund story: what cancelling costs (disclosed before confirm) and where the money is (a read-only refund status that tells the truth about the BNPL window).
services/refunds domain (new)
types.ts / constants.ts / keys.ts / invalidations.ts / apis/{clientApi,mockApi,index}.ts /
hooks/{useCancellationPolicyPreview,useCancelBooking,useRefundStatus}.ts / index.ts, mirroring the
services/payment + services/bookings shape exactly.
- Contract-derived enums:
RefundStatus=requested|approved|processing|succeeded|failed|rejected(the b11 set, forward-only),RefundChannel=psp_card|bnpl_revert|manual. The six statuses collapse onto three customer steps viarefundCustomerStep(submitted → on its way → completed,failed/rejected→ a distinct error state).isTerminalRefundStatusgates the poll. - Money: the refund is the decomposition of
gross = commission + payout. All IRR math is BigInt (integer parts-per-10000), never a float;refundAmount + fee = refundableGrossandplatformFeeRefunded + nursePayoutRefunded = totalRefundedreconcile to the rial (soPriceBreakdown's dev-guard never trips). - Caching:
useRefundStatuspolls (refetchInterval) only while non-terminal and stops atsucceeded/failed/rejected(and never polls thenullno-refund empty state); the cancel mutationsetQueryData-primes the fresh refund intorefundKeys.byBookingand invalidates the booking detail + lists + the policy preview (invalidations.ts) — no refetch storm.
Screens
- Cancellation flow
/bookings/[id]/cancel— step 1 discloses the resolved tier (label off thecancellation_policy_codei18n key, never the raw code), the refund % + fee %, the concrete Toman split (refunded vs kept, via the money util), and the multi-session refundable/locked breakdown; the confirm button is gated behind an explicit acknowledgement. Step 2 restates the amounts and submits viauseCancelBooking→ routes to the refund status. Copy makes the admin-approved, never-self-issued reality explicit. - Refund status
/bookings/[id]/refund_status— the three-step stepper, the refunded amount, the per-channel ETA (BNPL's honest ~7–10-business-day window fromexpected_customer_refund_eta), the optional fee-leg split, and afailed/rejectedcontact-support state (no retry — retry is admin-only, DEFERRED to f15). An empty state renders when the booking has no refund. - Booking detail
/bookings/[id]now composesCustomerBookingActions(page-only glue): the Cancel booking CTA while cancellable, or the refund section once cancelled — reusing the cached booking query key (no extra fetch); the refund read is enabled only after the booking is cancelled.
Shared composites (each with a co-located *.test.tsx)
CancellationPolicyDisclosure— the pre-confirm disclosure block (policy tier + %/fee + reconcilingPriceBreakdownrefund-vs-fee split + per-session refundable/locked + admin-approval explainer + ETA).RefundStatusCard— the 3-step stepper + amount + masked reference + optional fee-leg split + failed/contact-support state. Reused on the refund-status page and the booking-detail refund section.RefundEtaBanner— one branch onrefund_channel:bnpl_revertsurfaces the ~7–10-business-day window honestly (never instant),psp_card/manualtheir wording.
i18n
A new refunds namespace (69 keys) added to both messages/en.json and messages/fa.json in
sync (fa brand بالینیار, RTL-first). Policy-tier, refund-status, per-channel-ETA, per-session-reason,
and failed/contact-support strings are all i18n keys off the enum codes — never hardcoded off a raw code.
What is now testable and exactly how
Run npm run dev (the services/refunds mock is primary). Seeded bookings live in the f8 store:
| Booking | State | Demonstrates |
|---|---|---|
| 5001 | confirmed, 3 sessions (today) | multi-session cancel at the partial tier (<24h) |
| 5002 | confirmed, single (today) | BNPL cancel — processing refund + the ~7–10-day ETA banner, walks to completed over polls |
| 5003 | in_progress, 3 sessions (1 completed, 2 un-started >24h) | the mixed refundable/locked breakdown at the free tier |
| 5004 | already cancelled | the failed refund → contact-support state (no retry) |
- Disclosure before confirm. Open booking 5003 → Cancel booking → the resolved tier label, refund %
- fee %, and the Toman refund-vs-fee split render before confirm is enabled (acknowledgement gate).
- Multi-session breakdown. 5003 shows session 1 locked (completed) with a reason chip and sessions 2–3 refundable. 5001 shows all three refundable (partial tier).
- Refund progression. Cancel 5002 (BNPL) → refund status walks submitted → on its way → completed; polling stops at completed. Cancel 5001/5003 (card) → completed immediately.
- BNPL ETA. 5002's refund shows the ~7–10-business-day window + a Shamsi ETA + provider-routed wording.
- No self-refund. There is no issue/approve/retry control anywhere; 5004 shows contact-support copy.
- Locale + RTL. Toggle
fa/en→ every string flips and is present in both files. - Caching (Devtools). The cancel mutation invalidates
bookingKeys.bookingDetail/listsand primesrefundKeys.byBooking; the refund poll is active only while non-terminal; re-entry doesn't refetch.
What is mocked (and how to make it real)
The whole customer cancel + refund surface is mocked behind RefundsApi (USE_REFUNDS_MOCK = true) because
b11 shipped refunds admin-only — there is no customer cancel command, policy preview,
refund-by-booking lookup, or fee-leg decomposition on the customer status. The mock reads the shared f8
bookings store (tier by lead time + per-session refundability), flips the booking to cancelled, and drives
card-immediate / BNPL-processing refunds. See the mock registry RefundsApi row.
The real refundsClientApi maps the published refunds/{id}/status 1:1 and targets the proposed slugs for
the gaps; when REQ-019/020/021 land, flip the flag — no hook/component change.
Contract gaps filed (for-backend.md):
- REQ-019 — customer-initiated cancellation command (
POST bookings/{id}/cancel). - REQ-020 — pre-cancel cancellation-policy preview + per-session refundability + the canonical
cancellation_policy_codeset (the tier codesfree_24h/partial_under_24h/customer_no_showare client placeholders mapped to i18n keys). - REQ-021 —
GET refunds/by_booking/{id}+ the fee-leg decomposition / policy / timestamps on the customerrefunds/{id}/status(today admin-only); confirmprovider_commission_reversed_amountnullable.
Prior-phase files touched (in place, noted per operating-rules §0.3)
services/bookings/apis/mockApi.ts— added two non-seam exports (mockGetBookingForRefund,mockMarkBookingCancelled) alongsidemockInsertConvertedBooking, and two additive seeds (5003 mid-engagement, 5004 cancelled). Dated so they don't disturb the f8 today check-in demo; f8 tests build their own fixtures and don't read the store, so unaffected.constants/routes.ts—bookingCancelPath/bookingRefundStatusPathhelpers (id-keyed, likebookingInvoicePath).app/.../bookings/[id]/page.tsx— composesCustomerBookingActions.
Follow-ups for f15-b15 (admin)
The admin refund console (create/approve, leg-split editor, ticket linkage, the clawback "nurse already paid" banner, refund retry), self-service partial refund UI, and holiday-specific policy overrides remain DEFERRED. This phase is strictly the customer read + cancel-request surface.