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
@@ -12,6 +12,45 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-10-b11 — Cancellation & refund status (customer) — 2026-07-10
- **Shipped:** the trust-first **exit** — a **new `services/refunds` domain** (types/constants/keys/
invalidations/apis[client+mock]/3 hooks + barrel) and two customer screens: the **cancellation flow**
`/bookings/[id]/cancel` (discloses the resolved policy tier + **refund % + fee %** + the Toman
refund-vs-fee split + the multi-session refundable/locked breakdown **before** confirm, gated behind an
explicit acknowledgement → `useCancelBooking` → refund status) and the read-only **refund status**
`/bookings/[id]/refund_status` (3-step **submitted → on-its-way → completed** stepper, the honest BNPL
**~710-business-day** ETA, `failed`=contact-support with **no retry**, empty state). The customer
booking detail now hangs a **Cancel** CTA / **refund section** (`CustomerBookingActions`, reuses the
cached booking query). Three shared tested composites: `CancellationPolicyDisclosure`, `RefundStatusCard`,
`RefundEtaBanner`. New `refunds` i18n namespace (71 keys, both locales).
- **Load-bearing rules honored:** refunds are **admin-approved — the customer never self-issues** (no
issue/approve/retry control anywhere; failed = contact-support); the fee/refund % is **disclosed +
acknowledged before confirm**; money = IRR digit-strings, **BigInt only** (integer parts-per-10000 —
refund+fee and the fee-leg split reconcile to the rial, `PriceBreakdown` dev-guarded); **BNPL surfaced
honestly** (the ~710-day window + `expected_customer_refund_eta`, money flows *through the provider*,
never instant, never Balinyaar→customer) — and **suppressed on a failed refund** (no success-framed
"money is on its way"); **per-session** — only un-started sessions refundable, completed-and-verified stay
locked; labels are i18n keys off the codes, **never raw enum codes**; polling **only while non-terminal**;
cancel invalidates booking detail/lists + **primes** the refund cache.
- **Consumes:** dev/contracts/domains/refunds-invoices.md (b11 — `refunds/{id}/status` shape + enums;
`refund_status`=requested|approved|processing|succeeded|failed|rejected, `refund_channel`=psp_card|
bnpl_revert|manual, `expected_customer_refund_eta` is a date). **Not served by the contract (admin-only):**
the customer cancel command, the pre-cancel policy preview + per-session flags, refund-by-booking, and the
fee-leg decomposition on the customer status → REQ-019/020/021.
- **Mocked client-side:** `services/refunds` via `refundsMockApi` (**USE_REFUNDS_MOCK=true, primary**) — it
reads the shared f8 bookings store to resolve the tier by lead time + per-session refundability, flips the
booking to `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot), and drives card-immediate
(`succeeded`) / BNPL-`processing``succeeded` refunds with a real ETA + a seeded `failed` refund (5004) and
the outside-policy `409`. Added bookings-store seeds 5003 (mid-engagement mixed) + 5004 (cancelled) + two
non-seam exports. Real `refundsClientApi` maps `refunds/{id}/status` 1:1 and targets the proposed slugs for
the gaps; one flag flip when REQ-019/020/021 land.
- **Reviewed:** 4-dimension adversarial review + per-finding verify — 3 confirmed findings, all fixed
(failed-state ETA/amount suppression = BNPL-honesty; two missing `no_refund_*` i18n keys; a UTC/local
`daysUntil` mismatch that flipped the tier in +offset timezones).
- **Gate:** npm run check green · npm run test:ci green (**214 tests, +10**).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-019 cancel command, REQ-020 policy
preview + per-session flags + canonical policy codes, REQ-021 refund-by-booking + customer decomposition).
## frontend-phase-9-b10 — Checkout, card payment & invoice — 2026-07-10
- **Shipped:** the money moment — a **new `services/payment` domain** (types/keys/constants/
apis[client+mock]/invalidations/5 hooks + barrel) and the customer checkout flow: **C6 خلاصه و پرداخت**
@@ -263,3 +263,63 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
capture to demo the full flow), but on the real rails every fresh payment would land on that empty
state.
- **Status:** open
## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g.
`POST api/v1/bookings/{bookingId}/cancel` (owner-scoped) with body
`{ sessionIds?: long[], reasonCategory: string, reasonNotes?: string }`
the created refund summary (`{ id, bookingId, status, refundChannel, amount, expectedCustomerRefundEta,
reference }`, i.e. the `refunds/{id}/status` shape). `sessionIds` omitted = cancel all un-started
(remaining) sessions; completed-and-verified sessions stay payout-eligible.
- **Why:** b11 shipped refunds **admin-only** (`POST admin_refunds`) — there is **no** customer path to
request a cancellation, but f10's whole cancel flow is customer-initiated (the customer *requests*; an
admin still *approves/processes* the money). The client mocks this behind the `services/refunds` seam:
the mock flips the booking to `cancelled` (stamping the b9 cancellation snapshot), decomposes the refund
across the two fee legs, and returns a card-immediate (`succeeded`) or BNPL-`processing` refund. The real
`refundsClientApi.cancelBooking` already targets this slug.
- **Proposed shape:** `POST api/v1/bookings/{bookingId}/cancel` body as above → `RefundStatusDto`. The
server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the
balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the
customer surface just needs to *create* the cancellation request and read the resulting refund.
- **Status:** open
## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the
customer confirms, incl. the per-session refundability breakdown. Proposed
`GET api/v1/bookings/{bookingId}/cancellation_policy` (owner-scoped) →
`{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage,
refundAmountIrr, feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr,
appliesTo, leadTimeLabel, refundChannel, expectedCustomerRefundEta,
sessions: [{ bookingSessionId, sessionIndex, scheduledDate, refundable, reasonCode }] }` (IRR fields are
digit-strings; `refundAmountIrr + feeAmountIrr = refundableAmountIrr`; the fee-leg split is served,
never client-derived).
- **Why:** The phase's load-bearing rule is **disclose the fee/refund % before confirm** — an
outside-policy fee is never a surprise. b9 snapshots `cancellationPolicyCode`/`cancellationRefundPercentage`/
`refundableAmountIrr` on the booking only *after* a cancel; there is no pre-cancel preview that resolves
the tier by current lead time and enumerates which sessions are refundable (un-started) vs locked
(completed-and-verified). The client mocks the whole preview behind the `services/refunds` seam; the tier
**codes** (`free_24h` / `partial_under_24h` / `customer_no_show`) are client-invented placeholders (the
product doc pins no wire codes) mapped to i18n keys — please define the canonical `cancellation_policy_code`
set so the client maps the real codes.
- **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set
(`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys.
- **Status:** open
## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** Two additions to the customer refund surface:
1. **Reach a refund from its booking.** `GET api/v1/refunds/by_booking/{bookingId}` (owner-scoped) →
the `refunds/{id}/status` shape, or `404` when the booking has no refund. Today the only customer read
is `GET refunds/{id}/status` keyed by a **refund id** the customer can't obtain (the refund id lives on
the admin-only `GET admin_refunds` worklist).
2. **Expose the decomposition to the customer.** Add `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`,
`refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt` to the customer
`refunds/{id}/status` payload (they exist on the admin-only `RefundListItem`).
- **Why:** f10's refund-status screen deep-links from a booking and renders (where the design calls for it)
a **fee-leg transparency split** (Balinyaar-fee-refunded vs service-cost-refunded). Without (1) the
customer can't find their refund; without (2) the split can't render on the real path (the client mock
fills all six fields; the real `refundsClientApi` leaves them `null` and the split is hidden).
- **Also (minor):** please confirm `provider_commission_reversed_amount` (the BNPL provider's own
commission on a revert) is **nullable** on the refund shape and reconciled from the provider response —
the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure
as opaque/never customer-facing.
- **Status:** open
@@ -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.
@@ -73,3 +73,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🟡 |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |