frontend phase 10

This commit is contained in:
hamid
2026-07-10 12:51:53 +03:30
parent 40cc1d163b
commit ccfa27aff6
32 changed files with 2151 additions and 3 deletions
@@ -0,0 +1,117 @@
# Frontend Phase 10 — Cancellation & refund status (customer) — report
**Consumes:** [`dev/contracts/domains/refunds-invoices.md`](../../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 via `refundCustomerStep` (*submitted → on its way → completed*, `failed`/
`rejected` → a distinct error state). `isTerminalRefundStatus` gates 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 = refundableGross` and
`platformFeeRefunded + nursePayoutRefunded = totalRefunded` reconcile to the rial (so `PriceBreakdown`'s
dev-guard never trips).
- **Caching:** `useRefundStatus` polls (`refetchInterval`) **only while non-terminal** and stops at
`succeeded`/`failed`/`rejected` (and never polls the `null` no-refund empty state); the cancel mutation
`setQueryData`-primes the fresh refund into `refundKeys.byBooking` and 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 the
`cancellation_policy_code` i18n 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 via
`useCancelBooking` → 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 ~710-business-day window from `expected_customer_refund_eta`), the
optional fee-leg split, and a `failed`/`rejected` **contact-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 composes `CustomerBookingActions` (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 + reconciling
`PriceBreakdown` refund-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 on `refund_channel`: `bnpl_revert` surfaces the ~710-business-day
window honestly (never instant), `psp_card`/`manual` their 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 ~710-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) |
1. **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).
2. **Multi-session breakdown.** 5003 shows session 1 **locked** (completed) with a reason chip and sessions
23 **refundable**. 5001 shows all three refundable (partial tier).
3. **Refund progression.** Cancel 5002 (BNPL) → refund status walks *submitted → on its way → completed*;
polling stops at completed. Cancel 5001/5003 (card) → **completed** immediately.
4. **BNPL ETA.** 5002's refund shows the ~710-business-day window + a Shamsi ETA + provider-routed wording.
5. **No self-refund.** There is **no** issue/approve/retry control anywhere; 5004 shows contact-support copy.
6. **Locale + RTL.** Toggle `fa`/`en` → every string flips and is present in both files.
7. **Caching (Devtools).** The cancel mutation invalidates `bookingKeys.bookingDetail`/`lists` and primes
`refundKeys.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](./mocks-registry.md) `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`](../frontend/requests/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_code` set (the tier codes `free_24h`/`partial_under_24h`/`customer_no_show` are
client placeholders mapped to i18n keys).
- **REQ-021** — `GET refunds/by_booking/{id}` + the fee-leg decomposition / policy / timestamps on the
customer `refunds/{id}/status` (today admin-only); confirm `provider_commission_reversed_amount` nullable.
## Prior-phase files touched (in place, noted per operating-rules §0.3)
- `services/bookings/apis/mockApi.ts` — added two **non-seam** exports (`mockGetBookingForRefund`,
`mockMarkBookingCancelled`) alongside `mockInsertConvertedBooking`, 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` / `bookingRefundStatusPath` helpers (id-keyed, like
`bookingInvoicePath`).
- `app/.../bookings/[id]/page.tsx` — composes `CustomerBookingActions`.
## 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.