frontend phase 12

This commit is contained in:
hamid
2026-07-10 14:48:15 +03:30
parent 67c028562e
commit 6186f54294
34 changed files with 2363 additions and 1 deletions
@@ -0,0 +1,105 @@
# Frontend Phase 12 — Nurse earnings & payout history — report
**Date:** 2026-07-10 · **Track:** frontend · **Depends on:** f8 (nurse booking detail) + the b13 payouts
contract · **Status:** complete (mock-primary) · **Gate:** `npm run check` green · `npm run test:ci` green
(242 tests, +3 suites) · `npm run build` green with `NEXT_PUBLIC_API_URL` set.
This is the **last money-path frontend phase** — it closes the loop on the nurse side ("I did the work,
where is my money?") as a strictly **read-only** surface.
## What was built
### `services/payouts` domain (nurse read; the `auth`-service shape)
- **`types.ts`** — string-literal unions + shapes derived from the b13 contract:
- `EarningsState` = `pending | eligible | paid | clawback_applied` — a **client display model** (there is no
single wire enum; it is derived server-side from `bookings.status` + `dispute_window_ends_at` + the payout
link + clawbacks). `PayoutStatus` = the **contract's** `pending | submitted | paid | failed` (NB `submitted`,
not "processing"). `PayoutBatchStatus` = `draft | processing | partially_failed | completed | failed`.
- `NurseEarningsSummary` (four buckets + a **signed** `netPayableBalanceIrr` that may be negative),
`NurseEarningsItem` (per-booking, all four states), `NursePayoutHistoryItem`, `NursePayoutBookingLink`,
`NursePayoutBatchContext`, `NursePayoutDetail`, `EarningsListParams`, and the `PayoutsApi` seam (**all reads,
no mutations**).
- **`keys.ts`** — `payoutKeys` with the **state filter + page baked into the key** (`earningsList(state, page)`,
`history(page)`, `detail(id)`, `earningsSummary()`) so each tab/page caches independently.
- **`constants.ts`** — `USE_PAYOUTS_MOCK = true` (mock-primary), a `MOCK_SCENARIO` toggle
(`standard` | `clawback_heavy`) for the negative-balance demo, generous per-read `staleTime`s (earnings move
weekly), `PAYOUTS_PAGE_SIZE`.
- **`apis/clientApi.ts`** — real HTTP impl: `getNursePayoutHistory` → the **live** `GET api/v1/nurse_payouts/
history`; `getNurseEarningsBalance` / `getNurseEarnings` / `getNursePayoutDetail` target the proposed
REQ-025 slugs. **`apis/mockApi.ts`** — the primary impl (self-contained, money-correct fixtures — see below).
**`apis/index.ts`** — one-line seam selector.
- **`hooks/`** — `useNurseEarningsBalance` / `useNurseEarnings(state, page)` / `useNursePayoutHistory(page)` /
`useNursePayoutDetail(id)` — all read-only `useQuery`, `keepPreviousData` on the paged lists, detail disabled
on an invalid id. **`index.ts`** re-exports hooks only.
### Three shared, tested composites (`src/components/`)
- **`EarningsBalanceHeader`** — the net payable balance + four buckets (pending/eligible/paid-lifetime/clawback
off `--bal-{warning,info,success,error}`). A **negative** net renders an explicit **"owed back"** state
(error-toned card, the magnitude only — never a bare minus).
- **`EarningsRow`** — the three-amount breakdown framed for the nurse (`gross commission = your payout`,
reconciled by `PriceBreakdown`), one of four **visually-distinct** state chips, and the state affordance:
pending → a **display-only** dispute-window countdown (reuses `CountdownTimer`); eligible → awaiting-batch;
paid → `paid_at` + `transferReference` + a payout-detail link; clawback_applied → the `original clawback =
net` explanation. Every row deep-links to the f8 booking detail.
- **`PayoutHistoryRow`** — net transferred + payout-status chip + period + masked IBAN (last-4, `dir="ltr"`) +
transfer ref; a `failed` payout shows its reason as a **read-only** banner (no nurse retry).
### Three nurse screens (nurse shell)
- **`/nurse/earnings`** — balance header + collapsible explainer + a `Tabs` state segment (All + the four states)
→ the earnings list (skeleton / empty / error-with-retry) + a prev/next pager.
- **`/nurse/earnings/payouts`** — the payout history list (same state treatments) → payout detail.
- **`/nurse/earnings/payouts/[id]`** — the reconciliation detail: batch window (Shamsi) + status chips, the money
decomposition (`gross_earnings clawback_applied = net_amount`, plus the amount transferred), masked IBAN +
transfer ref, a failure banner, and the list of covered bookings (each deep-linking to `/nurse/visits/[id]`).
### Wiring
- `earnings` nav item + a `PaidOutlined` icon; `NURSE_EARNINGS` / `NURSE_EARNINGS_PAYOUTS` routes +
`nursePayoutDetailPath` / `nurseBookingDetailPath` helpers; the three components in the `@/components` barrel;
a `payouts` i18n namespace (82 keys, **`en.json` + `fa.json` in sync**) + `nav.earnings`.
## What is now testable and exactly how (phase §7)
Prereq: `npm run dev` (mock is primary, `USE_PAYOUTS_MOCK=true`), sign in as a nurse, open **درآمدها**.
1. **Pending** — booking 5001 shows under **pending / "in escrow · dispute window open"** with a live countdown
off `disputeWindowEndsAt`; it counts into the **pending** bucket, never as paid.
2. **Eligible → paid** — booking 5002 shows **eligible / "awaiting the weekly batch"**; booking 5003 shows
**paid** with `paid_at` (Shamsi) + a `transferReference`, links to the payout detail, and appears in payout
history; the detail lists the exact booking(s) covered.
3. **Clawback nets the total** — booking 5004 shows **clawback_applied** with the `original clawback = net`
explanation (1,700,000 1,700,000 = 0). To see the **negative net balance** ("owed back"), set
`MOCK_SCENARIO = 'clawback_heavy'` in `services/payouts/constants.ts` → the header renders the explicit
owed-back state (magnitude only).
4. **Failed payout** — payout 9003 (in history + at `/nurse/earnings/payouts/9003`) shows `failure_reason`
`invalid_sheba` as a read-only banner; **no retry control** exists for the nurse.
5. **Money correctness** — every row satisfies `gross commission = your payout`; Toman = IRR ÷ 10; no BNPL
provider commission appears; the amount is identical for a card- vs BNPL-funded booking of the same gross.
6. **i18n / RTL / caching** — `fa`↔`en` translates + mirrors; switching state tabs / paging shows separate
cache entries (React Query Devtools) and **no refetch** of already-loaded data.
7. **Gate** — `npm run check` + `npm run test:ci` pass.
## What is mocked, and how it swaps
`services/payouts` is **mock-primary** (`payoutsMockApi`) because b13 serves the nurse **only**
`GET api/v1/nurse_payouts/history`. The four-bucket **earnings summary**, the per-booking **earnings list +
money-state**, the **nurse-readable payout detail** (batch context + booking links), and `failureReason` on the
history DTO are contract gaps filed as **REQ-025**. `payoutsClientApi` already maps the live history route 1:1
and targets the proposed slugs for the rest — when REQ-025 lands, the swap is a single `USE_PAYOUTS_MOCK=false`
flip with **no hook/component change**. Recorded in `reports/mocks-registry.md` (`PayoutsApi` row).
The mock fixtures are engineered to be **money-correct** (`gross = commission + payout`; `net = gross clawback`;
Σ booking-link amounts = `grossEarnings`; the signed net balance computed with BigInt, not clamped) and to
exercise **every** UI state (all four earnings states, all four payout statuses incl. a `failed` one, a negative
net balance). Booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land on
real mock detail screens.
## Contracts consumed
- `dev/contracts/domains/payouts.md` (b13) — `GET api/v1/nurse_payouts/history` (live) + the enum codes
(`PayoutStatus`, `PayoutBatchStatus`) and the `PayoutDto`/`PayoutBookingLinkDto`/`PayoutBatchDto` shapes the
nurse-read analogues mirror. `api-conventions.md` (`page`/`pageSize`, envelope) + `money-and-types.md` (IRR
integer digit-strings, Toman display-only, UTC → Shamsi).
## Follow-ups (deferred, not built here)
- **REQ-025** (this phase's filing) — the three nurse-read endpoints + `failureReason` on the history DTO.
- **Admin payout console** (create/process/retry batch, eligible-earnings preview, clawback write-off queue) →
DEFERRED to f15.
- **Nurse bank-account add/verify (استعلام شبا) UI** → already the nurse onboarding/profile phase (f2/`/nurse/
bank`). This phase only *displays* the masked `iban_snapshot`; it never edits bank accounts.
- **On-demand / instant withdrawal**, per-nurse payout-frequency settings → DEFERRED product-side (MVP is weekly).
@@ -76,3 +76,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `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 | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel 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 b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |