add build development phases

This commit is contained in:
hamid
2026-06-28 21:59:59 +03:30
parent 1df3cd9f64
commit 53a40dc51d
52 changed files with 12379 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
# `dev/` — the Balinyaar build workspace
This folder is the **plan for building Balinyaar**, not application code. It takes the repo from its
current *starter + auth* baseline to the MVP described in [`product/`](../product/), as a chain of
agent-runnable prompt files split into two parallel tracks.
| Folder | What it is |
| --- | --- |
| [`phases/`](phases/README.md) | The prompt chain — `backend/` (b0b15) and `frontend/` (f0f15), plus the shared rules/template in `phases/_shared/`. **Start at [`phases/README.md`](phases/README.md).** |
| [`contracts/`](contracts/README.md) | The shared API/flow contract between the two independent projects. Backend writes, frontend reads. |
| [`shared-working-context/`](shared-working-context/README.md) | The parallel-agent handoff + per-phase reports + the mock registry. Each lane writes only its own files. |
## How to use it
1. Read [`phases/README.md`](phases/README.md) — the roadmap and dependency graph.
2. To run a phase, point a fresh agent at one phase file (e.g. *"Execute `dev/phases/backend/backend-phase-2.md`"*).
The phase file tells it what to read, what to build, and how to close out.
3. Run the two tracks **in parallel** with two agents if you like: a frontend phase named
`frontend-phase-N-bM.md` only needs **backend phase bM** merged first; everything else about the two
tracks is decoupled through `contracts/` and `shared-working-context/` (which are designed so the two
agents never touch the same files).
## Non-negotiables (every phase enforces them)
- Follow the project rules in the relevant `CLAUDE.md` / `CONVENTIONS.md` and the
[shared operating rules](phases/_shared/agent-operating-rules.md).
- Mock external services **behind DI seams** and record them in
[`shared-working-context/reports/mocks-registry.md`](shared-working-context/reports/mocks-registry.md).
- Finish each phase with: updated docs, a written contract (backend), a handoff note, a phase report,
and saved memory.
+45
View File
@@ -0,0 +1,45 @@
# Contracts — the shared interface between `client/` and `server/`
The two projects are independent (no shared build). This folder is their **single shared source of
truth** for everything that crosses the wire: API routes, request/response shapes, status codes, enums,
shared flows, and money/format conventions. It lets a frontend agent build against a stable contract
**before, during, and after** the matching backend phase, and it lets the two run in parallel.
## Ownership (this is what makes parallel work safe)
- **Backend owns and writes** `contracts/domains/*` and `contracts/openapi/*`. A backend phase that
ships an API writes/updates the contract in the **same** change.
- **Frontend reads** contracts and derives its TypeScript types from them. The frontend never edits
files here. If a contract is missing or wrong, the frontend appends a request to
[`../shared-working-context/frontend/requests/for-backend.md`](../shared-working-context/frontend/requests/for-backend.md);
the backend delivers the fix in a later change.
## What's here
| Path | What it is |
| --- | --- |
| `conventions/api-conventions.md` | The envelope, routing, pagination, errors, auth, locale — read first. |
| `conventions/money-and-types.md` | How money, dates, enums, gender, IDs are represented on the wire. |
| `domains/_TEMPLATE.md` | The shape every per-domain contract doc follows. |
| `domains/<domain>.md` | One file per domain (identity, catalog, booking, payments, …), added by the backend phase that ships it. |
| `openapi/` | The published `swagger.json` snapshot(s) — the machine-readable contract for type generation. |
## How a contract is produced (backend)
1. Build the endpoints following `server/CONVENTIONS.md`.
2. Write/extend `domains/<domain>.md` from `domains/_TEMPLATE.md`: every route, its method + snake_case
path, auth/policy, request and response JSON (with a real example), the enums it uses, and the error
cases. Reference, don't restate, `conventions/*`.
3. Publish the OpenAPI snapshot per `openapi/README.md`.
4. Note in your handoff (`shared-working-context/backend/handoff/after-backend-phase-N.md`) that the
contract is live.
## How a contract is consumed (frontend)
1. Read `domains/<domain>.md` + `conventions/*`. Derive types in `src/services/{domain}/types.ts`
(or generate from `openapi/swagger.json`) — keep names aligned with the contract.
2. If something is missing/ambiguous, request it (don't guess) and mock behind the `services/{domain}`
seam meanwhile.
> Keep contracts **versioned by being honest**: when a shipped shape changes, update its `domains/*` doc
> and the OpenAPI snapshot in the same change, and call it out in the handoff so the frontend re-syncs.
@@ -0,0 +1,50 @@
# API conventions (read before writing or consuming any contract)
These hold for **every** Balinyaar endpoint. Per-domain contract docs assume them and don't restate them.
## Base & versioning
- Base URL from `NEXT_PUBLIC_API_URL` (client) / `https://localhost:5002` (server default).
- Versioned routes: `api/v{version}/...` (Asp.Versioning). Default `v1`.
- **All URL segments are `snake_case`** (server `SnakeCaseParameterTransformer`). Controllers use
`[controller]`/`[action]` tokens, so `GetNurseProfile``.../get_nurse_profile`.
## Response envelope (`OperationResult` → `ApiResult`)
Every response is the server's standard envelope, not a bare body. Success and failure share the shape;
the frontend's `clientFetch`/`serverFetch` already unwrap it and throw `ApiError` on failure. Document
each endpoint's **payload** (the `data`/result) and its failure cases. The envelope carries at least:
a success flag, an HTTP-aligned status, a user-safe message, and the typed `data` (or validation errors).
The canonical shape is defined by `Baya.Application/Models/ApiResult` + `OperationResult<T>` on the
server — mirror that, don't invent a new envelope.
## Status codes
- `200` success (payload in `data`).
- `400` validation/business-rule failure (field-level errors included).
- `401` unauthenticated (missing/expired token) · `403` unauthorized (lacks permission).
- `404` not found.
- `409` conflict (idempotency / duplicate / state-machine violation) where applicable.
- `5xx` unexpected (generic safe message; details only in server logs).
## Auth
- Bearer JWE in `Authorization: Bearer <token>` (access token, ~15 min). Refresh via the refresh
endpoint; rotation + reuse-detection apply. The client attaches the header automatically in
`clientFetch`. State which policy/role each endpoint needs.
## Localisation
- The client sends the active locale (`Accept-Language` / `x-app-locale`, `fa` default). Server-produced
user-facing messages should honour it. Reference data that has `name_fa`/`name_en` returns both;
the client picks by locale.
## Pagination (mandatory on lists)
- Query params: `page` (1-based) + `page_size` (cap it server-side, e.g. ≤100). Response payload carries
`items` + `total` (+ `page`/`page_size`). Document the default and max `page_size` per endpoint.
## Idempotency (money & side-effecting POSTs)
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
endpoints dedup on the provider's `external_event_id`. Document which endpoints require a key.
## Naming
- JSON properties are the server's serialized casing (follow what NSwag/Swagger emits — typically
`snake_case` to match routing, or the project's configured policy; **derive the exact casing from the
published `swagger.json`, don't assume**). The frontend types match the wire exactly.
> When in doubt about an envelope/casing detail, the published `openapi/swagger.json` is authoritative.
@@ -0,0 +1,39 @@
# Money & shared types on the wire
## Money — IRR Rials, integer, no floats
- All monetary values are **IRR Rials as integers** (`BIGINT` server-side). There are **no floats** on
the money path anywhere — not in the DB, not in the API, not in the client.
- Because IRR amounts exceed JS's safe integer range in some aggregates and to avoid float coercion,
represent money on the wire as a **string of digits** (e.g. `"23300000"`) unless the published
`swagger.json` shows otherwise; the client parses with `BigInt`/integer-safe helpers and formats for
display. **Toman is display-only** and is converted to/from Rials **only** inside a provider adapter
at its boundary — never in shared contracts or the client's own math.
- The three booking amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`.
## Dates & times
- Timestamps are **UTC ISO-8601** (`DATETIME2(7)` server-side). Persian-calendar (Shamsi) display is a
**client** concern. The exception is bank-closure scheduling, which the server resolves via the
holiday calendar — the client never computes payout dates.
- `day_of_week` for availability uses the **Shamsi week (0 = Saturday … 6 = Friday)**, not ISO Monday-start.
## Enums (string-valued, stable codes)
Enums cross the wire as their stable string code (e.g. `male`/`female`/`any`, `per_hour`/`per_session`/
`per_half_day`/`per_day`/`per_24h`, booking/verification/payment statuses, `refund_channel` =
`psp_card`/`bnpl_revert`/`manual`). Each domain contract doc lists the exact set it uses. The frontend
mirrors them as string-literal union types and **never** hardcodes a display label off the code — labels
are i18n keys.
## Identifiers
- Entity IDs are integers/`BIGINT` (serialized per the published schema). Human-facing references
(`reference_code` on tickets, `invoice_number`) are strings shown to users — treat as opaque.
## PII & sensitive fields
- Encrypted-at-rest fields (phone, national_id, IBAN, addresses, clinical notes) are returned **only**
to authorized callers and often **masked** (e.g. last 4 of an IBAN). Contracts must state when a field
is masked vs. full, and the two-stage clinical-disclosure rule (full care instructions only after a
booking is confirmed, to the assigned nurse + admin) applies to the relevant payloads.
## Gender (load-bearing)
- `gender` (`male`/`female`) drives **same-gender caregiver matching** — a near-hard requirement. It is
present on users/patients and on the booking request as `required_caregiver_gender`
(`male`/`female`/`any`). Never default or drop it silently.
+36
View File
@@ -0,0 +1,36 @@
# Contract — <Domain> (backend phase bN)
> One-line: what this domain's API covers. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
> machine schema: [`../openapi/`](../openapi/README.md).
**Status:** live as of backend-phase-bN · **Frontend consumer:** frontend-phase-fM
## Enums used
- `<enum_name>`: `value_a` | `value_b` | … — meaning of each.
## Endpoints
### `<HTTP> api/v1/<controller>/<action>`
- **Purpose:** …
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
- **Path/query params:** `name` (type) — meaning; pagination `page`/`page_size` (default/max) for lists.
- **Request body:**
```json
{ "field": "example" }
```
- **Success `200` payload (`data`):**
```json
{ "field": "example" }
```
- **Failure cases:** `400` …, `401` …, `403` …, `404` …, `409` … (when/why each).
- **Notes:** masking, two-stage disclosure, tenancy, side effects (notifications/ledger/audit), etc.
_(repeat per endpoint)_
## Shared shapes
- `<DtoName>`: field-by-field (name, type, nullable, masked?, meaning).
## Changelog
- bN — initial contract.
+22
View File
@@ -0,0 +1,22 @@
# OpenAPI snapshots
The server already generates OpenAPI via **NSwag** (Swagger UI at `/swagger`, documents `v1`, `v1.1`).
This folder holds the **published `swagger.json` snapshot(s)** so the frontend can generate/verify types
without running the backend.
## Backend: publish on every API-shipping phase
After adding/changing endpoints and confirming the build, export the OpenAPI document and commit it here
as `swagger.v1.json` (overwrite — git history is the version trail). Typical options:
- Run the API and save `GET /swagger/v1/swagger.json` to `dev/contracts/openapi/swagger.v1.json`, **or**
- Use the NSwag CLI / build target the server already wires to emit the document.
Record in your handoff that the snapshot was refreshed. Keep it in sync with `../domains/*.md` — the
markdown is the human contract, this JSON is the machine contract; they must agree.
## Frontend: consume
Generate types from `swagger.v1.json` (e.g. an `openapi-typescript`-style step) **or** hand-write
`src/services/{domain}/types.ts` to match it. Either way, the wire shapes come from here — not from
guessing. Casing/format questions are resolved by this file.
> Until the first API-shipping backend phase runs, this folder is empty by design.
+140
View File
@@ -0,0 +1,140 @@
# Balinyaar phase roadmap
The build, as a dependency-ordered chain of agent prompts. Two tracks — **backend** and **frontend**
that can run in parallel (two agents) coordinated only through [`../contracts/`](../contracts/README.md)
and [`../shared-working-context/`](../shared-working-context/README.md).
> **Every agent: read [`_shared/agent-operating-rules.md`](_shared/agent-operating-rules.md) first.**
> It carries the rules each phase file assumes (how to work, the gate, contracts, the handoff, reports,
> memory). The per-phase files stay focused on *what* to build.
## Naming
- Backend: `backend/backend-phase-N.md` — run in order, `N = 0…15`.
- Frontend: `frontend/frontend-phase-N-bM.md` — the **`bM` suffix is the highest backend phase that must
be merged before this frontend phase starts** (earlier backend phases are implied; `frontend-phase-0`
has no backend dependency). Within the frontend track, run in order `N = 0…15`.
## How to run a phase
Point a fresh agent at one file: *"Execute `dev/phases/backend/backend-phase-5.md` end to end."* The file
lists its required reading, scope, mocks, rules, Definition of Done, how-to-test, and close-out. It links
the prior phases whose work it builds on, so nothing is duplicated.
## Dependency graph
```mermaid
flowchart TD
subgraph BACKEND
b0[b0 Foundation·seams·cleanup·REST·rate-limit] --> b1[b1 Config & Reference]
b1 --> b2[b2 Identity — auth/OTP/sessions]
b2 --> b3[b3 Identity — profiles/patients/bank]
b1 --> b4
b3 --> b4[b4 Geography·addresses·service-areas]
b3 --> b5[b5 Catalog & pricing]
b4 --> b5
b3 --> b6["b6 Nurse verification (mock)"]
b5 --> b7[b7 Search & matching]
b6 --> b7
b5 --> b8[b8 Booking requests & lifecycle]
b7 --> b8
b8 --> b9[b9 Bookings·sessions·care·EVV]
b9 --> b10[b10 Payments core: ledger·txn·webhook·capture]
b1 --> b10
b10 --> b11[b11 Refunds·invoices·clawbacks]
b10 --> b12["b12 BNPL (mock)"]
b11 --> b13["b13 Payouts (mock)"]
b12 --> b13
b9 --> b14[b14 Reviews & patient records]
b3 --> b15[b15 Messaging·notifications·partner·admin backoffice]
b11 --> b15
b13 --> b15
b14 --> b15
end
subgraph FRONTEND
f0[f0 Foundations·shells·design-system·contracts client] --> f1[f1-b2 Auth & OTP]
f1 --> f2[f2-b3 Onboarding & profiles]
f2 --> f3[f3-b4 Addresses & geo]
f3 --> f4[f4-b5 Catalog browse & service builder]
f4 --> f5[f5-b6 Nurse verification flow]
f5 --> f6[f6-b7 Search & discovery]
f6 --> f7[f7-b8 Booking request flow]
f7 --> f8[f8-b9 Booking detail·sessions·EVV]
f8 --> f9[f9-b10 Checkout & payment]
f9 --> f10[f10-b11 Refund & cancellation]
f10 --> f11[f11-b12 BNPL checkout]
f11 --> f12[f12-b13 Nurse earnings & payouts]
f12 --> f13[f13-b14 Reviews & patient records]
f13 --> f14[f14-b15 Messaging & notifications]
f14 --> f15[f15-b15 Admin & partner consoles]
end
b2 -. contract .-> f1
b3 -. contract .-> f2
b4 -. contract .-> f3
b5 -. contract .-> f4
b6 -. contract .-> f5
b7 -. contract .-> f6
b8 -. contract .-> f7
b9 -. contract .-> f8
b10 -. contract .-> f9
b11 -. contract .-> f10
b12 -. contract .-> f11
b13 -. contract .-> f12
b14 -. contract .-> f13
b15 -. contract .-> f14
b15 -. contract .-> f15
```
## Backend track
| # | Phase | Builds (entities / capabilities) | Seams introduced |
| --- | --- | --- | --- |
| b0 | Foundation, seams & cleanup | Delete `Order` + old migrations; add the REST controller surface + rate limiting; register `LoggingBehavior`; `ICurrentUser` + audit-field SaveChanges interceptor; define + DI-register the cross-cutting seams & their mocks; mock-report discipline | `ICacheService`, `IObjectStorage`, `IFieldEncryptor`, `IDateTimeProvider`, `INotificationDispatcher` (stub) |
| b1 | Config, reference & platform signals | `platform_configs` (+ typed cached accessor + seed keys), `audit_logs` (+ interceptor writes), `system_events`, `iranian_holidays` (+ seed), **`notifications`** (write + list/read + retention) and **`support_alerts`** (raise API; built early because verification/booking/payments/reviews all raise them); **first marketplace migration baseline**; admin config/holiday/audit endpoints | `IHolidayCalendar`, `IAnalyticsSink`, `INotificationDispatcher` (real in-app write) |
| b2 | Identity — auth | REST OTP login (`/auth/otp/request|verify`, `/auth/refresh`, `/auth/logout`, `/me`); `users` extensions (gender, national_id…), `user_sessions` (rotation + reuse detection), role selection; wire real SMS seam delivery | `ISmsSender` |
| b3 | Identity — profiles | `nurse_profiles`, `customer_profiles`, `patients` (CRUD + tenancy), `nurse_bank_accounts` (+ ownership-inquiry seam) | `IBankAccountOwnershipVerifier` |
| b4 | Geography & addresses | `provinces`/`cities`/`districts` (+ seed), `nurse_service_areas`, `customer_addresses` (+ geocode) | `IGeocoder` |
| b5 | Catalog & pricing | `service_categories`/`service_option_groups`/`service_option_values` (admin + seed), `nurse_service_variants`/`nurse_service_variant_options` (nurse builder, price units) | — |
| b6 | Nurse verification (mock) | `nurse_verifications`, `verification_step_types` (+seed), `verification_steps`, `verification_documents`, `nurse_credentials`; the guarded `is_verified` flip; admin review queue; bank-account verification step | `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier` |
| b7 | Search & matching | `nurse_search_index` (fan-out + maintenance hooks), the search query (category/city/district/gender/price, rating sort) behind a search seam | `INurseSearch` |
| b8 | Booking requests & lifecycle | `booking_requests` (create/accept/reject + expiry job), deadlines (config), same-gender + tenancy validation | — |
| b9 | Bookings, sessions, care & EVV | `bookings` (3-amount split, conversion command), `booking_sessions`, `booking_care_instructions` (two-stage disclosure), `visit_verifications` (EVV check-in/out), `cancellation_policies`; dispute-window set on completion | (reuses `IGeocoder` for GPS match) |
| b10 | Payments core | `payment_gateways`, `payment_transactions` (filtered uniques), `payment_webhook_events` (idempotency), `ledger_entries` (double-entry, 6 account types), card capture → confirm → convert booking, Redis lock on money path | `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock` |
| b11 | Refunds, invoices, clawbacks | `refunds` (1:N, fee/payout decomposition, channel-aware, ticket-linked), refund ledger postings, `nurse_clawbacks`, `invoices` (VAT on commission) | `IMoadianClient` |
| b12 | BNPL (mock) | `bnpl_transactions` (1:1, state machine), BNPL settle ledger (`bnpl_fee_expense`), revert/update path, callbacks via webhook idempotency | `IBnplProvider`, `ICurrencyNormalizer` |
| b13 | Payouts (mock) | `nurse_payout_batches`, `nurse_payouts`, `nurse_payout_booking_links` (booking_id UNIQUE), weekly batch, holiday-aware scheduling, clawback netting, eligibility gating | `IBankTransferProvider` |
| b14 | Reviews & patient records | `reviews` (1:1 booking, moderation, aggregate recompute), `review_tags_master`/`review_tag_links`, `patient_care_records` (encrypted, patient-scoped), low-rating → support alert | `IReviewModerationService` |
| b15 | Messaging, partner & admin backoffice | `tickets`/`ticket_participants`/`ticket_messages` (`is_internal`), the `support_alerts` admin worklist (table/raise from b1), `partner_centers` (merchant-of-record + sponsor), admin backoffice consolidation (verify/refund/payout/moderation/config worklists), refund↔ticket link; deferred tables stay inactive | `ILicenseVerificationService` |
## Frontend track
| # | Phase (file) | Needs | Builds (screens / flows) |
| --- | --- | --- | --- |
| f0 | `frontend-phase-0.md` | — | Remove demo scaffolding; the three actor app shells (customer mobile + bottom nav, nurse, admin) & route groups; the `services/{domain}` + Query caching pattern; the contracts→types pattern; shared composite components (OTP input, cards, stepper, price-breakdown); i18n namespaces; RTL baseline |
| f1 | `frontend-phase-1-b2.md` | b2 | Phone login (A1), OTP (A2), customer/nurse login switch, role router; replace username/password with OTP; roles in `AuthContext` |
| f2 | `frontend-phase-2-b3.md` | b3 | "Who is care for" (A3), add/list/edit patient (A4), customer profile, nurse profile bootstrap, nurse bank-account settings |
| f3 | `frontend-phase-3-b4.md` | b4 | Address book + map picker + cascading province/city/district; nurse coverage-area editor |
| f4 | `frontend-phase-4-b5.md` | b5 | Customer home + category grid (A5), nurse "add a service" builder (B7 services), catalog browse |
| f5 | `frontend-phase-5-b6.md` | b6 | Nurse verification: status checklist (B3), identity submit (B4), credentials (B5), under-review (B6), document upload, trust badge |
| f6 | `frontend-phase-6-b7.md` | b7 | Search & filter (C1), results (C2), nurse profile (C3) |
| f7 | `frontend-phase-7-b8.md` | b8 | Request form (C4), awaiting-acceptance (C5) + status tracker; nurse request inbox (accept/reject) |
| f8 | `frontend-phase-8-b9.md` | b9 | Booking detail & sessions; nurse EVV check-in/out (E3 top); care-instructions (post-confirm); status timeline |
| f9 | `frontend-phase-9-b10.md` | b10 | Summary & pay (C6) w/ commission/tax/escrow notice; card payment (mock redirect); confirmation; invoice |
| f10 | `frontend-phase-10-b11.md` | b11 | Cancellation flow (policy fee disclosure); customer refund status (BNPL ETA) |
| f11 | `frontend-phase-11-b12.md` | b12 | BNPL: method (D1), plan (D2), eligibility (D3), contract/schedule (D4), wallet/installment status (D5) |
| f12 | `frontend-phase-12-b13.md` | b13 | Nurse earnings & payout history (pending/eligible/paid/clawback) |
| f13 | `frontend-phase-13-b14.md` | b14 | Leave a review; nurse profile reviews tab; patient record viewer (E2); nurse visit notes (E3 note); longitudinal history |
| f14 | `frontend-phase-14-b15.md` | b15 (+ b1 notifications) | Ticket inbox + thread (support); notification center/bell (polling unread count); emergency banner |
| f15 | `frontend-phase-15-b15.md` | b15 | Admin backoffice console (verification queue, refunds, payouts, moderation, config/holidays/audit, support alerts) + partner-center portal |
## Scope notes & deferrals (apply across phases)
- **MVP-only:** items the product docs tag DEFERRED stay out — availability hard-filtering, recurring
schedules, surge/holiday pricing, two-way reviews, push/SMS notification channels, مودیان automation,
org/employer model, ML fraud, customer national-ID KYC, in-house BNPL credit. Build the *seam/flag*,
not the feature, where a phase calls for it.
- **Everything fake is a seam.** No phase ships a hidden stub; mocks live behind interfaces in the
[mock registry](../shared-working-context/reports/mocks-registry.md).
- **Money correctness is sacred** across b9b13: IRR `BIGINT`, the three-amount split, append-only
balanced ledger, webhook idempotency, dispute-window gating, one-payout-per-booking. The
[payments contract conventions](../contracts/conventions/money-and-types.md) bind both tracks.
+204
View File
@@ -0,0 +1,204 @@
# Agent Operating Rules — read this before every phase
> **You are an autonomous engineer working one phase of the Balinyaar build.** This file is the
> contract for *how* you work. Every `backend-phase-N.md` / `frontend-phase-N-bM.md` links here and
> assumes you have read it. Do not skip it. Do not be lazy. Build production-quality, scalable,
> maintainable code — not a demo.
---
## 0. The golden behaviours
1. **No laziness, ever.** Implement the whole phase scope. No `// TODO: implement later`, no stubbed
handlers that return fake data *unless the phase explicitly says "mock this behind a seam"*, no
"left as an exercise". If you mock something, you mock it **behind a dependency-injected interface**
and you record it in the mock registry (§7) — that is the *only* sanctioned form of "not real yet".
2. **Read before you write.** Complete the **Required reading** list in your phase file *first*. The
business rules are decisions, not guesses — the product docs are the source of truth. Inferring a
rule from code instead of reading the doc is a defect.
3. **Don't duplicate prior work.** Each phase lists **"What already exists"** with links to the prior
phases that built it. Extend it; never re-create it. If you need to change something a prior phase
built, change it in place and note it in your report.
4. **Stay in your project.** Backend phases touch `server/` (+ `dev/contracts`, `dev/shared-working-context/backend`). Frontend phases touch `client/` (+ `dev/shared-working-context/frontend`). Do **not** edit the other side's app code — coordinate through contracts and the handoff (§6).
5. **Match the surrounding style.** Mirror existing patterns; introduce no new ones without reason.
The per-project `CLAUDE.md` (and server `CONVENTIONS.md`) are non-negotiable — follow them exactly.
6. **Leave the tree green.** Your phase is not done until the project's own quality gate passes
(§4) and the Definition of Done ([definition-of-done.md](definition-of-done.md)) is fully met.
7. **Think about the future.** Every decision should consider scalability and maintenance: indexing,
pagination, caching, idempotency, re-render cost, bundle size, the seam that lets a mock become real.
See the conventions checklists ([backend](backend-conventions-checklist.md) ·
[frontend](frontend-conventions-checklist.md)).
---
## 1. The canonical sources of truth (know where each rule lives)
| You need… | Read |
| --- | --- |
| Repo-wide rules, two-project layout | root [`CLAUDE.md`](../../../CLAUDE.md) |
| Backend engineering rules | [`server/CLAUDE.md`](../../../server/CLAUDE.md) + [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) |
| Frontend engineering rules | [`client/CLAUDE.md`](../../../client/CLAUDE.md) |
| Frontend design/brand rules | the **frontend-designer** skill (invoke it for any UI work) |
| Product / business / data-model / payments truth | [`product/`](../../../product/) (start at `product/index.md`) |
| The whole build plan + order | [`dev/phases/README.md`](../README.md) |
| Cross-project API/flow contracts | [`dev/contracts/`](../../contracts/README.md) |
| What the other agent has handed you | [`dev/shared-working-context/`](../../shared-working-context/README.md) |
**Precedence when two sources seem to conflict:** product docs (business truth) → the relevant
`CLAUDE.md`/`CONVENTIONS.md` (engineering truth) → this file → the phase file's specifics. If a real
conflict remains, do the safe thing, implement it, and flag it in your report — never silently guess
on money, auth, tenancy, or clinical-data rules.
---
## 2. Phase lifecycle (do these in order)
1. **Orient.** Read this file, your phase file end-to-end, and the **Required reading** it lists.
Skim the prior-phase reports in `dev/shared-working-context/reports/` for what changed recently.
2. **Plan.** Restate the scope to yourself; list the entities/endpoints/screens you will build and the
order. Identify every external dependency you must mock and the seam it sits behind.
3. **Build.** Implement the full scope following the project conventions and the checklists. Wire mocks
behind DI. Keep commits/changes coherent.
4. **Self-verify.** Run the project's quality gate (§4) and the phase's **"How to test"** steps. Fix
everything. Re-read your own diff as a reviewer would.
5. **Document & hand off.** Update the project docs (§5), write the contract(s) (§6, backend), write
the phase report and update the mock registry (§7), write the handoff note (§6), and save memory (§8).
6. **Declare done** only when the Definition of Done is fully satisfied.
---
## 3. Best-practice mandate (this is graded, not optional)
**Backend** (full list in [backend-conventions-checklist.md](backend-conventions-checklist.md)):
Clean-Architecture boundaries (Domain → Application → Infrastructure → API; dependencies point inward);
CQRS via `martinothamar/Mediator` (`ISender`/`ICommand`/`IQuery`, `internal sealed` handlers,
`OperationResult` for expected failures — never throw); `AsNoTracking()` + `Select()` projection on
every read; pagination on every list; one `IEntityTypeConfiguration<T>` per entity; soft-delete query
filters; audit fields via the SaveChanges interceptor; **caching** read-heavy/config data behind the
cache seam; **idempotency + (where stated) Redis distributed locks** on the money path; **money is IRR
`BIGINT`, no floats, ever**; validate at the boundary with FluentValidation; `CancellationToken`
through every async call; zero new build warnings; no dead code; comment the *why*.
**Frontend** (full list in [frontend-conventions-checklist.md](frontend-conventions-checklist.md)):
Respect the RSC/client boundary; **fetch only through `clientFetch`/`serverFetch`** in
`services/{domain}`; use **TanStack Query with sensible `queryKey`s, `staleTime`, and cache
invalidation** so you never re-fetch data you already have; prevent needless re-renders (stable
references, `select`, colocated state, memo only where it pays); **MUI primitives stay MUI** (Button,
Avatar, Paper… — never re-implement a root component), but **compose shareable mid-level components at
the right shared level** (not buried in a page) when they'll be reused; every user-visible string is an
i18n key in **both** `en.json` and `fa.json`; colours from `tokens.css`; RTL-correct; `npm run check`
green; no dead code.
If a phase's work could be done quickly-but-wrong or properly-but-slower, **do it properly.**
---
## 4. The quality gate (run before declaring done)
- **Backend:** `dotnet build Baya.sln` (zero new warnings) **and** `dotnet test Baya.sln` (all pass).
Add the handler/integration tests your phase introduces. A reachable SQL Server is required to run.
- **Frontend:** `npm run check` (type + lint) **and** `npm run test:ci` if you touched/added a shared
component. Keep `en.json`/`fa.json` in sync.
A phase that doesn't pass its own gate is **not done**, regardless of how complete the code looks.
---
## 5. Update the project documents (in the same change)
Keep the docs honest — stale instructions are worse than none. When your phase changes how something
works or what the architecture is:
- **Architecture map:** if you add/rename/remove a project, layer, route group, provider, domain folder,
or a cross-boundary seam, update the canonical map in the **same** change — server: the *Project map*
in `server/CLAUDE.md`; client: the *Project Structure* tree in `client/CLAUDE.md`; repo-level: the
*Repository layout* in root `CLAUDE.md`.
- **Product docs:** if you discover or decide a business rule that the `product/` docs don't yet
capture (or that drifts from them), update the relevant `product/**.md` (and regenerate the HTML view
per `product/CLAUDE.md` guidance if you changed Markdown). Do not invent rules — record decisions.
- **Conventions:** if you establish a new reusable pattern (a seam, a base class, a hook family), add a
short note to the relevant `CLAUDE.md`/`CONVENTIONS.md` so the next phase reuses it.
Do **not** re-introduce removed starter/template scaffolding while editing docs.
---
## 6. Contracts & the parallel-agent handoff (how the two sides stay in sync)
Backend and frontend phases are designed to run **as two independent agents in parallel**. They never
edit the same files. They coordinate through two folders:
- **`dev/contracts/`** — *backend-owned, frontend-read.* When a backend phase ships an API, it writes
the contract: a per-domain doc under `dev/contracts/domains/<domain>.md` (request/response shapes,
routes, status codes, enums, examples) **and** ensures the server's `swagger.json` snapshot is
published per `dev/contracts/openapi/README.md`. Follow the envelope/format rules in
`dev/contracts/conventions/`. The frontend treats these as the source of truth for types — it does
**not** guess shapes.
- **`dev/shared-working-context/`** — *the running handoff.* Strict lane ownership so parallel agents
never collide:
- **Backend writes only:** `shared-working-context/backend/STATUS.md` (append your phase summary),
`shared-working-context/backend/handoff/after-backend-phase-N.md` (one new file per phase — what
the frontend can now build, which endpoints/contracts are live, what's mocked), and your report +
the mock registry in `shared-working-context/reports/`.
- **Frontend writes only:** `shared-working-context/frontend/STATUS.md`, and
`shared-working-context/frontend/requests/for-backend.md` (append contract gaps / shape requests —
the backend agent reads this, you never edit backend files), and your frontend report in
`shared-working-context/reports/`.
- **Never edit a file the other lane owns.** If the frontend needs a contract change, it *requests*
it; the backend *delivers* it in a subsequent change. This is what makes parallel runs safe.
When mock data is needed on the frontend before its backend phase is merged, build it behind the same
`services/{domain}` seam (a mock `clientApi`) and record it in your frontend report so it's swapped out
cleanly once the real endpoint lands.
---
## 7. The mock / integration report (mandatory, saved to a file — not just chat)
Every phase that mocks or defers an external service **must**:
1. Put the mock behind a **DI-registered interface** (a "seam") with a real-shaped contract, so the real
implementation is a drop-in later. Mock and real both implement the same interface; selection is by
configuration/registration, never by `if (mock)` scattered in handlers.
2. Append an entry to **`dev/shared-working-context/reports/mocks-registry.md`** with: the seam
(interface name + file), what is faked, why, the config keys it reads, and **step-by-step how to make
it real** (which provider, which package, which settings, which methods to implement, what to test).
3. Write a **per-phase report** at `dev/shared-working-context/reports/<backend|frontend>-phase-N-report.md`
covering: *what was built*, *what is now testable and exactly how*, *what is mocked / waiting on a real
service*, *contracts produced/consumed*, *follow-ups for later phases*. (See
[reports/README.md](../../shared-working-context/reports/README.md) for the template.)
Services that will be mocked across the chain and the seam each lives behind (define once, reuse):
SMS/OTP delivery (`ISmsSender`), object storage / MinIO/S3 (`IObjectStorage`), cache/Redis
(`ICacheService`), distributed locks/Redis (`IDistributedLock`), search/Elasticsearch (`INurseSearch`),
PSP card gateway (`IPaymentProvider`), settlement-sharing/تسهیم (`ISettlementSplitProvider`), BNPL
(`IBnplProvider`), bank transfer PAYA/SATNA (`IBankTransferProvider`), webhook verification
(`IWebhookVerifier`), Shahkar (`IShahkarVerifier`), identity KYC/liveness (`IIdentityKycProvider`),
credential/license check (`ICredentialVerifier`), IBAN ownership (`IBankAccountOwnershipVerifier`),
geocoding/maps (`IGeocoder`), مودیان e-invoicing (`IMoadianClient`), AI review moderation
(`IReviewModerationService`), field encryption/KMS (`IFieldEncryptor`), notification dispatch
(`INotificationDispatcher`). The exact phase that *introduces* each seam is in the roadmap; later phases
*reuse* the seam, they do not redefine it.
---
## 8. Write memory & context when done
After the work passes its gate, record what a future agent would need and could not cheaply re-derive:
- **Persistent memory** (the Claude memory dir, per the repo's memory instructions): save/update a
`project`-type memory note for any non-obvious decision, new seam, or gotcha this phase introduced,
and add a one-line pointer to `MEMORY.md`. Don't record what the code/docs already make obvious.
- **Working context:** the handoff note (§6) and the phase report (§7) — these are the durable record
the *other* agent and the *next* phase rely on.
---
## 9. When you're genuinely blocked
Some things are intentionally out of scope and must be **mocked, not invented**: real PSP/BNPL
connections, the Shahkar/MoH/INO/criminal-record vendors, MinIO/S3 credentials, the مودیان enrollment.
If you reach one, implement the seam + a faithful mock + the registry entry, and move on — do not stall
and do not fabricate a real integration. If a *business* rule is truly undecided (the product docs say
"open question"), pick the safe default, implement it config-drivenly where possible, and flag it in
your report for the human to confirm. Never block the chain on an external unknown.
@@ -0,0 +1,59 @@
# Backend conventions checklist (quick reference)
The authoritative rules are [`server/CLAUDE.md`](../../../server/CLAUDE.md) and
[`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md). This is a tick-list to keep handy while you
build. If anything here seems to conflict with those files, **those files win**.
## Architecture
- [ ] Clean Architecture respected: Domain → Application → Infrastructure → API; **dependencies point
inward**. Domain references nothing; Application references only Domain; Infrastructure/API
implement Application contracts. Never reference Infrastructure/API from Domain/Application.
- [ ] New cross-layer dependency or project/folder ⇒ update the **Project map** in `server/CLAUDE.md`.
- [ ] New infrastructure is registered via a `ServiceConfiguration/` extension method called from
`Program.cs` — never inline in `Program.cs`.
## CQRS / features
- [ ] Feature lives in `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`.
- [ ] Requests are `record`; handlers are `internal sealed`; one handler per request.
- [ ] **Never throw for expected failures** — return `OperationResult.SuccessResult/FailureResult/NotFoundResult`.
- [ ] Contracts the handler needs are interfaces in `Application/Contracts/`, implemented in Infrastructure.
- [ ] Input-bearing commands have a FluentValidation validator (picked up by `ValidateCommandBehavior`).
## Persistence (EF Core)
- [ ] Read queries use `AsNoTracking()` **and** project with `.Select(...)` to a DTO — never hydrate
entities to map them. Mapping (Mapster) happens in the handler after the query.
- [ ] Every unbounded list is **paginated** (`Skip`/`Take`); no unbounded `ToListAsync()`.
- [ ] Access the DB via `IUnitOfWork` in handlers; commit once per command (`CommitAsync`).
- [ ] One `IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/<Area>Config/`.
- [ ] Soft-deletable entities declare a global query filter (`!IsDeleted` / `deleted_at IS NULL`).
- [ ] Audit fields (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) are set by the SaveChanges
interceptor via `ICurrentUser` — handlers don't pass them.
- [ ] Encrypted PII columns (phone, national_id, IBAN, addresses, clinical notes) go through the field
encryptor seam — never stored or logged in plaintext.
## Performance, caching, money, idempotency
- [ ] Read-heavy/config/reference data is cached behind the cache seam with sensible invalidation;
`platform_configs` values are read through the typed config accessor (cached), not hardcoded.
- [ ] **Money is IRR `BIGINT` — no floats, anywhere.** Toman conversion happens only inside a provider
adapter at its boundary. The three booking amounts always satisfy
`gross = commission + payout`.
- [ ] Money-path writes are **idempotent** (webhook dedup on the unique external-event key; filtered
unique on succeeded transaction; forward-only state machines) and, where the phase says so, guarded
by a Redis **distributed lock** — with the DB constraint as the authoritative backstop.
- [ ] `ledger_entries` is append-only and balanced (Σdebit = Σcredit per `transaction_group_id`).
## API surface
- [ ] Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return
`base.OperationResult(...)` — never `Ok()/BadRequest()/NotFound()` directly. (Note: the baseline
has **no** controllers yet — REST is introduced in an early phase; follow that pattern thereafter.)
- [ ] Routes use `[controller]`/`[action]` tokens (snake_case transformer); pass `CancellationToken`.
- [ ] Authorize with the narrowest fitting policy; auth/OTP/refund/payout-sensitive endpoints are
rate-limited.
- [ ] The endpoint's contract is published to `dev/contracts/` (see operating-rules §6).
## Quality
- [ ] `async`/`await` all the way; `CancellationToken` threaded through; no `.Result`/`.Wait()`/`async void`.
- [ ] Package versions only in `Directory.Packages.props`.
- [ ] Handler unit tests (NSubstitute) + at least one `WebApplicationFactory` integration test per new
feature area (happy path, 401, validation 400).
- [ ] `dotnet build` zero new warnings; `dotnet test` green; no dead code.
+43
View File
@@ -0,0 +1,43 @@
# Definition of Done — applies to every phase
A phase is **done** only when *all* of the following are true. Each phase file adds its own
phase-specific criteria on top of this shared baseline.
## Code
- [ ] The full scope in the phase file is implemented — no laziness, no unsanctioned stubs. Anything
not real is mocked **behind a DI seam** and recorded in the mock registry.
- [ ] It follows the project conventions exactly (server `CLAUDE.md` + `CONVENTIONS.md`, or client
`CLAUDE.md` + the frontend-designer skill) and the relevant conventions checklist.
- [ ] No dead code (unused vars/imports/usings/params/members). Comments explain *why*, not *what*.
- [ ] Best practices honoured: (backend) clean-arch boundaries, projected/paginated reads, caching where
it pays, idempotency/locks on the money path, IRR `BIGINT` money, validation at the boundary;
(frontend) RSC/client boundary, query caching + no needless refetch, minimal re-renders, MUI
primitives reused, i18n in both locales, tokens-based colours, RTL-correct.
## Gate
- [ ] **Backend:** `dotnet build Baya.sln` has zero new warnings **and** `dotnet test Baya.sln` passes,
including the tests this phase adds.
- [ ] **Frontend:** `npm run check` passes **and** `npm run test:ci` passes if a shared component was
touched/added; `en.json` and `fa.json` are in sync.
## Documentation
- [ ] The relevant architecture map is updated in the same change (server *Project map* / client
*Project Structure* / root *Repository layout*) if the structure changed.
- [ ] Any business rule discovered/decided is reflected in the `product/` docs (no invented rules).
- [ ] New reusable patterns/seams are noted in the relevant `CLAUDE.md`/`CONVENTIONS.md`.
## Contracts & handoff
- [ ] **Backend:** the API contract for what shipped is written to `dev/contracts/domains/<domain>.md`
and the `swagger.json` snapshot is published per `dev/contracts/openapi/README.md`.
- [ ] **Frontend:** types/services are derived from the published contract (not guessed); any contract
gap is appended to `dev/shared-working-context/frontend/requests/for-backend.md`.
- [ ] The handoff note `dev/shared-working-context/<lane>/...` is written (backend: a new
`handoff/after-backend-phase-N.md`; both lanes: a STATUS append).
## Reporting & memory
- [ ] The per-phase report `dev/shared-working-context/reports/<lane>-phase-N-report.md` is written:
what was built, **what is now testable and exactly how**, what is mocked + how to make it real,
contracts produced/consumed, follow-ups.
- [ ] The mock registry `dev/shared-working-context/reports/mocks-registry.md` is updated for every
seam this phase mocked/touched.
- [ ] Persistent memory is saved for any non-obvious decision/seam/gotcha, with a `MEMORY.md` pointer.
@@ -0,0 +1,49 @@
# Frontend conventions checklist (quick reference)
The authoritative rules are [`client/CLAUDE.md`](../../../client/CLAUDE.md) and the **frontend-designer**
skill (invoke it for any visual work). This is a tick-list. If anything here seems to conflict with
those, **they win**.
## Boundaries & structure
- [ ] Never add a layout above `[locale]`; `src/app/[locale]/layout.tsx` is the root layout.
- [ ] Respect the RSC/client boundary: no `next/headers`, `next-intl/server`, or `@/lib/cookies/server`
in client components; no `@/lib/cookies/client` in an RSC.
- [ ] New route group / provider / top-level `src/` folder ⇒ update the **Project Structure** tree in
`client/CLAUDE.md`.
## Data fetching & caching (prevent extra fetches and re-renders)
- [ ] Fetch **only** through `clientFetch`/`serverFetch` (`@/lib/api`); domain calls live in
`src/services/{domain}/apis/`. Never raw `fetch()`.
- [ ] Server state is **TanStack Query**: a `keys.ts` factory per domain, deliberate `staleTime`/`gcTime`,
and **cache invalidation on mutation** (`invalidateQueries`/`setQueryData`) so you never refetch
data you already hold. Prefer prefetch/`initialData` from RSC where it removes a client round-trip.
- [ ] One hook per file (`use{Action}.ts`), `useQuery`/`useMutation`. Don't toast 401/403/5xx in hooks
(the fetch layer already does) — only domain-specific 4xx messages.
- [ ] Minimise re-renders: stable references (`useCallback`/`useMemo` only where it pays), `select` to
subscribe to slices, colocate state low, lift only when shared. Don't put fast-changing state in
a high context provider.
## UI composition
- [ ] **Concrete MUI primitives stay MUI** — use `Button`, `Avatar`, `Paper`, `TextField`, etc. (or the
existing `App*` wrappers); never invent a new root-level Button/Avatar.
- [ ] **Composite/shareable components** (built from primitives, reused in >1 place — a nurse card, an
OTP input, a price-breakdown, a step header) live at the right **shared** level (`src/components/…`),
not inline in a page or buried in a leaf. A component imported from >1 place gets a co-located
`*.test.tsx`.
- [ ] Page-only, never-reused composition can stay in the page.
## i18n, theme, direction
- [ ] Every user-visible string is a key in **both** `messages/en.json` and `messages/fa.json` (in sync).
`fa` is default and **RTL** — design RTL-first; verify mirroring.
- [ ] Colours come from `tokens.css` (`var(--bal-…)` / brand tokens), never hardcoded in `sx`. Use the
pre-built `APP_THEME_LTR/RTL`; never `createTheme()` in a component.
- [ ] MUI **v9** API only (`sx={{ mb: 4 }}`, no v5/v6-only props).
## Quality
- [ ] Magic strings → named constants (`src/constants/` or a colocated `constants.ts`).
- [ ] Cookies/app state only through the cookie manager; never `document.cookie`/`localStorage` for auth.
- [ ] No dead code (unused vars/imports are lint **errors**). Comment the *why*, not the *what*.
- [ ] `npm run check` green; `npm run test:ci` green when a shared component changed.
- [ ] Types come from the published contract in `dev/contracts/` — don't guess server shapes; if a shape
is missing, append a request to `dev/shared-working-context/frontend/requests/for-backend.md` and
mock behind the `services/{domain}` seam meanwhile.
+63
View File
@@ -0,0 +1,63 @@
# Phase prompt template (the skeleton every phase file follows)
> This is the shape of every `backend-phase-N.md` and `frontend-phase-N-bM.md`. It exists so the
> chain is uniform and an agent always knows where to find each thing. When you (a planning author)
> create or edit a phase file, fill every section. When you (an executing agent) run a phase, expect
> every section to be present.
---
```
# <Backend|Frontend> Phase N — <Title>
> One-paragraph mission: what this phase delivers and why it matters to the product.
> **Track:** backend|frontend · **Depends on:** <prior phases / backend phase bM> · **Unlocks:** <what comes next>
> **Before you start, read [_shared/agent-operating-rules.md](../_shared/agent-operating-rules.md).** It is not optional.
## 1. Context — where this sits
- Where we are in the chain; the 23 sentence product framing.
- **What already exists (do not rebuild):** bullet list linking the prior phases + the baseline
facts (from `dev/shared-working-context/...` and the project state).
## 2. Required reading (do this first)
- Exact product docs to read (with paths) and *why* each matters.
- Exact code areas to read (existing patterns to mirror).
- Contracts to consume (frontend) / prior handoff notes.
## 3. Scope — build this
- The precise, enumerated deliverables: entities/migrations, commands/queries, endpoints
(backend); screens/flows, services/hooks, components (frontend). Each with enough detail to build
without guessing. Tag anything (DEFERRED) that is explicitly out of scope.
## 4. Mocks & seams in this phase
- Which external services are mocked, the interface each sits behind, what the mock returns, and the
pointer to record it in the mock registry. (Reuse seams introduced earlier; only *introduce* the
ones this phase owns.)
## 5. Critical rules you must not get wrong
- The domain-specific invariants (money correctness, idempotency, tenancy, two-stage clinical
disclosure, same-gender matching, RSC boundary, re-render/caching, etc.) relevant to this phase.
## 6. Definition of Done
- The phase-specific acceptance criteria, on top of the shared
[definition-of-done.md](../_shared/definition-of-done.md).
## 7. How to test (what a human can verify after this phase)
- Concrete, runnable steps (endpoints to curl / Swagger calls / screens to click) and the expected
result for each. This becomes the "what can be tested" section of your report.
## 8. Hand off & document (close the phase)
- Docs to update (which CLAUDE.md / product doc).
- Contract(s) to write (backend) / consume (frontend).
- The handoff note + report files to write, and the memory to save. (Mechanics in operating-rules §58.)
```
---
## Authoring notes
- Keep each phase **self-contained**: an agent should be able to execute it having read only the phase
file + the files it links. Link generously to prior phases and to `product/` — don't restate them.
- Phases are **vertical slices** where possible (entity → handler → endpoint → contract for backend;
service → hook → screen for frontend) so each ends in something testable.
- Never let a phase silently expand scope. If something belongs to a later phase, say so and link it.
+175
View File
@@ -0,0 +1,175 @@
# Backend Phase 0 — Foundation, cross-cutting seams & starter cleanup
> **Mission:** turn the inherited starter skeleton into a clean Balinyaar foundation. Remove the demo
> scaffolding, stand up the **REST API surface** the marketplace needs (the baseline is gRPC-only today),
> wire the missing cross-cutting plumbing (rate limiting, request logging, current-user + audit-field
> stamping, PII encryption), and define every **mock-able external dependency as a DI seam** with a
> faithful in-memory implementation so later phases just plug real providers in. No domain tables yet —
> this phase makes the next fifteen phases possible and consistent.
>
> **Track:** backend · **Depends on:** nothing (first phase) · **Unlocks:** every backend phase
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the very first build phase. The server (`server/`, .NET 10, Clean Architecture, CQRS via
**`martinothamar/Mediator`** — *not* MediatR) already ships a working spine you must **keep and build
on**, and some demo scaffolding you must **remove**.
**What already exists (do not rebuild) — confirmed in the codebase:**
- ASP.NET Core Identity + **JWE/JWT** + **phone-OTP (passwordless TOTP)** auth, the dynamic-permission
RBAC system, the CQRS pipeline (`ValidateCommandBehavior`, `MetricsBehaviour`), `OperationResult<T>`,
Mapster, FluentValidation, Serilog, OpenTelemetry/prometheus, health checks, the `BaseController` +
the full MVC filter/versioning/Swagger stack, and `Baya.Tests.Setup` (in-memory SQLite).
- The 12-project Clean-Arch solution `Baya.sln`. Identity tables map to the **`usr`** schema.
**What is starter scaffolding you will remove in this phase:**
- The `Order` demo end-to-end: `Domain/Entities/Order/Order.cs`, `Application/Features/Order/**`,
`Contracts/Persistence/IOrderRepository.cs`, `Persistence/Repositories/OrderRepository.cs`,
`Persistence/Configuration/OrderConfig/`, the `User.Orders` nav, `IUnitOfWork.OrderRepository`,
`OrderGrpcServices` + `OrderGrpcServiceModels.proto` and its wiring.
- The three old migrations + snapshot (`Migrations/2021…`, `2022…`, `2023…`, namespace
`Persistence.Migrations`) — they predate the marketplace and will be regenerated as the new baseline
in **backend-phase-1**.
**Known gaps you will close here:** no HTTP controllers exist (REST surface must be created);
`LoggingBehavior<,>` is defined but never registered; **no rate limiting** is wired despite
`CONVENTIONS.md` §11; OTP delivery is stubbed (handled in b2).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Startup wiring*, *Project map*, *Identity & auth*,
*Persistence*; and [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §1 routing, §4
controllers, §6 persistence (audit fields, soft delete), §9 logging, §11 security (rate limiting),
§12 service registration.
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four
ground truths (no cash custody, 10% VAT, full-upfront BNPL, weekly payout) and the **IRR-Rials-always**
rule that shapes the money types you scaffold here.
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope/format your REST
surface must honour.
- Read the actual code you'll touch: `Baya.Web.Api/Program.cs`, `Baya.Application/Common/*Behavior*.cs`,
`Baya.WebFramework/BaseController/*`, `Baya.Infrastructure.Persistence/ApplicationDbContext.cs`,
and each project's `ServiceConfiguration/` extension.
## 3. Scope — build this
### 3.1 Remove the starter scaffolding
Delete the `Order` feature/entity/repository/config/gRPC/proto and the three old migrations listed in §1.
Remove every reference (nav property, `IUnitOfWork` member, DI wiring, proto compile). The solution must
build clean afterwards. Do **not** remove Identity/auth/observability — those stay.
### 3.2 Stand up the REST surface
The marketplace is REST/JSON. Create the first versioned controller(s) under `Baya.Web.Api/Controllers/V1/`
following `CONVENTIONS.md` §4 exactly (sealed, `BaseController`, inject `ISender`, `[controller]`/`[action]`
tokens, `base.OperationResult(...)`, `[Display(Description=...)]`). A minimal **health/ping** or
**reference** controller is enough to prove the pipeline end-to-end through Swagger; later phases add the
real controllers. Confirm `MapControllers()` now serves real routes and Swagger renders them.
### 3.3 Close the wiring gaps
- **Register `LoggingBehavior<,>`** in the Application pipeline (alongside the existing behaviors), in the
correct order, so every request is structurally logged (no PII — `CONVENTIONS.md` §9).
- **Rate limiting** (`CONVENTIONS.md` §11): add `AddRateLimiter` via a `ServiceConfiguration/` extension
and `UseRateLimiter()` **before** `UseAuthentication()` in `Program.cs`. Define named policies (a
per-IP fixed-window/token-bucket baseline) ready for auth/OTP/refund/payout endpoints to apply later.
### 3.4 Current-user + audit-field stamping
- Add **`ICurrentUser`** (Application contract) wrapping the HTTP context (user id, roles), registered
**Scoped**, with a null-object for non-HTTP contexts (jobs/tests).
- Add a **SaveChanges interceptor** (or extend the existing `SavingChanges` hook) that stamps
`CreatedAt/ModifiedAt` and `CreatedById/ModifiedById` from `ICurrentUser` on a shared base/interface,
per `CONVENTIONS.md` §6. Define the audit-capable base type (extend the existing `BaseEntity`/
`ITimeModification` rather than inventing a parallel one). The `audit_logs` *table* and the
append-only change log come in **b1** — here you build the *field-stamping* plumbing the interceptor
needs and leave a clean extension point for b1 to add log-row writing.
### 3.5 Define the cross-cutting seams (interfaces + mocks + DI)
Create these **Application-layer interfaces** with real-shaped signatures, an **Infrastructure mock
implementation** each, and **DI registration** via a `ServiceConfiguration/` extension (selected by
config so a real impl swaps in later). Keep amounts as IRR `long`. This phase **introduces** these seams;
later phases reuse them.
- **`IDateTimeProvider`** — `DateTimeOffset UtcNow` (so time is testable; no `DateTime.Now` in handlers).
- **`IFieldEncryptor`** — `string Encrypt(string)` / `string Decrypt(string)` (+ a deterministic
`Hash(string)` for lookups like `iban_hash`). Mock = local symmetric key from config; **never** log
plaintext PII. This is what every encrypted PII column will use.
- **`ICacheService`** — typed get/set/remove with TTL and a `GetOrCreateAsync`. Mock = in-memory
(`IMemoryCache`-backed). The config accessor (b1) and read-heavy queries cache through this; Redis later.
- **`IObjectStorage`** — presigned/streamed put/get/delete keyed by an opaque storage key, returning a
retrievable URL. Mock = local-disk/in-memory store under a scratch path. MinIO/S3 later.
- **`INotificationDispatcher`** — `DispatchAsync(notification)` with a channel concept (in-app now;
SMS/push later). Mock = no-op/log; the real in-app `notifications` write lands in b15. Define the seam
now so emitting domains (booking, payments) can depend on it.
> The **OTP/SMS** seam (`ISmsSender`) is introduced in **b2** (with the auth REST surface), and the
> **search/payment/bnpl/bank/vendor** seams in their phases — do not pre-build those here; just leave the
> registry rows. (Listed in [mocks-registry.md](../../shared-working-context/reports/mocks-registry.md).)
### 3.6 Money & convention guardrails
Add (or confirm) the small shared helpers the money path will rely on: IRR is `long`/`BIGINT`
everywhere; if you add a money value object keep it integer-only with no float path. Document the rule in
`server/CONVENTIONS.md` if not already explicit.
## 4. Mocks & seams in this phase
| Seam | Mock behaviour | Registry |
| --- | --- | --- |
| `IDateTimeProvider` | returns real UTC now (deterministic override in tests) | n/a (not external) |
| `IFieldEncryptor` | local symmetric key from config; passthrough-but-reversible; deterministic hash | update row |
| `ICacheService` | in-memory `IMemoryCache` | update row |
| `IObjectStorage` | local-disk/in-memory blob store | update row |
| `INotificationDispatcher` | log/no-op (in-app write arrives in b15) | update row |
Record each in [`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file,
what's faked, config keys, how to make real, status 🟡).
## 5. Critical rules you must not get wrong
- **Don't break the working spine.** Identity, JWE auth, dynamic permissions, the CQRS behaviors, and
observability must still work after cleanup. Run the existing tests.
- **Seams live in Application, implementations in Infrastructure.** Never reference Infrastructure from
Application/Domain. Register via `ServiceConfiguration/` extensions called from `Program.cs` — no inline
DI in `Program.cs` (`CONVENTIONS.md` §12).
- **Mock = real interface, fake body.** No `if (mock)` branches in handlers; selection is by registration.
- **`IFieldEncryptor` never leaks plaintext** into logs, exceptions, or non-`AsNoTracking` query
projections of PII.
- **Audit-field stamping is infrastructure, not handler code** — handlers never set `CreatedById` etc.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `Order` + the three old migrations are gone; `dotnet build Baya.sln` is clean (zero new warnings);
`dotnet test Baya.sln` passes (existing identity tests still green).
- [ ] At least one real REST controller is reachable through Swagger and returns the `OperationResult`
envelope.
- [ ] `LoggingBehavior` registered; rate limiter wired (policies defined, `UseRateLimiter` placed before
auth).
- [ ] `ICurrentUser` + audit-field interceptor in place; the five seams (§3.5) registered with mocks.
- [ ] The **Project map** in `server/CLAUDE.md` is updated (Order removed; seams/cross-cutting noted);
`CONVENTIONS.md` notes the IRR-`BIGINT` money rule if it wasn't explicit.
## 7. How to test (what a human can verify after this phase)
- `dotnet build Baya.sln` and `dotnet test Baya.sln` — both succeed.
- `dotnet run` the API → open `/swagger` → the new REST controller appears and its endpoint returns a
200 in the standard envelope; the Order endpoints are gone.
- Hit the rate-limited test endpoint past its limit → `429`.
- A unit test proves `IFieldEncryptor.Decrypt(Encrypt(x)) == x` and the audit interceptor stamps
`CreatedAt`/`CreatedById` on a save (use the SQLite test context).
## 8. Hand off & document (close the phase)
- **Docs:** update `server/CLAUDE.md` *Project map* (+ a one-line note on the new seams and where they're
registered). Update `CONVENTIONS.md` only if you established a new rule.
- **Contracts:** no domain API yet — but publish the first `swagger.json` snapshot
([openapi/README.md](../../contracts/openapi/README.md)) so the frontend's f0 can wire its type pipeline
against the envelope shape.
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-0.md` (the spine
is clean, REST works, seams exist, what f0/b1 can rely on), append to `backend/STATUS.md`, write
`reports/backend-phase-0-report.md`, and update `reports/mocks-registry.md` (the five rows → 🟡).
- **Memory:** save a `project` memory note for any non-obvious decision (e.g. how the seams are selected
by config, the audit interceptor design) with a `MEMORY.md` pointer.
+414
View File
@@ -0,0 +1,414 @@
# Backend Phase 1 — Config, reference & platform signals
> **Mission:** lay the platform backbone every later phase reads from or writes to. This phase creates
> the **first marketplace EF migration baseline** (b0 deleted the old starter migrations) and stands up
> the cross-cutting tables nothing in the marketplace can run without: typed runtime config
> (`platform_configs`), the immutable audit trail (`audit_logs`) wired to the SaveChanges interceptor,
> the analytics event log (`system_events`), the shared `iranian_holidays` calendar that shifts payout
> dates, in-app `notifications`, and the internal `support_alerts` staff worklist. It ships the typed
> cached config accessor, the holiday calendar, the audit/event/notification/alert capabilities, seeds
> the canonical config keys (fee rate, VAT, dispute window, deadlines, payout interval, EVV tolerance,
> BNPL flags, cancellation tiers) and a sample holiday set — so b2…b15 read real values instead of
> hardcoding money-critical constants.
>
> **Track:** backend · **Depends on:** [backend-phase-0](backend-phase-0.md) (the cross-cutting seams, `ICurrentUser`, the audit-field SaveChanges interceptor, `IFieldEncryptor`, `ICacheService`) · **Unlocks:** every phase that reads config/holidays or raises notifications/alerts — i.e. all of b2…b15
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **second** backend phase and the foundation for the rest of the chain. b0 cleaned the
starter skeleton, stood up the REST surface, wired rate limiting + request logging + current-user/
audit-field stamping, and **defined the cross-cutting seams as DI-registered mocks**. It deliberately
shipped **no domain tables** — that starts here. Everything b2…b15 does (auth, profiles, catalog,
booking, payments, payouts, reviews, admin) reads config rows, shifts dates off holidays, writes audit
rows, raises notifications, or raises support alerts. Those four mechanisms are built **once, here**, so
no later phase reinvents them.
This is also where the **first marketplace migration baseline** is born: b0 removed the three pre-2024
starter migrations and their snapshot. The migration you generate in this phase is the new `Persistence`
baseline that every subsequent phase adds incrementally onto.
**What already exists (do not rebuild) — built by [backend-phase-0](backend-phase-0.md):**
- The cross-cutting **seams** with in-memory mocks, DI-registered via `ServiceConfiguration/` extensions
and selected by config: **`ICacheService`** (in-memory `IMemoryCache`; your config accessor caches
through it), **`IFieldEncryptor`** (`Encrypt`/`Decrypt`/`Hash`; reuse for any encrypted column),
**`IDateTimeProvider`** (`DateTimeOffset UtcNow`; never call `DateTime.Now`), **`IObjectStorage`**,
and **`INotificationDispatcher`** (b0 shipped a **no-op/log stub****this phase supersedes it** with a
real in-app `notifications` write; see §3.6 and §4).
- **`ICurrentUser`** (Application contract, Scoped, null-object outside HTTP) and the **SaveChanges
interceptor** that stamps `CreatedAt/ModifiedAt/CreatedById/ModifiedById`. b0 left a **clean extension
point on that interceptor for b1 to add audit-log-row writing** — you extend it here, you do not write
a parallel interceptor.
- The full working spine: ASP.NET Core Identity + JWE/JWT + phone-OTP, dynamic-permission RBAC, the CQRS
pipeline (`ValidateCommandBehavior`, `MetricsBehaviour`, and the now-registered `LoggingBehavior`),
`OperationResult<T>`, Mapster, FluentValidation, the `BaseController` + MVC/versioning/Swagger stack,
the REST controller surface under `Baya.Web.Api/Controllers/V1/`, rate-limiting policies, and
`Baya.Tests.Setup` (in-memory SQLite). Identity tables live in the **`usr`** schema.
- The 12-project Clean-Arch solution `Baya.sln`. The `swagger.json` envelope snapshot is published so the
frontend's f0 type pipeline already knows the `OperationResult`/`ApiResult` shape.
**What is NOT yet built (you build it here):** any marketplace table, any migration, the typed config
accessor, the holiday calendar, audit-row writing, system-event emission, notifications, support alerts.
## 2. Required reading (do this first)
**Operating rules & conventions**
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Project map*, *Persistence*, *Startup wiring*,
*Features* (the `Features/<Area>/{Commands|Queries}/<Name>/` layout); and
[`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §6 persistence (configs per entity, audit
fields, soft-delete filters, EF projection + pagination), §9 logging (no PII), §12 service registration.
**Product / domain truth (the business rules are decisions, read them — don't infer from code)**
- [`product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md)
— the canonical field list and roles of `audit_logs`, `system_events`, `platform_configs`, and the
**new** `iranian_holidays` table (exact columns) + the full config-key list to seed.
- [`product/data-model/11-notifications.md`](../../../product/data-model/11-notifications.md) — the roles
of `notifications` (N:1 → `users`, `data_json` typed payload, 90-day read-retention) and
`support_alerts` (internal-only, polymorphic `entity_type`/`entity_id` + nullable typed FKs).
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md)
*why* in-app-only/polled, the 90-day retention rule, the admin/backoffice spine these rows feed, the
append-only audit requirement, and the Shamsi/holiday reasoning back-office depends on.
- [`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md)
*why* `vat_rate` (10%, configurable, applies to the **commission line** only) and the BNPL
merchant-of-record config flags exist; finance must be able to prove the exact rate at any past moment
(this is the reason every config change is audited).
**Contract conventions you must honour**
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
`OperationResult` envelope, snake_case routes, mandatory list pagination (`page`/`page_size`),
localisation (`name_fa` returned for reference data), status codes.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — IRR
`BIGINT`/`long`, no floats. `platform_fee_rate`/`vat_rate` are **rates** (`DECIMAL`), not money; the
amounts they later multiply are IRR `long`.
**Prior handoff**
- [`../../shared-working-context/backend/handoff/after-backend-phase-0.md`](../../shared-working-context/backend/handoff/after-backend-phase-0.md)
and [`../../shared-working-context/reports/backend-phase-0-report.md`](../../shared-working-context/reports/backend-phase-0-report.md)
— exactly what b0 left you (seam names/files, the interceptor extension point, the REST pattern).
**Code to mirror**
- The seam interfaces + their mock implementations and `ServiceConfiguration/` registration from b0
(mirror that style for `IHolidayCalendar`, `IAnalyticsSink`, and the real `INotificationDispatcher`).
- The SaveChanges interceptor b0 extended — you add audit-row writing to its diff-collection path.
- The `BaseController` REST pattern from b0's controller — mirror it for the new admin controllers.
## 3. Scope — build this
> Layout follows `Features/<Area>/{Commands|Queries}/<Name>/`. Suggested areas:
> **`Configuration`**, **`Audit`**, **`Analytics`**, **`Holidays`**, **`Notifications`**,
> **`SupportAlerts`**. Each entity gets exactly one `IEntityTypeConfiguration<T>` under
> `Persistence/Configuration/<Area>Config/`. Reads use `AsNoTracking()` + `.Select(...)` projection +
> pagination; commands return `OperationResult`. Put these tables in a dedicated **`ref`/`ops` schema**
> (mirror how Identity uses `usr`) — keep them off the `usr` schema.
### 3.1 Entities & the first marketplace migration baseline
Create these EF entities + configurations, then generate **one** migration that becomes the new
`Persistence` baseline. (b0 already deleted the old migrations/snapshot.)
- **`platform_configs`** — typed key-value runtime parameters. Columns: `id` (BIGINT PK), `key`
(NVARCHAR, **UNIQUE**), `value` (NVARCHAR — the raw string), `data_type` (NVARCHAR — `decimal` / `int` /
`bool` / `string` / `json`; **tells the app how to parse `value`**), `description` (NVARCHAR, nullable),
plus the standard audit fields. **Every change to a row here is audited** (§3.3). Soft-delete is **not**
appropriate — configs are updated in place, the audit trail is the history.
- **`audit_logs`** — immutable, append-only. Columns: `id` (BIGINT PK), `entity_type` (NVARCHAR),
`entity_id` (NVARCHAR — string so it's polymorphic across PK types), `action` (NVARCHAR —
`created`/`updated`/`deleted`), `changed_fields_json` (NVARCHAR(MAX) — `{ field: { old, new } }` for
fast filtering), `actor_user_id` (FK→`users`, nullable for system actions), `occurred_at`
(DATETIMEOFFSET, from `IDateTimeProvider`). **No `ModifiedAt`/`IsDeleted` — append-only, never updated
or deleted.** Index `(entity_type, entity_id)` and `occurred_at`. (Month-partitioning + cold-storage
archival is **(DEFERRED)** — note the index design now; the archive store seam lands later.)
- **`system_events`** — high-volume analytics, **NOT compliance**. Columns: `id` (BIGINT PK), `name`
(NVARCHAR — event name), `props_json` (NVARCHAR(MAX) — arbitrary properties), `user_id` (FK→`users`,
nullable), `occurred_at` (DATETIMEOFFSET). Append-only; no audit fields needed beyond `occurred_at`.
- **`iranian_holidays`** — exact columns from the data-model doc: `id` (BIGINT PK), `holiday_date`
(DATE, index it — lookups are by date), `name_fa` (NVARCHAR(200)), `type` (NVARCHAR(20) —
`official`/`religious`/`national`), `is_bank_closed` (BIT). Unique on `holiday_date`.
- **`notifications`** — in-app, per user. Columns: `id` (BIGINT PK), `user_id` (FK→`users`, indexed),
`type` (NVARCHAR — drives front-end rendering/deep-link), `title` (NVARCHAR), `body` (NVARCHAR,
nullable), `data_json` (NVARCHAR(MAX) — **typed deep-link payload**, a versioned contract, not an
arbitrary blob), `is_read` (BIT, default 0), `read_at` (DATETIMEOFFSET, nullable), `created_at`. Index
`(user_id, is_read, created_at)` to serve unread-first paging and the unread-count query cheaply.
- **`support_alerts`** — internal staff worklist, **never user-facing**. Columns: `id` (BIGINT PK),
`type` (NVARCHAR — `low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/
`payment_anomaly`/`fraud_signal`), `severity` (NVARCHAR — `low`/`medium`/`high`), `status` (NVARCHAR —
`open`/`assigned`/`resolved`), `entity_type` (NVARCHAR) + `entity_id` (NVARCHAR) — **polymorphic,
validated at the application layer, no DB FK** — plus **nullable typed FKs** `booking_id`
(FK→`bookings`, nullable) and `review_id` (FK→`reviews`, nullable) for the common cases (declare the FK
columns now; the `bookings`/`reviews` tables arrive in b9/b14 — gate the relationship config so the
migration is additive-safe, or add the FK constraints in the phase that creates the target table and
note it in your handoff), `owner_user_id` (FK→`users`, nullable — the assigned admin), `resolution_note`
(NVARCHAR, nullable), `resolved_at` (DATETIMEOFFSET, nullable), plus standard audit fields.
> **Migration discipline:** generate the migration with a descriptive name (e.g.
> `InitialMarketplaceBaseline`) into `Baya.Infrastructure.Persistence/Migrations`. It must apply cleanly
> against a fresh DB and be the snapshot every later phase builds on. Run `dotnet ef database update`
> (or the project's migration entry point) to confirm.
### 3.2 Config — `IPlatformConfig` (Application contract) + cached accessor
- **`IPlatformConfig`** in `Application/Contracts/` with: `Task<T> GetConfig<T>(string key, …)` (parses by
the row's `data_type`, **cached** through `ICacheService` with a sensible TTL and key scheme), and
`Task SetConfig(string key, string value, …)` (admin path — writes the row **and** an audit entry; see
§3.3) and `Task<…> GetConfigChangeHistory(string key, …)` (reads the audit trail filtered to that key's
rows). Implement in Infrastructure.
- **Cache invalidation:** `SetConfig` must evict the cache key it changes so the next `GetConfig<T>` reads
the new value — but **see the money-correctness rule in §5**: changing a rate must never retroactively
alter already-computed amounts.
- **Commands/queries:** `UpdatePlatformConfig` (command, admin-only, validated, audited),
`ListPlatformConfigs` (query, paged), `GetConfigChangeHistory` (query, paged).
### 3.3 Audit — interceptor-driven write + trail query
- **Extend the b0 SaveChanges interceptor** (do not write a new one) so that on `SavingChanges` it
inspects tracked entries for entities marked **auditable** (a marker interface, e.g. `IAuditable`, or an
explicit allow-list — `platform_configs` is auditable; `system_events`/`notifications` are **not**) and
appends an `audit_logs` row per change with `entity_type`, `entity_id`, `action`, a computed
`changed_fields_json` (old/new per modified property; redact encrypted PII — never write plaintext into
the diff), and `actor_user_id` from `ICurrentUser`. The audit write happens in the **same transaction**
as the change.
- **`WriteAuditLog`** is the interceptor-driven mechanism above; also expose an explicit
`IAuditLogger.WriteAsync(...)` Application contract for cases a handler needs to record a non-entity
state change (e.g. an action with no row diff).
- **Query `GetAuditTrail(entity_type, entity_id)`** — paged, `AsNoTracking()` + projection, admin-only.
### 3.4 Analytics — `IAnalyticsSink` + `EmitSystemEvent`
- **`IAnalyticsSink`** (Application contract, **seam introduced here**) with
`Task EmitAsync(string name, object props, …)`. Mock implementation = **insert a `system_events` row**.
Calls are **fire-and-forget** from the caller's perspective (do not block or fail the user's operation
if the sink errors — log and continue). `EmitSystemEvent` is the thin command/helper that calls the
sink. **Never** route compliance-relevant facts here — those go to `audit_logs`.
### 3.5 Holidays — `IHolidayCalendar` + seed
- **`IHolidayCalendar`** (Application contract, **seam introduced here**) with
`Task<bool> IsHoliday(DateOnly date, …)`, `Task<bool> IsBankClosed(DateOnly date, …)`, and
`Task<DateOnly> NextBusinessDay(DateOnly date, …)`. Mock implementation = the **seeded
`iranian_holidays` table** (cache the lookups through `ICacheService`). `NextBusinessDay` skips
`is_bank_closed` days (and weekend rules per the Iranian banking week) — this is what payout scheduling
(b13) calls.
- **Admin CRUD** for the calendar: `ListHolidays` (query, paged/by-range), `UpsertHoliday`,
`DeleteHoliday` (commands, admin-only, audited if you make the table auditable — at minimum log via the
interceptor allow-list if finance cares; otherwise standard audit fields suffice).
### 3.6 Notifications — real in-app write (supersedes the b0 stub)
- **Replace** the b0 no-op `INotificationDispatcher` mock with a **real in-app implementation** that writes
a `notifications` row (channel concept retained: in-app **now**; SMS/push are **(DEFERRED)** to later
channels behind the same dispatcher — do **not** build them). Keep the seam interface stable so other
domains (booking, payments, reviews, verification) call `DispatchAsync(...)` unchanged.
- **`CreateNotification(user_id, type, data_json)`** — the internal command other domains call (via the
dispatcher) to mint a typed in-app record with a deep-link payload.
- **Queries:** `ListMyNotifications` (paged, **unread-first** ordering, tenant-scoped to the caller),
`GetUnreadCount` (cheap count for the polling bell — index-backed).
- **Commands:** `MarkNotificationRead` (single, sets `is_read`+`read_at`), `MarkAllRead` (bulk for the
caller).
- **Retention job `PurgeOldReadNotifications`** — hard-deletes notifications where `is_read = 1` **AND**
`created_at` (or `read_at`) older than 90 days; **never deletes unread**. There is no scheduler in the
codebase yet — introduce the job behind a small **`IJobScheduler`/hosted-service seam** (mock = a
`BackgroundService` running on an interval, or an endpoint/CLI trigger), register it, and record it in
the mock registry. Real Hangfire/Quartz is **(DEFERRED)**.
### 3.7 Support alerts — raise / assign / resolve / list
- **`RaiseSupportAlert(type, entity_type, entity_id, severity, booking_id?, review_id?)`** — the internal
command review/EVV/verification/payment flows call later. Validate the polymorphic
`(entity_type, entity_id)` at the application layer; prefer setting the typed FK when the entity is a
booking/review.
- **`AssignSupportAlert(alert_id, owner_user_id)`** and **`ResolveSupportAlert(alert_id, note)`** — owner +
resolution trail; forward-only status (`open``assigned``resolved`).
- **`ListSupportAlerts`** — admin-only query, paged, filter by `type`/`status`/`owner`. **These are on
admin-only routes and never joined into any user-facing endpoint.**
### 3.8 Admin REST surface
Add the controllers (sealed, `BaseController`, inject `ISender`, `base.OperationResult(...)`,
`[controller]`/`[action]` tokens, narrowest fitting admin policy, lists paginated). Suggested actions
(final route casing is snake_case per conventions):
- **Config:** `GET get_platform_configs`, `POST update_platform_config`, `GET get_config_change_history`.
- **Holidays:** `GET get_holidays`, `POST upsert_holiday`, `POST delete_holiday`.
- **Audit:** `GET get_audit_trail`.
- **Support alerts (admin):** `GET get_support_alerts`, `POST assign_support_alert`,
`POST resolve_support_alert`.
- **Notifications (current user):** `GET get_notifications`, `GET get_unread_count`,
`POST mark_notification_read`, `POST mark_all_read`.
> **Not exposed via REST:** `CreateNotification`, `RaiseSupportAlert`, `EmitSystemEvent`,
> `WriteAuditLog` — these are **internal application commands** other domains invoke through their
> contracts/the dispatcher, never user-callable HTTP endpoints.
### 3.9 Seed data
- **`platform_configs` keys (seed all, with `data_type`):** `platform_fee_rate` (decimal — platform
commission %), `vat_rate` (decimal, **0.10**), `dispute_window_hours` (int, **72**),
`booking_payment_deadline_minutes` (int, **30**), `nurse_response_deadline_hours` (int),
`nurse_payout_interval_days` (int), `evv_location_tolerance_meters` (int),
`min_rating_for_support_alert` (decimal/int), `bnpl_merchant_of_record` (string),
`bnpl_provider_commission_rate` (decimal), `bnpl_settlement_timing` (string), and the
**cancellation-tier defaults** (the tiered cancellation policy thresholds/percentages — store as
`json` or as discrete tier keys; b9/b11 consume them). Seed via the migration or an idempotent seeder.
- **`iranian_holidays`:** seed a representative sample set (Nowruz block, a few official/religious/national
days) with correct `is_bank_closed` flags so `IsBankClosed`/`NextBusinessDay` are testable. The full,
maintained, partly-lunar-Hijri calendar feed is **(DEFERRED)** behind the `IHolidayCalendar` "make it
real" path.
### 3.10 Out of scope (DEFERRED — do not build here)
- `roles`/`user_roles` admin RBAC tables and the role-scope authorization grid → **(DEFERRED to b15)**;
use the existing dynamic-permission RBAC + the narrowest fitting policy for now.
- The verification queue, refund tooling, payout tooling, invoices, partner centers, tickets → their own
phases (b6, b11, b13, b15). This phase builds only the **shared signals** they all use.
- `audit_logs` month-partitioning + cold-storage archival (`IArchiveStore`); `system_events`
warehouse/exporter; SMS/push notification channels; real Hangfire/Quartz scheduler; the external
Iranian-holiday sync feed → all **(DEFERRED)** behind their seams.
## 4. Mocks & seams in this phase
This phase **introduces** three seams and **supersedes** one b0 stub. Reuse the b0 seams
(`ICacheService`, `IFieldEncryptor`, `IDateTimeProvider`, `IObjectStorage`) as-is.
| Seam | New / changed | Mock behaviour | Registry |
| --- | --- | --- | --- |
| `IHolidayCalendar` | **introduced (b1)** | reads the seeded `iranian_holidays` table; cached via `ICacheService` | update row (was listed for b1) |
| `IAnalyticsSink` | **introduced (b1)** | inserts a `system_events` row; fire-and-forget | **add new row** |
| `INotificationDispatcher` | **superseded** (b0 stub → real) | writes a real `notifications` row (in-app channel); SMS/push deferred | update row (b0/15 → in-app real here) |
| `IJobScheduler` (retention) | **introduced (b1)** | in-proc `BackgroundService`/interval runner for `PurgeOldReadNotifications`; real Hangfire/Quartz deferred | **add new row** |
| `ICacheService` | reuse (b0) | in-memory `IMemoryCache` — config + holiday lookups cache through it | n/a (already 🟡) |
| `IFieldEncryptor` | reuse (b0) | used to redact PII in `changed_fields_json` | n/a (already 🟡) |
Record each introduced/changed seam in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam, file, what's faked,
config keys, how to make it real, status 🟡). Selection stays **by registration/config**, never by an
`if (mock)` branch in a handler.
## 5. Critical rules you must not get wrong
- **`audit_logs` is append-only / immutable.** There is **NO update or delete of `audit_logs` rows in app
code** — ever. No `Update`/`Remove` on the set, no soft-delete column, no admin edit endpoint. It is the
system of record for compliance; enforce append-only at the repository/configuration level.
- **EVERY `platform_configs` change is audited.** Finance must be able to **prove the exact commission/VAT
rate in effect at any moment**. `SetConfig`/`UpdatePlatformConfig` must write the row **and** an
`audit_logs` entry in the **same transaction** — never mutate a config without the audit entry.
- **Config-as-rows = money correctness.** Commission %, VAT rate, dispute window, payout interval, EVV
tolerance, cancellation tiers all flow from `platform_configs`. **Read them at compute time (cached),
never hardcode**, and respect `data_type` when parsing. **Changing a rate must NOT retroactively alter
already-computed bookings/ledger** — later phases snapshot the rate they used onto the booking/invoice
at compute time; this phase must not encourage live re-reading of a rate for an already-priced row.
- **`iranian_holidays` drives payout date shifting.** If PAYA/SATNA banks are closed
(`is_bank_closed = 1`), a weekly payout shifts to the next business day via `NextBusinessDay`. Get the
calendar and the "next open bank day" logic right or payouts (b13) mis-schedule. Holidays are partly
movable/lunar-Hijri — the table is maintained, the calendar is a seam.
- **`system_events` is analytics, NOT compliance.** Never rely on it for audit/dispute evidence — it can
be sampled, dropped, or exported. Compliance facts go to `audit_logs`. Emission is fire-and-forget and
must never fail or slow the user's operation.
- **Notifications retention purges only READ notifications older than 90 days — NEVER unread.** The purge
predicate is `is_read = 1 AND <age> > 90d`. Unread notifications are never auto-deleted.
- **`support_alerts` are internal-only.** They must **never** appear in any user-facing endpoint, query,
or join. Keep them on admin-only routes with admin authorization. Never leak alert content into a
user-facing `notification`.
- **`data_json` is a typed contract.** Front-end navigation/deep-linking depends on its shape — version it
and validate it; do not dump arbitrary blobs. Same discipline for `support_alerts` polymorphic
`(entity_type, entity_id)`: validate at the application layer (no DB FK), prefer the typed FK
(`booking_id`/`review_id`) when available.
- **Tenancy.** A user sees only their **own** notifications. `ListMyNotifications`/`GetUnreadCount`/
`MarkAllRead` are always scoped to `ICurrentUser` — never a `user_id` from the request body.
- **Encrypted PII never enters `changed_fields_json`.** When the interceptor diffs an auditable entity,
redact any encrypted/PII property — write a marker (e.g. `"<redacted>"`), never the plaintext.
- **Money is IRR `BIGINT`/`long`, no floats.** Rates (`platform_fee_rate`, `vat_rate`,
`bnpl_provider_commission_rate`) are `DECIMAL` rate values; the IRR amounts they later multiply are
`long`. No money column or float appears in this phase's tables, but keep the rule visible for the
phases that read these rates.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The **first marketplace migration baseline** is generated and applies cleanly to a fresh DB; all six
tables (`platform_configs`, `audit_logs`, `system_events`, `iranian_holidays`, `notifications`,
`support_alerts`) exist with the columns/indexes/uniques above.
- [ ] `IPlatformConfig.GetConfig<T>` returns a correctly **typed** value parsed by `data_type` and is
cached; `SetConfig`/`UpdatePlatformConfig` writes the row **and** an `audit_logs` row in one
transaction; `GetConfigChangeHistory` returns that audit entry.
- [ ] The SaveChanges interceptor (extended from b0) writes an `audit_logs` row on an auditable-entity
change with correct `changed_fields_json` and `actor_user_id`; no path updates/deletes `audit_logs`.
- [ ] `IHolidayCalendar.IsBankClosed`/`IsHoliday`/`NextBusinessDay` answer correctly over the seeded
calendar; the seed set is present.
- [ ] `IAnalyticsSink.EmitAsync` inserts a `system_events` row and is fire-and-forget.
- [ ] The **real** `INotificationDispatcher` writes a `notifications` row (b0 stub removed/replaced);
`CreateNotification``ListMyNotifications` (unread-first, tenant-scoped) + `GetUnreadCount` work;
`MarkNotificationRead`/`MarkAllRead` flip `is_read`; `PurgeOldReadNotifications` deletes only
read>90d.
- [ ] `RaiseSupportAlert``ListSupportAlerts` (admin) works; `AssignSupportAlert`/`ResolveSupportAlert`
record owner + resolution; support alerts appear on no user-facing route.
- [ ] All seeded config keys (§3.9) and the sample holidays are present after migration/seed.
- [ ] Admin + notification controllers reachable via Swagger, returning the `OperationResult` envelope;
lists paginated; admin routes authorized.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green (the handler + integration
tests this phase adds included). The **Project map** in `server/CLAUDE.md` is updated (new areas,
new schema, the three new seams + where they're registered).
## 7. How to test (what a human can verify after this phase)
Run `dotnet ef database update` (fresh DB) then `dotnet run` the API and use Swagger; back the unit/
integration assertions with `Baya.Tests.Setup` (SQLite).
1. **Migration creates all tables.** Apply the migration to a clean DB → all six tables + indexes/uniques
exist; the seeded config keys and sample holidays are present (query them).
2. **Typed config read.** `GetConfig<decimal>("vat_rate")``0.10m`; `GetConfig<int>("dispute_window_hours")`
`72`; `GetConfig<int>("booking_payment_deadline_minutes")``30`. A second read hits the cache.
3. **Config change is audited.** `POST update_platform_config { key:"platform_fee_rate", value:"0.18" }`
→ succeeds; `GET get_config_change_history?key=platform_fee_rate` shows the change (old→new, actor,
timestamp); a follow-up `GetConfig<decimal>("platform_fee_rate")` returns the new value (cache evicted).
Confirm no path can update/delete the `audit_logs` row.
4. **Holiday calendar.** `IsBankClosed(<a seeded bank-closed date>)``true`; `IsHoliday(<non-holiday>)`
`false`; `NextBusinessDay(<a seeded holiday>)` → the next open bank day.
5. **System event.** `EmitSystemEvent("nurse_search_performed", {...})` → a `system_events` row exists;
failure of the sink does not surface to the caller.
6. **Notifications round-trip.** `CreateNotification(user, "booking_confirmed", {...})` then
`GET get_notifications` (as that user) shows it unread-first; `GET get_unread_count` returns `1`;
`POST mark_notification_read` → unread count `0`. As a **different** user, the notification is not
visible (tenancy).
7. **Retention.** Insert a read notification dated >90 days, run `PurgeOldReadNotifications` → it is gone;
an unread >90-day notification and a read <90-day notification both **remain**.
8. **Support alerts.** `RaiseSupportAlert("low_rating", "review", "42", review_id:42)` then
`GET get_support_alerts` (admin) shows it `open`; `POST assign_support_alert``assigned` with owner;
`POST resolve_support_alert``resolved` with note. Confirm it appears on **no** user-facing endpoint.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) — the new feature
areas (`Configuration`/`Audit`/`Analytics`/`Holidays`/`Notifications`/`SupportAlerts`), the new
`ref`/`ops` schema, and the three new seams + where they register (`ServiceConfiguration/`). Add a short
note in `server/CONVENTIONS.md` if you established a reusable pattern (the typed config accessor, the
auditable-entity marker, the retention-job seam). If you discovered/decided any config-key default the
`product/` docs don't capture, record it in
[`product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md)
(don't invent rules — record decisions) and regenerate the HTML view per `product/CLAUDE.md`.
- **Contract:** write
[`../../contracts/domains/config-reference.md`](../../contracts/domains/config-reference.md) (use
[`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) covering the admin config/holiday/audit/
support-alert endpoints and the current-user notification endpoints — request/response shapes, the
`data_json`/`changed_fields_json` payload contracts, enums (`data_type`, holiday `type`, alert
`type`/`status`/`severity`, notification `type`), pagination, status codes. Re-publish the `swagger.json`
snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). Mark it **live as
of backend-phase-1**, frontend consumer **f14** (notification center) / **f15** (admin config/holidays/
audit/alerts).
- **Handoff & report:** write
`shared-working-context/backend/handoff/after-backend-phase-1.md` (the baseline migration is live; config
is read via `IPlatformConfig`; holidays via `IHolidayCalendar`; audit rows write automatically on
auditable entities; `INotificationDispatcher` now writes real in-app notifications; `RaiseSupportAlert`
exists for later domains to call — list the internal contracts b2…b15 should depend on, not re-create),
append to `shared-working-context/backend/STATUS.md`, write
`shared-working-context/reports/backend-phase-1-report.md` (what was built, what is now testable and
exactly how — §7, what is mocked + how to make it real, contracts produced, follow-ups: the FK
constraints for `support_alerts.booking_id`/`review_id` to be added in b9/b14), and update
`shared-working-context/reports/mocks-registry.md` (`IHolidayCalendar`, `IAnalyticsSink`, the retention
`IJobScheduler` → 🟡; flip `INotificationDispatcher` to in-app-real 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes (config-as-rows
read-at-compute-time + rate-snapshot rule; the auditable-entity marker + append-only enforcement; the
notification retention predicate; the support-alert internal-only boundary; the new `ref`/`ops` schema)
with a one-line `MEMORY.md` pointer.
+439
View File
@@ -0,0 +1,439 @@
# Backend Phase 10 — Payments core: ledger, transactions, webhooks & card capture
> **Mission:** stand up the **money core** — the append-only, double-entry **`ledger_entries`** that is
> the financial source of truth; **`payment_transactions`** (every attempt, with the two filtered-unique
> guards that make capture idempotent); **`payment_webhook_events`** (the at-least-once callback store whose
> `UNIQUE(provider_code, external_event_id)` is the single idempotency chokepoint); and **`payment_gateways`**
> (encrypted provider config for selection/failover). On top of these, build the card rail end-to-end:
> **InitiatePayment** against a `pending_payment` booking → a PSP webhook **confirms** it → the balanced
> **card-capture ledger group** posts (DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission +
> `nurse_payable` payout) → the booking **converts/confirms** (the b9 `ConvertRequestToBooking`). Every
> mutation runs behind a **Redis `lock(booking:{id}:payment)`** with the DB constraints as the authoritative
> backstop. This is the foundation refunds (b11), BNPL (b12), and payouts (b13) all post against — get the
> idempotency and the balanced posting exactly right and the rest of the money path is safe.
>
> **Track:** backend · **Depends on:** [b9](./backend-phase-9.md) (bookings + the three-amount split + `ConvertRequestToBooking`), [b1](./backend-phase-1.md) (typed cached `platform_configs`), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICacheService`, `IDateTimeProvider`, REST surface, audit interceptor) · **Unlocks:** refunds/invoices/clawbacks (b11), BNPL (b12), payouts (b13); frontend **f9-b10**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b10**, the inbound money rail. Until now the platform could create a booking but
never take a Rial: [b9](./backend-phase-9.md) built `bookings` carrying the frozen three-amount split
(`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`, with the
`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount` CHECK) plus `dispute_window_ends_at`, and
the `ConvertRequestToBooking` command that turns an `accepted_awaiting_payment` request into a money-bearing
booking **on capture** — that conversion is the hook this phase fires. This phase makes "the family pays the
gross price by card" real and lawful: Balinyaar is **merchant-of-record but never a cash custodian** (a
پرداخت‌یار may not hold deposits, run wallets, or move money between merchants), so "escrow" is modeled as an
**internal double-entry ledger STATE** over funds that legally sit at the licensed PSP/bank — never as
platform-held cash. The provider sits behind a **swappable seam** because Iranian provider cut-offs are real
(Toman/Jibit were abruptly suspended Nov 2024), and every callback is **idempotency-deduplicated before any
money state mutates** because PSP callbacks are at-least-once and retried.
**What already exists (do not rebuild) — built by prior phases:**
- **Bookings + the three-amount split + conversion** — [b9](./backend-phase-9.md) built `bookings`
(`gross_price_irr`, `balinyaar_commission_irr`, `platform_fee_rate`, `nurse_payout_amount`, the
`gross = commission + payout` CHECK, all amounts ≥ 0), the booking status machine
(`pending_payment``confirmed``in_progress``completed``disputed`/`closed`/`cancelled`),
`dispute_window_ends_at`, and the **`ConvertRequestToBooking`** command (creates the `bookings` row 1:1
from an `accepted_awaiting_payment` `booking_requests`, writes `variant_snapshot_json` + encrypted
`address_snapshot_json`, computes the three amounts). **This phase calls `ConvertRequestToBooking` on
successful capture — it does not re-implement booking creation or the amount math.** The CUT `payout_released`
BIT stays CUT — "paid" derives from the ledger + payout links, never a boolean.
- **Config (typed, cached)** — [b1](./backend-phase-1.md) built `platform_configs` + the **typed cached
config accessor**. Read `commission_rate`/`vat_rate`/`dispute_window_hours` and any gateway-selection
defaults **through that accessor** (cached), never hardcoded. (The amounts themselves are already frozen
on the booking by b9; this phase reads config only where it must, e.g. dispute-window seeding lives on the
booking already.)
- **Cross-cutting seams & plumbing** — [b0](./backend-phase-0.md) built the REST surface (`BaseController`,
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routing, rate limiting), CQRS via
**`martinothamar/Mediator`** (`ISender`/`ICommand`/`IQuery`, `internal sealed` handlers,
`OperationResult<T>` for expected failures), the audit-field SaveChanges interceptor, and the seams
**`IFieldEncryptor`** (encrypts `payment_gateways.config_json`), **`ICacheService`**, **`IDateTimeProvider`**
(stamps `created_at`/`received_at`/`processed_at`). Reuse all of these — do not redefine them.
- **The `IUnitOfWork`/`CommitAsync` pattern, FluentValidation `ValidateCommandBehavior`, Mapster, soft-delete
query filters, one `IEntityTypeConfiguration<T>` per entity** — established in b0/b1 and used by every phase
since. Mirror them exactly.
**What this phase introduces:** the four payments-core tables (`payment_gateways`, `payment_transactions`,
`payment_webhook_events`, `ledger_entries`) + their EF configs + **one migration**; the capabilities
`InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, `GetNursePayableBalance`; the four
money-path seams **`IPaymentProvider`**, **`ISettlementSplitProvider`**, **`IWebhookVerifier`**,
**`IDistributedLock`** (with faithful mocks); and the public/webhook REST surface. Refunds, clawbacks,
invoices (b11), BNPL settle (b12), and payouts (b13) are **(DEFERRED)** here — see §3.6 — but the ledger,
the idempotency store, and the six `account_type`s they all post against are built here.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially
*Performance/caching/money/idempotency*: **money is IRR `BIGINT`, no floats**; money-path writes are
**idempotent** (webhook dedup on the unique external-event key; filtered unique on succeeded transaction)
and **guarded by a Redis distributed lock with the DB constraint as the authoritative backstop**;
`ledger_entries` is **append-only and balanced** (Σdebit = Σcredit per `transaction_group_id`).
- [`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md) —
the inbound money path: card → PSP → Shaparak → registered IBANs; **escrow is a ledger state, not held
cash**; every callback idempotency-deduplicated before money moves; provider swappable by config.
- [`../../../product/payments/index.md`](../../../product/payments/index.md) and
[`../../../product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — **the canonical
ledger postings** (the six `account_type`s and the exact card-capture group: DEBIT `escrow_held` gross =
CREDIT `platform_revenue` commission + `nurse_payable` payout). Mirror the account names and posting
discipline **exactly**.
- [`../../../product/payments/iranian-payment-reality.md`](../../../product/payments/iranian-payment-reality.md) —
**why** the platform may not custody funds (§2.2 پرداخت‌یار custody prohibition), why تسهیم
(settlement-sharing) is the lawful split primitive (§2.3), why a held platform pool is **banned** (§2.4),
and why providers must be swappable (§2.5 Toman/Jibit cut-off). This is the legal shape your seams encode.
- [`../../../product/payments/integration-notes.md`](../../../product/payments/integration-notes.md) — the
per-provider verb sets and the server-side `verify` re-check rule (**never trust a callback alone**); the
"make it real" detail you record in the mock registry.
- [`../../../product/data-model/06-payments-ledger-and-refunds.md`](../../../product/data-model/06-payments-ledger-and-refunds.md) —
**the canonical schemas**: `payment_gateways`, `payment_transactions` (and its two **NEW** filtered uniques),
`payment_webhook_events` (field table + the `UNIQUE(provider_code, external_event_id)` idempotency key),
and `ledger_entries` (the field table, the `account_type` set, the canonical-postings table). Mirror field
names exactly. (`refunds`, `nurse_clawbacks`, `invoices` in this doc are **b11** — read for context only.)
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) — **IRR `BIGINT` serialized as a
string of digits** on the wire, the envelope, the `payment`/`refund_channel` enum codes, Toman is
display-only and converted **only inside a provider adapter at its boundary**.
- **Code to mirror:** b9's `Features/Booking/**` (the `ConvertRequestToBooking` command + the booking status
machine you call/transition), b9's amount-bearing `bookings` config; b1's typed config accessor; b0's seam
registration (`ServiceConfiguration/` extension, config-selected impls) and the `IFieldEncryptor` usage on
encrypted columns. Mirror their `Features/<Area>/{Commands|Queries}/<Name>/` layout, `IEntityTypeConfiguration<T>`,
and the `IUnitOfWork`/single-`CommitAsync` pattern.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-9.md`, `…-1.md`, `…-0.md`,
and `reports/mocks-registry.md` (the `IFieldEncryptor`/`ICacheService`/`IDateTimeProvider` rows you reuse,
and the `IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/`IDistributedLock` rows you flip to 🟡).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`**no floats anywhere**. The payments features live under
`Baya.Application/Features/Payments/{Commands|Queries}/<Name>/`; the entities in
`Baya.Domain/Entities/Payments/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/PaymentsConfig/`; the four seams in `Application/Contracts/` with their mock
implementations in Infrastructure, **DI-registered via a `ServiceConfiguration/` extension** (config-selected
so a real adapter swaps in later); **one EF migration** for the four tables and their indexes.
### 3.1 Entities + migration
**`payment_gateways`** [CORE] — config per connected PSP/BNPL provider; selection/failover.
- Fields: `id` BIGINT PK; `provider_code` NVARCHAR(50) (`zarinpal` / `sadad` / `vandar` / `jibit` …);
`type` NVARCHAR(20) — **`standard`** (card IPG) / **`bnpl`** — *selects the flow*; `display_name`;
**`config_json` NVARCHAR(MAX) — ENCRYPTED via `IFieldEncryptor`** (merchant id, terminal/IBAN
registration for the تسهیم split, base_url, sandbox flag — **provider-selection / failover config, NEVER
per-transaction credentials**); `is_active` BIT; `priority` INT (failover order); soft-delete + audit.
- **`config_json` is encrypted at rest and never logged in plaintext.** Selection is config-driven: pick the
active `standard` gateway by `priority` so a cut-off provider is swapped **by config, not code change**.
**`payment_transactions`** [CORE] — every payment attempt against a booking; the `succeeded` row triggers
confirmation; stores the full `gateway_response_json` and the **Shaparak `gateway_reference_code`** (definitive
proof for reconciliation/chargebacks).
- Fields (mirror [`product/data-model/06`](../../../product/data-model/06-payments-ledger-and-refunds.md)):
`id` BIGINT PK; `booking_id` BIGINT FK → `bookings`; `customer_id` BIGINT FK; `gateway_id` BIGINT FK →
`payment_gateways`; **`amount` BIGINT (IRR)**; `currency` NVARCHAR (always `IRR` internally);
`status` NVARCHAR(20) — `pending` / `succeeded` / `failed`; `gateway_transaction_id`;
**`gateway_reference_code`** NVARCHAR NULL; `gateway_response_code`; `gateway_response_json` NVARCHAR(MAX);
`is_installment` BIT; `ip_address`; `user_agent`; soft-delete + audit timestamps.
- **The two structural idempotency guards (NEW — do not drop):**
- **filtered `UNIQUE(gateway_reference_code) WHERE gateway_reference_code IS NOT NULL`** — Shaparak ref dedupe.
- **filtered `UNIQUE(booking_id) WHERE status = 'succeeded'`** — **at most one capturing transaction per
booking**; this is the authoritative anti-double-capture backstop.
- Secondary index on `(booking_id, status)` for the lookup in capture/initiate.
**`payment_webhook_events`** [CORE] — raw, deduplicated store of every PSP/BNPL callback; the **idempotency
chokepoint**.
- Fields: `id` BIGINT PK; **`provider_code` NVARCHAR(50)**; **`external_event_id` NVARCHAR(200)**;
`event_type` NVARCHAR(80); `signature_valid` BIT; `payload_json` NVARCHAR(MAX) (raw callback);
`processing_status` NVARCHAR(20) — `received` / `processed` / `failed` / `ignored`;
`related_payment_transaction_id` BIGINT NULL; `received_at`, `processed_at` DATETIME2.
- **`UNIQUE(provider_code, external_event_id)`** — the idempotency key. The handler **inserts/upserts here
first** and **no-ops on a duplicate**, inside the same transaction that mutates payment state (§3.3).
**`ledger_entries`** [CORE] — the append-only, double-entry financial **source of truth**. Every money event
posts **balanced** rows sharing a `transaction_group_id` (Σdebit = Σcredit per group).
- Fields (mirror [`product/data-model/06`](../../../product/data-model/06-payments-ledger-and-refunds.md)):
`id` BIGINT PK; **`transaction_group_id` UNIQUEIDENTIFIER** (groups the balanced legs of one event);
**`account_type` NVARCHAR(40)** — the closed set: **`escrow_held` / `platform_revenue` / `nurse_payable` /
`refund_payable` / `bnpl_fee_expense` / `nurse_clawback_receivable`** (define all six now even though this
phase only posts the first three — b11/b12/b13 post the rest; the data-model doc also lists `psp_fee_expense`/
`bad_debt`, include them if present in the canonical schema you mirror); `nurse_id` BIGINT FK NULL (set for
`nurse_payable` / `nurse_clawback_receivable`); **`direction` NVARCHAR(6)** — `debit` / `credit`;
**`amount_irr` BIGINT — always positive; `direction` carries the sign**; `booking_id` BIGINT FK NULL;
`source_ref_type` NVARCHAR(40) (`payment_transaction` / `refund` / `nurse_payout` / `bnpl_transaction` /
`clawback`); `source_ref_id` BIGINT; `memo` NVARCHAR(300) NULL; **`created_at` DATETIME2 — append-only,
never updated**.
- **No soft-delete, no audit-modified columns, no UPDATE/DELETE path** — `ledger_entries` is append-only;
corrections are **new balancing rows**. Do not configure a `ModifiedAt`/`IsDeleted` flow on this entity;
it is insert-only by design (mark the entity so the audit interceptor never stamps a modify on it).
- Indexes: `(account_type, nurse_id)` (for `GetNursePayableBalance` and later balance reads),
`transaction_group_id` (to read a posting group), `(source_ref_type, source_ref_id)`, `booking_id`.
> **Build-order rule (from the payments digest):** the **ledger + webhook idempotency** come first; the
> provider adapters plug into the seams only after that foundation exists. Get the table shapes and the two
> filtered uniques right before writing a single capture.
### 3.2 `InitiatePayment` (start a card attempt)
**`InitiatePaymentCommand(bookingId)`** [CORE] — creates a `pending` `payment_transactions` row against a
booking in `pending_payment`, selects the active `standard` gateway (by `payment_gateways.type='standard'`,
active, lowest `priority`), and calls the provider to start the IPG session.
- Validates the booking exists and is `pending_payment` (tenancy: the caller is the booking's customer); the
payment deadline (`payment_deadline_at` from the originating request, b8/b9) has **not** lapsed.
- Reads `amount` = the booking's `gross_price_irr` (already frozen by b9 — **never recompute it here**).
- Calls **`IPaymentProvider.InitPaymentAsync(bookingId, amountIrr, idempotencyKey, ct)`** → returns the
redirect URL + a deterministic `gatewayReferenceCode`; persists the `pending` `payment_transactions` row
(with `gateway_reference_code`, honouring the filtered unique) and returns the redirect/token to the client.
- Route: **`POST api/v1/bookings/{bookingId}/payments`** (authenticated; **rate-limited** as a money endpoint;
carries an **idempotency key**). Returns the redirect URL + the transaction id.
- Validator (FluentValidation): `bookingId` present; resolves to a `pending_payment` booking owned by the caller.
- **Idempotency:** a repeat InitiatePayment for a booking that already has a `succeeded` transaction returns a
`409` (the booking is already paid) — do not create a second attempt; the filtered `UNIQUE(booking_id) WHERE
status='succeeded'` is the backstop.
### 3.3 `HandlePaymentWebhook` (the idempotent callback ingest)
**`HandlePaymentWebhookCommand(provider, headers, rawBody)`** [CORE] — the verify-then-dedup-then-mutate path
for every inbound PSP callback.
- Route: **`POST api/v1/webhooks/payments/{provider}`** (no user auth — authenticated by **signature**;
rate-limited; tolerant of at-least-once retries by design).
- Steps, **all inside one DB transaction** (single `CommitAsync`):
1. **Verify** the callback via **`IWebhookVerifier.Verify(provider, headers, rawBody)`** →
`(signatureValid, externalEventId, eventType, parsedPayload)`. If the signature is invalid, store the
event with `signature_valid=0`, `processing_status='ignored'`, and stop (never mutate money on an
unverified callback).
2. **Upsert `payment_webhook_events` FIRST** keyed on **`(provider_code, external_event_id)`**. If the row
already exists (duplicate replay), **no-op**: mark/leave `processing_status` and return success **without
mutating any payment or ledger state**. This is the idempotency guarantee — a replayed `succeeded` must
never double-confirm and a replayed `settled` must never double-count.
3. On a **new** event whose `event_type` indicates success, **re-verify server-side** (the integration-notes
rule — never trust the callback alone): call **`IPaymentProvider.VerifyAsync(gatewayReferenceCode,
expectedAmountIrr, ct)`** to re-check the amount and reference against the stored `pending` transaction,
then dispatch **`ConfirmPaymentAndPostLedger`** (§3.4).
4. Set `processing_status='processed'`, `processed_at`, and `related_payment_transaction_id`.
- **The whole thing is wrapped in a Redis `lock(booking:{id}:payment)`** via **`IDistributedLock`** so a fast
double-callback and a user retry don't both start money mutation; the DB uniques are the authoritative
backstop if the lock is lost/expired or Redis is down.
### 3.4 `ConfirmPaymentAndPostLedger` (capture → ledger → convert booking)
**`ConfirmPaymentAndPostLedgerCommand(paymentTransactionId)`** [CORE] — flips the transaction to `succeeded`
under the filtered-unique guard, posts the **card-capture ledger group**, and triggers booking conversion.
- Steps (inside the same transaction/lock from §3.3):
1. Mark the `payment_transactions` row **`status='succeeded'`** — the filtered `UNIQUE(booking_id) WHERE
status='succeeded'` makes a second succeeded row impossible (a concurrent double-confirm fails on the
constraint, which the handler treats as "already captured → no-op success").
2. Post the **card-capture group** to `ledger_entries` under one fresh `transaction_group_id`, reading the
booking's three frozen amounts:
```
DEBIT escrow_held gross_price_irr
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount (nurse_id set; = gross balinyaar_commission)
```
**The group must balance: Σdebit (gross) = Σcredit (commission + payout).** `amount_irr` is positive on
every row; `direction` carries the sign. `source_ref_type='payment_transaction'`,
`source_ref_id=paymentTransactionId`, `booking_id` set, `created_at` from `IDateTimeProvider`.
3. **Register the تسهیم split** via **`ISettlementSplitProvider.RegisterSplitAsync(bookingId, legs, ct)`**
where `legs = [(nurseSheba, nurse_payout_amount, "nurse"), (platformSheba, balinyaar_commission_irr,
"platform")]` — the lawful split-by-ratio to registered IBANs (the provider credits each IBAN directly;
Balinyaar never moves the money). The mock accepts any legs whose sum = gross and returns `Settled`.
4. **Trigger `ConvertRequestToBooking`** (from [b9](./backend-phase-9.md)) — *or*, if the booking row was
already created at request-conversion time per b9's design, transition it `pending_payment → confirmed`.
Follow whichever b9 actually did; **do not duplicate the conversion/amount logic** — call b9's command.
- This command is **never a public endpoint** — it is dispatched only from `HandlePaymentWebhook` (and, in
tests, directly). The webhook is the only public confirm path.
### 3.5 `GetNursePayableBalance` (derived, never stored)
**`GetNursePayableBalanceQuery(nurseId)`** [CORE] — sums `ledger_entries WHERE account_type='nurse_payable'
AND nurse_id=@nurseId`, **signed by `direction`** (credit adds, debit subtracts), to the IRR `BIGINT` balance
currently owed the nurse. **Pure projection over the ledger** — `AsNoTracking()`, a single aggregate query,
**no cached wallet column ever**. This is what b13 (payouts) reads to know what to pay, so it must be the
ledger truth, not a status flag.
- Route: **`GET api/v1/nurses/{nurseId}/payable_balance`** (authorized: the nurse themself or admin).
- (Optionally also expose `GetEscrowHeldQuery` / `GetCommissionIncomeQuery` as the same shape over their
account types — thin admin reads; build `nurse_payable` now, the others are trivial siblings.)
### 3.6 DEFERRED (do not build; leave the account type / seam / pointer)
- **Refunds, clawbacks, invoices** — `refunds` (1:N, fee/payout decomposition, `refund_channel`),
`nurse_clawbacks`, the refund/clawback ledger postings, and `invoices` (VAT on commission) are owned by
**[b11](./backend-phase-11.md)**. This phase **defines the `refund_payable` and `nurse_clawback_receivable`
account types** in the ledger so b11 just posts against them, and exposes
**`IPaymentProvider.RefundAsync`** in the seam (mock returns `Succeeded`) so b11 can call it — but builds no
refund table or flow. (DEFERRED → b11.)
- **BNPL settle** — the `bnpl_transactions` table, the **BNPL-settle ledger group** (card-capture legs **plus**
DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` so escrow reflects net cash), and the `IBnplProvider` seam
are owned by **[b12](./backend-phase-12.md)**. This phase defines `bnpl_fee_expense` and routes BNPL callbacks
through the **same** `payment_webhook_events` idempotency store. (DEFERRED → b12.)
- **Payouts** — `nurse_payout_batches` / `nurse_payouts` / `nurse_payout_booking_links` and the payout ledger
movement (DEBIT `nurse_payable` / CREDIT `escrow_held`) are owned by **[b13](./backend-phase-13.md)**, gated
on the dispute window. `GetNursePayableBalance` (built here) is what it reads. (DEFERRED → b13.)
- **Real provider adapters** (ZarinPal/Sadad/Vandar/Jibit card + تسهیم; real signature/HMAC verification;
real Redis lock) — **mock now behind the seams**, recorded in the registry with the make-real steps. (DEFERRED.)
## 4. Mocks & seams in this phase
This phase **introduces** four money-path seams. Each is an **Application interface** with a faithful
Infrastructure **mock**, DI-registered via a `ServiceConfiguration/` extension (config-selected — **never an
`if (mock)` branch in a handler**). All amounts crossing these interfaces are **IRR `BIGINT`**; Toman
conversion happens **only inside the real adapter at the provider boundary**, never internally.
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IPaymentProvider`** | **introduced here** | `InitPaymentAsync` → **deterministic `gatewayReferenceCode`** + a fake redirect URL; `VerifyAsync` → **instant `Succeeded`, echoes the amount** (re-checks reference); `RefundAsync` → always `Succeeded` (for b11 to call). **No external call.** | **add a new row** (🟡) |
| **`ISettlementSplitProvider`** | **introduced here** (تسهیم) | `RegisterSplitAsync` → **accepts any legs whose sum = gross**, returns `Registered` then **instant `Settled`**; `GetSplitStatusAsync` → `Settled`. The platform never moves money — the mock just records the split intent. | **add a new row** (🟡) |
| **`IWebhookVerifier`** | **introduced here** | `Verify` → **`signatureValid=true`**, extracts a test `externalEventId` + `eventType` from the body. Lets tests replay duplicate webhooks to prove idempotency. | **add a new row** (🟡) |
| **`IDistributedLock`** | **introduced here** | **no-op / in-process** lock (a process-local semaphore keyed by the lock string) so the money-path code runs the same shape it will with real Redis. **The DB unique/state-machine is the authoritative backstop** — never rely on the lock alone. | **add a new row** (🟡) |
| `IFieldEncryptor` | reuse from **b0** | encrypts/decrypts `payment_gateways.config_json`; never logs plaintext. | reuse row |
| `ICacheService` | reuse from **b0** | typed config accessor (b1) reads `commission_rate`/`vat_rate` through it. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | stamps `created_at`/`received_at`/`processed_at` (deterministic in tests). | reuse row |
Append the four new rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real**): for `IPaymentProvider` —
ZarinPal/Sadad/Vandar/Jibit as acquirer-with-تسهیم, merchant id + terminal/IBAN registration, Shaparak
`gateway_reference_code`, persist the full gateway response, golden-tier eligibility; for
`ISettlementSplitProvider` — each beneficiary's registered Sheba, split-by-ratio config, min-amount caveat
(~100,000 IRR), provider credits IBANs directly; for `IWebhookVerifier` — per-provider HMAC/signature scheme
(or, where none exists, the mandatory server-side `verify` re-check of amount + reference); for
`IDistributedLock` — StackExchange.Redis with a lease/expiry, key conventions `booking:{id}:payment`. A
`IProviderRegistry`/config-driven factory selects the concrete provider per `payment_gateways.config_json` so a
cut-off provider is swapped without code change.
## 5. Critical rules you must not get wrong
- **Money is IRR `BIGINT`, no floats anywhere** — not in the DB, not in a handler, not on the wire. Toman
conversion happens **only inside a provider adapter at its boundary**; the seam interfaces and the ledger
speak IRR Rials only. Never introduce a `decimal`/`double` on the money path.
- **Idempotency: always upsert `payment_webhook_events` on `(provider_code, external_event_id)` FIRST and
no-op on duplicate** — inside the same DB transaction that mutates payment state — so a replayed
`succeeded` never double-confirms and a replayed `settled` never double-counts. This dedup is the single
chokepoint for every PSP/BNPL replay; do it before any money state changes.
- **Escrow IS the ledger** — never infer money state from status booleans or add money columns to "track" a
balance. `ledger_entries` is the single source of truth; every money event posts **balanced** rows; balances
are **derived by filter**, never stored in a drifting column. (The `payout_released` BIT stayed CUT in b9 for
exactly this reason.)
- **The card-capture posting is balanced:** **DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission
+ `nurse_payable` payout**, all under one `transaction_group_id`, `amount_irr` positive with `direction`
carrying the sign, **Σdebit = Σcredit**. The three amounts are never conflated and come **frozen from the
booking** (b9) — never recomputed here.
- **`ledger_entries` is append-only** — never `UPDATE` or `DELETE` a ledger row; corrections are **new
balancing rows**, never edits. Configure the entity so the audit interceptor never stamps a modify and
there is no soft-delete path.
- **The filtered `UNIQUE(booking_id) WHERE status='succeeded'` is the structural anti-double-capture guard —
do not drop it.** It (and the `UNIQUE(gateway_reference_code)`) is what makes a retried success webhook
unable to create a second capture even if the lock is lost. Treat a unique-violation on confirm as
"already captured → idempotent no-op success", not an error to surface.
- **The Redis lock is the fast first line; the DB constraint is the authoritative backstop.** Wrap
capture/verify in `lock(booking:{id}:payment)` via `IDistributedLock`, but **never rely on the lock alone**
for correctness — if Redis is down or the lease expires, the DB uniques must still prevent a double-capture.
- **Escrow is a ledger state, not platform cash — never model a held pool.** A پرداخت‌یار may not hold
deposits, run wallets, or move money between merchants. The lawful split is **تسهیم via
`ISettlementSplitProvider`** to registered IBANs (the provider credits each directly); the ledger only
**mirrors** money that legally sits at the provider/bank. Do not design "collect into a platform pool, hold
until EVV, redistribute" — it is banned.
- **Provider swappable by config.** Handlers depend on `IPaymentProvider`/`IWebhookVerifier`/
`ISettlementSplitProvider`, never on a concrete client; selection is by `payment_gateways` config. The
ledger must survive a provider cut-off mid-cycle (Toman/Jibit Nov-2024 precedent).
- **`payment_gateways.config_json` is encrypted and is provider-selection/failover config — never
per-transaction credentials**, and never logged in plaintext (`IFieldEncryptor`).
- **Never trust a callback alone** — on a success event, re-verify server-side via
`IPaymentProvider.VerifyAsync` (amount + reference) before confirming. An unverified-signature callback
mutates nothing.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The four tables (`payment_gateways`, `payment_transactions`, `payment_webhook_events`, `ledger_entries`)
exist via **one migration** with their `IEntityTypeConfiguration<T>`s: the **two filtered uniques** on
`payment_transactions` (`gateway_reference_code` WHERE NOT NULL; `booking_id` WHERE status='succeeded'),
the **`UNIQUE(provider_code, external_event_id)`** on `payment_webhook_events`, the six (or eight)
`account_type`s and the append-only (no soft-delete/no-modify) config on `ledger_entries`, and the
`config_json` encryption on `payment_gateways`.
- [ ] `InitiatePayment`, `HandlePaymentWebhook`, `ConfirmPaymentAndPostLedger`, and `GetNursePayableBalance`
are implemented per §3, behind the four seams, with FluentValidation on the input-bearing commands and
`AsNoTracking()` + `.Select(...)` projection on the balance query.
- [ ] The webhook handler **upserts `payment_webhook_events` first and no-ops on duplicate**, inside one
transaction wrapped in `IDistributedLock(booking:{id}:payment)`; the card-capture ledger group is
**balanced** (Σdebit = Σcredit) and triggers b9's `ConvertRequestToBooking`/`pending_payment→confirmed`.
- [ ] **`IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`** are
introduced as Application interfaces with Infrastructure mocks, **DI-registered via a
`ServiceConfiguration/` extension** (config-selected; no `if (mock)` in handlers).
- [ ] Handler/unit tests (NSubstitute): the card-capture group **balances** and posts the three correct legs;
a **replayed webhook event is a no-op** (no second confirm, no second ledger group); a **second
`succeeded` transaction for a booking is blocked** by the filtered unique; `GetNursePayableBalance`
equals the signed ledger sum; an **unverified-signature** callback mutates nothing. ≥1
`WebApplicationFactory` integration test for `POST api/v1/bookings/{id}/payments` (happy path, 401,
validation 400) and the webhook ingest (happy + duplicate-replay). `dotnet build Baya.sln` zero new
warnings; `dotnet test Baya.sln` green (a reachable SQL Server is required — the filtered uniques are
the test's whole point).
- [ ] The **Project map** in `server/CLAUDE.md` reflects the `Features/Payments/**` area, the four tables, and
the four new seams + where they're registered.
- [ ] The contract `dev/contracts/domains/payments.md` is written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from b9): one active **`standard`** `payment_gateways` row; a `bookings` row in
`pending_payment` for a known customer + nurse, with `gross_price_irr` = `balinyaar_commission_irr` +
`nurse_payout_amount` (e.g. gross `23300000`, commission `3495000`, payout `19805000` — adjust to your
config's commission rate). Configure the mock `IWebhookVerifier`/`IPaymentProvider`.
1. **Initiate a payment** — `POST api/v1/bookings/{bookingId}/payments` (as the customer) → `200` with a
redirect URL + a `pending` `payment_transactions` row carrying the mock's deterministic
`gateway_reference_code`. (No ledger rows yet, booking still `pending_payment`.)
2. **A webhook confirms it** — `POST api/v1/webhooks/payments/{provider}` with a `succeeded` event for that
reference → the transaction flips to `succeeded`; **one balanced ledger group** appears (DEBIT
`escrow_held` `23300000` = CREDIT `platform_revenue` `3495000` + `nurse_payable` `19805000`); the
**booking converts/confirms** (`pending_payment → confirmed`, b9). Verify Σdebit = Σcredit for the group.
3. **Replaying the same webhook event is a no-op** — POST the **same** `external_event_id` again → `200`, but
**no second confirm and no second ledger group** (the `payment_webhook_events` upsert short-circuits).
Query the ledger: still exactly one capture group; `payment_webhook_events` still one row.
4. **`GetNursePayableBalance` reflects the accrual** — `GET api/v1/nurses/{nurseId}/payable_balance` →
`19805000` (the credited `nurse_payable`, signed by direction). It is computed from the ledger, not a column.
5. **A second `succeeded` transaction for the same booking is blocked** — attempt to confirm a *different*
transaction for the same booking (or initiate again after capture) → blocked by the filtered
`UNIQUE(booking_id) WHERE status='succeeded'` (`409`/idempotent no-op), never a second capture.
6. **Unverified callback mutates nothing** — POST a webhook the mock verifier marks `signature_valid=false` →
stored with `processing_status='ignored'`, **no transaction flip, no ledger rows**.
7. **Encrypted gateway config** — inspect `payment_gateways.config_json` in the DB → ciphertext, not plaintext;
the active `standard` gateway is selected by `type` + `priority`.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Payments/**` area, the four payments-core tables, the **append-only `ledger_entries`** note, and
the four new seams + where they're registered). If you decide/confirm a rule the `product/` docs don't yet
capture (e.g. the exact "upsert webhook event first, then re-verify server-side, then confirm" ordering, or
treating a unique-violation on confirm as an idempotent no-op), record it in
[`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md)
or [`../../../product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — don't invent
rules. Note the new `IPaymentProvider`/`IWebhookVerifier`/`ISettlementSplitProvider`/`IDistributedLock`
pattern in `server/CONVENTIONS.md` if it establishes a reusable money-path shape (lock-then-DB-constraint).
- **Contract to write:** **`dev/contracts/domains/payments.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — document
`POST api/v1/bookings/{bookingId}/payments` (auth, **idempotency key**, rate-limited; request/redirect
response), `POST api/v1/webhooks/payments/{provider}` (signature auth, at-least-once/idempotent, the
`processing_status` enum), `GET api/v1/nurses/{nurseId}/payable_balance` (derived IRR `BIGINT` balance,
authorization); the `payment` status enum (`pending`/`succeeded`/`failed`), the `account_type` set, the
`gateway.type` enum (`standard`/`bnpl`); state that **money is IRR `BIGINT` serialized as a string of
digits**, that the **card-capture ledger group is balanced**, and that **internal account types are never
exposed to the customer** (the checkout UI shows gross + the commission/VAT breakdown only). Republish the
`swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is
what **f9-b10** consumes (Summary & pay (C6), card payment redirect, confirmation).
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-10.md` (the money
core is live — initiate → webhook confirm → balanced capture → booking confirm; what **f9** can now build —
checkout summary with commission/VAT/escrow notice (C6), card payment via the mock redirect, the
succeeded/confirmed state; which endpoints/contract are live; that the PSP/تسهیم/webhook-verify/lock are
**mocked behind seams**; that refunds (b11), BNPL settle (b12), and payouts (b13) post against this ledger
next). Append to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-10-report.md`
(what was built, **what is now testable and exactly how** per §7, what is mocked + how to make it real, the
`account_type`s reserved for b11b13, contracts produced, follow-ups), and update
`dev/shared-working-context/reports/mocks-registry.md` (the four new rows → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
**upsert-webhook-event-first-then-no-op** idempotency ordering; the **two filtered uniques** on
`payment_transactions` as the anti-double-capture backstop; the **balanced card-capture posting** (DEBIT
`escrow_held` gross = CREDIT `platform_revenue` + `nurse_payable`) and the six `account_type`s; the
**append-only, derive-balances-by-filter** ledger discipline; the **lock-first / DB-constraint-backstop**
pattern via `IDistributedLock`; and the four money-path seams (PSP / تسهیم / webhook-verify / lock,
mock-now/real-later) — with a one-line pointer in `MEMORY.md`.
+405
View File
@@ -0,0 +1,405 @@
# Backend Phase 11 — Refunds, invoices & nurse clawbacks
> **Mission:** make money flow *backwards* correctly. Build the admin-only refund engine that reverses a
> captured booking payment across **both fee legs** (platform commission vs nurse payout), posts the
> balanced reversal into the append-only ledger, and forks hard on one question — *has the nurse already
> been paid?* Pre-payout it is a clean `nurse_payable` reversal; post-payout it opens a first-class
> **`nurse_clawbacks`** receivable, because an Iranian IBAN transfer is effectively irreversible. Same
> phase adds the minimal **`invoices`** record (VAT on the commission line, sequential number, optional
> مودیان submission behind a seam). Refunds are admin-initiated, ticket-linked, channel-aware (card vs
> BNPL revert), and never customer self-service. After this phase, a cancellation can actually return
> money and the books stay balanced.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (ledger / transactions / webhook idempotency / capture), [b9](./backend-phase-9.md) (cancellation policies / bookings / dispute window), [b1](./backend-phase-1.md) (VAT config / typed config accessor) · **Unlocks:** payout clawback netting ([b13](./backend-phase-13.md)); frontend **f10-b11**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b11**, the reversal leg of the payments arc (b10 ledger/capture → **b11
refunds·invoices·clawbacks** → b12 BNPL → b13 payouts). The platform never custodies cash: "escrow" is an
internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and a booking's money already sits posted as `escrow_held` / `platform_revenue` / `nurse_payable` from
b10's capture. A refund un-does some or all of that. The hard problem is **timing**: if the nurse has not
yet been paid (the common case, because b13 gates payout on `dispute_window_ends_at`), the refund simply
reverses the `nurse_payable` accrual — nothing leaves Balinyaar. If the nurse *has* been paid, the money
is already gone to an irreversible bank transfer, so the refund becomes platform-funded and opens a
**clawback receivable** the next payout batch nets out. This phase also issues the minimal commission
**invoice** with config-driven VAT, because Iranian commission marketplaces owe VAT on *their commission*
(the Snapp/Tapsi precedent), not on the nurse's earnings.
**What this phase does *not* do:** it does **not** build the cancellation policy resolver or the
`CancelBooking` flow (that is b9 — this phase *consumes* the resolved policy snapshot); it does **not**
build the card/BNPL provider adapters (b10/b12 — this phase *calls* their refund/revert methods through
seams); it does **not** net or recover clawbacks into a payout (that is b13 — this phase only *opens* the
receivable + posts its ledger leg).
**What already exists (do not rebuild) — built by prior phases:**
- **The ledger, transactions & webhook idempotency** — [b10](./backend-phase-10.md) built
`ledger_entries` (append-only, balanced, `transaction_group_id`, the account types incl.
`escrow_held`, `platform_revenue`, `nurse_payable`, `refund_payable`, `nurse_clawback_receivable`),
`payment_transactions` (the `succeeded` capturing row, filtered `UNIQUE(booking_id) WHERE
status='succeeded'`, `UNIQUE(gateway_reference_code)`), `payment_webhook_events`
(`UNIQUE(provider_code, external_event_id)`), the **card-capture posting** (`DEBIT escrow_held` gross /
`CREDIT platform_revenue` commission + `nurse_payable` payout), the **ledger posting helper**, the
`IPaymentProvider` seam (incl. `RefundAsync`), the `IWebhookVerifier` seam, and the `IDistributedLock`
Redis-lock pattern on the money path. **Reuse the ledger posting helper, the webhook idempotency path,
the `IPaymentProvider` seam, and the lock — do not re-implement them.**
- **Bookings, cancellation policies & the dispute window** — [b9](./backend-phase-9.md) built `bookings`
(the three-amount split `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`,
`platform_fee_rate` snapshot, `dispute_window_ends_at`), `booking_sessions` (`visit_payout_amount`,
`payout_eligible_at`, `cancellation_event_id`), `cancellation_policies` (config-driven tiers by lead
time × actor, `code`, `is_active`), and the `CancelBooking` / `CancelSession` commands that **resolve
the applicable policy and snapshot `cancellation_policy_code` + the resolved refund percentage** onto
the cancellation event. **This phase reads that resolved policy snapshot to populate the refund's
`cancellation_policy_code` / `refund_percentage_applied`; it does not re-resolve policy from live
config.**
- **VAT config & the typed config accessor** — [b1](./backend-phase-1.md)'s `platform_configs` table with
a typed, cached accessor (behind `ICacheService`). The `vat_rate` key (default `0.10`) and any
refund-ETA config are read **through that accessor**, never hardcoded. b1 also built `notifications` +
the `INotificationDispatcher` real in-app write, and `support_alerts`.
- **The b0 foundation:** the REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`** (`ISender`/`ICommand`/`IQuery`, `internal sealed` handlers),
`IFieldEncryptor`, `ICurrentUser` + audit interceptor, rate limiting, `IDateTimeProvider`,
`IObjectStorage` (for the invoice PDF key, optional), and the mock-report discipline.
**What this phase introduces:** the three tables (`refunds`, `nurse_clawbacks`, `invoices`), the
refund/clawback/invoice capabilities, and **one new seam — `IMoadianClient`** (the mocked سامانه مودیان
e-invoicing rail). The BNPL revert path *targets* the `IBnplProvider.RevertAsync` seam introduced in
**b12**; until b12 lands, the `bnpl_revert` channel is exercised through the same ledger legs with the
BNPL provider call behind its seam (see §3.6 + §4).
> **Forward dependency (tickets):** refunds **must** link a `ticket_id`, but the `tickets` table arrives
> in [b15](./backend-phase-15.md). Make `refunds.ticket_id` a **nullable FK now** with the column +
> index in place, enforce "ticket required" as a **validator/handler rule that is config-gated off until
> b15** (so admin refunds are testable today), and note the forward-dep in the report. b15 wires the
> real FK target and flips the rule on. **Do not invent a `tickets` table in this phase.**
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
ledger, idempotent money writes, Redis lock on the money path, config read through the typed accessor).
- [`product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md) —
**the business rules**: tiered/snapshotted policy, **admin-only + ticket-linked** refunds, fee-leg
decomposition, per-session (un-started only), pre- vs post-payout fork, BNPL-via-provider-revert-only,
MVP vs DEFERRED (automated nurse-no-show penalty is a manual admin action; self-service partial-refund
UI and holiday overrides are DEFERRED).
- [`product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) —
**Q1 the BNPL refund unwind**: money *always* flows `customer ↔ provider ↔ Balinyaar`, never direct;
`revert` (full) vs `update` (partial, strictly-lower amount); the async **710 business-day** customer
window surfaced as `expected_customer_refund_eta`; `refund_status = processing` until reconciled; the
**nullable** `provider_commission_reversed_amount` (do not hardcode whether the provider returns its
commission); the same fee-leg decomposition applies.
- [`product/data-model/06-payments-ledger-and-refunds.md`](../../../product/data-model/06-payments-ledger-and-refunds.md) —
**the canonical schema** for `refunds` (the new 1:N cardinality, `platform_fee_refunded_irr` /
`nurse_payout_refunded_irr`, `refund_channel`, `external_revert_reference`,
`expected_customer_refund_eta`, `cancellation_policy_code` / `refund_percentage_applied`),
`nurse_clawbacks` (`status`, `original_payout_id`, `recovered_in_payout_id`), `invoices`
(`invoice_number` UNIQUE, `platform_commission_irr` the VAT-relevant line, `vat_rate`/`vat_irr`,
`moadian_reference_number`/`moadian_status`), and the **canonical postings** table. Mirror these field
names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **refund and
clawback postings** in depth (pre-payout reversal; the `refund_payable``escrow_held` confirm step;
the clawback receivable leg).
- **Code to mirror:** b10's ledger posting helper + `IDistributedLock` usage + `payment_webhook_events`
idempotency + the `IPaymentProvider`/`IWebhookVerifier` seams + the `Features/Payments/**` command
structure; b9's `bookings`/`cancellation_policies` configs and the policy-snapshot fields; b1's typed
config accessor and `INotificationDispatcher`; b0's `IFieldEncryptor`/`IObjectStorage` + seam
registration via `ServiceConfiguration/` extensions.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, money as a
digit-string on the wire, the `refund_channel` enum, masking, the envelope).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md`, `…-9.md`,
`…-1.md`, and `reports/mocks-registry.md` (seam rows you reuse / the one you add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT` — no floats anywhere. Features live under
`Baya.Application/Features/Refunds/{Commands|Queries}/<Name>/` (refunds + clawbacks) and
`Baya.Application/Features/Invoices/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Refunds/` and `…/Invoices/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/RefundsConfig/` and `…/InvoicesConfig/`; one EF migration for the three tables.
### 3.1 Entities + migration
**`refunds`** [CORE] — admin-initiated, ticket-linked, **1:N per `payment_transaction`**, fee-leg
decomposed, channel-aware.
- Fields (baseline + new, mirror [data-model 06](../../../product/data-model/06-payments-ledger-and-refunds.md)):
`id`, `payment_transaction_id` (FK `payment_transactions`), `booking_id` (FK `bookings`),
`requested_by_customer_id` (FK `customer_profiles` — the customer the refund is *for*, not the actor),
`ticket_id` (**FK NULLABLE — forward-dep on `tickets` in b15**, see §1 callout), `amount` (BIGINT, the
total refunded = fee leg + payout leg), `refund_percentage` (resolved %), `reason_category`,
`reason_notes`, `status`, approval/rejection fields (`approved_by_admin_id`, `rejected_reason`),
`gateway_refund_reference` (the PSP card-refund ref), `processed_at` (nullable), `admin_notes`, audit
fields; **plus the new decomposition/channel fields:**
- `platform_fee_refunded_irr` (BIGINT) — the portion of `balinyaar_commission_irr` being reversed.
- `nurse_payout_refunded_irr` (BIGINT) — the portion of `nurse_payout_amount` being reversed (drives a
clawback if the nurse was already paid).
- `refund_channel` (enum) — `psp_card` | `bnpl_revert` | `manual` (the data-model also writes
`manual_bank`; **use `manual` as the canonical wire code** per
[`money-and-types.md`](../../contracts/conventions/money-and-types.md), and document the mapping).
- `external_revert_reference` (NVARCHAR(200) NULL) — the BNPL provider revert id.
- `expected_customer_refund_eta` (DATE NULL) — the ~710 business-day BNPL window, surfaced in
UI/reconciliation; null for instant card refunds.
- `cancellation_policy_code` (NVARCHAR NULL) + `refund_percentage_applied` (DECIMAL NULL) — **snapshot**
of the policy that produced this refund (read from b9's cancellation event; never re-resolved live).
- **`refund_status`** enum (`status`): `requested` | `approved` | `processing` | `succeeded` | `failed` |
`rejected`. (`processing` is the state a BNPL revert sits in until the reconciliation job confirms the
customer cash-back.)
- **Cardinality / invariant:** **1:N** per `payment_transaction`. The app invariant
**`Σ refunded ≤ captured`** is enforced **in the handler** (sum of prior succeeded/processing refund
`amount` for the transaction + this one ≤ the captured `payment_transactions.amount`) — it is *not* a
single-row DB CHECK. Likewise `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr` (handler
invariant + a CHECK where SQL Server allows).
- Relations: N:1 → `payment_transactions`, `bookings`, `customer_profiles`, `tickets` (nullable); 1:1 →
`nurse_clawbacks` (only when refunding a booking whose nurse was already paid).
**`nurse_clawbacks`** [CORE] — first-class receivable when a booking is refunded/disputed **after** the
nurse was already paid.
- Fields (mirror [data-model 06](../../../product/data-model/06-payments-ledger-and-refunds.md)): `id`,
`nurse_id` (FK `nurse_profiles`), `booking_id` (FK `bookings`), `refund_id` (FK `refunds`),
`original_payout_id` (FK `nurse_payouts` **NULL**`nurse_payouts` arrives in b13, so this FK is
nullable now and the column/index are in place; the *value* is set once b13 exists),
`amount_irr` (BIGINT — equals the `nurse_payout_refunded_irr` leg), `status`, `recovered_in_payout_id`
(FK `nurse_payouts` **NULL** — set by **b13** when a batch nets it; this phase only ever leaves it
null/`pending`), `created_at`, `resolved_at` (nullable), audit fields.
- **`clawback_status`** enum (`status`): `pending` | `recovered` | `written_off`. **This phase only ever
creates rows in `pending`** (and supports an admin `write_off`); **`recovered` is set by b13's payout
netting — do not implement recovery here.**
- Relations: N:1 → `nurse_profiles`, `bookings`; 1:1 → `refunds`; → `nurse_payouts` (original +
recovering, both nullable until b13).
**`invoices`** [MVP] — minimal official receipt per booking; **VAT on the commission line only**.
- Fields (mirror [data-model 06](../../../product/data-model/06-payments-ledger-and-refunds.md)): `id`,
`booking_id` (FK `bookings`), `invoice_number` (NVARCHAR(40) **UNIQUE** — official, **sequential**),
`issuing_entity_type` (`platform` | `partner_center`), `gross_irr` (BIGINT),
`platform_commission_irr` (BIGINT — **the VAT-relevant line**), `bnpl_commission_irr` (BIGINT NULL),
`vat_rate` (DECIMAL(5,4) — read from config, default `0.10`), `vat_irr` (BIGINT — computed
`round(platform_commission_irr * vat_rate)`, integer-only), `moadian_reference_number` (NVARCHAR(40)
NULL — the 22-digit سامانه مودیان ref when issued), `moadian_status` (NVARCHAR(20) NULL —
`pending` | `submitted` | `registered` | `failed`), `pdf_storage_key` (NVARCHAR(512) NULL — an
`IObjectStorage` key), `issued_at` (DATETIME2), audit fields.
- **`invoice_number` is UNIQUE and sequential** — generate it from a gap-free, concurrency-safe sequence
(a dedicated DB sequence / a locked counter row), **never** a random or timestamp-derived value. Relate
1:1 → `bookings`; N:1 → `partner_centers` (when the issuer is a partner center).
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route (admin/customer) | What it does |
| --- | --- | --- | --- |
| **`CreateRefundCommand`** | Command | `POST api/v1/admin_refunds` | Admin-only. Validates the booking has a captured (`succeeded`) `payment_transaction`; **requires a `ticket_id`** (config-gated off until b15, see §1); reads the **resolved cancellation policy snapshot** from b9's cancellation event for the booking to populate `cancellation_policy_code` / `refund_percentage_applied`; **decomposes** the refund into `platform_fee_refunded_irr` + `nurse_payout_refunded_irr` (pro-rata of the booking's `balinyaar_commission_irr` / `nurse_payout_amount` at the resolved %, or admin-supplied legs that must still sum to `amount`); enforces **`Σ refunded ≤ captured`**; picks `refund_channel` from the original payment type (`psp_card` for card, `bnpl_revert` for BNPL, `manual` for an out-of-band bank refund); creates the `refunds` row in `requested`/`approved`. Under `lock(booking:{id}:refund)`. Then dispatches the channel execution + ledger posting (below). |
| **`ExecuteRefundChannelCommand`** | Command (internal step) | — | Calls the channel: **card**`IPaymentProvider.RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct)` (channel `psp_card`), storing `gateway_refund_reference`, status → `succeeded` (card refunds are effectively immediate, `expected_customer_refund_eta = null`); **BNPL**`IBnplProvider.RevertAsync(...)` (b12 seam; **full** = `revert`, **partial/shortened** = `update` with a strictly-lower amount), storing `external_revert_reference`, setting `expected_customer_refund_eta = now + config(bnpl_refund_eta_business_days, 10)` (business-day shifted), status stays **`processing`** until the reconciliation job/webhook confirms cash-back; **manual** → records the admin-entered bank ref, status `processing`/`succeeded` per admin. **Carries an `idempotencyKey`** so a retried call never double-refunds. |
| **`PostRefundLedgerCommand`** | Command (internal step) | — | **Pre-payout path** (nurse not yet paid): posts the balanced reversal in one `transaction_group_id``DEBIT platform_revenue platform_fee_refunded_irr` + `DEBIT nurse_payable nurse_payout_refunded_irr`, `CREDIT refund_payable (sum)`. When the provider confirms the customer cash-back (card immediately; BNPL via reconciliation), a **second** balanced posting **clears `refund_payable` ↔ `escrow_held`** (`DEBIT refund_payable` / `CREDIT escrow_held`). Uses b10's posting helper; append-only; Σdebit = Σcredit. |
| **`CreateClawbackCommand`** | Command (internal step) | — | **Post-payout path** (nurse already paid — detected via b13's `nurse_payout_booking_links` for the booking, or, until b13 exists, a config/flag indicating the booking was paid): instead of debiting `nurse_payable`, posts `DEBIT nurse_clawback_receivable nurse_payout_refunded_irr` (+ the `DEBIT platform_revenue` fee leg) / `CREDIT refund_payable`, and **creates a `nurse_clawbacks` row in `pending`** (`nurse_id`, `booking_id`, `refund_id`, `amount_irr = nurse_payout_refunded_irr`, `original_payout_id` when available). Raises a `support_alert` (b1) on every clawback. **Does not net or recover it — that is b13.** |
| **`WriteOffClawbackCommand`** | Command | `POST api/v1/admin_clawbacks/{id}/write_off` | Admin marks a `pending` clawback `written_off` (uncollectable) with a reason; posts the balancing ledger correction (`DEBIT bad_debt` / `CREDIT nurse_clawback_receivable`) and sets `resolved_at`. (Recovery via payout netting is b13.) |
| **`IssueInvoiceCommand`** | Command | `POST api/v1/admin_invoices` (and reused on confirmation) | Creates an `invoices` row for a booking: **sequential `invoice_number`** from the safe sequence; copies `gross_irr` / `platform_commission_irr` / `bnpl_commission_irr` from the booking; reads **`vat_rate` from config** (default `0.10`); computes `vat_irr = round(platform_commission_irr * vat_rate)` (integer-only, VAT **on the commission only**, never the nurse's earnings — set `vat_irr = 0` when a medical-service exemption sets `vat_rate = 0`); attempts **`IMoadianClient.SubmitAsync`** which (mock) returns no ref → `moadian_reference_number = null`, `moadian_status = pending`. Idempotent per booking (one issued invoice per booking; re-issue returns the existing). |
| **`ListRefundsQuery`** | Query | `GET api/v1/admin_refunds?booking_id=&status=&page=&page_size=` | Admin refund worklist: projected (AsNoTracking + `.Select`) + paginated; surfaces channel, decomposed legs, status, `expected_customer_refund_eta`, the policy snapshot. |
| **`GetRefundStatusQuery`** | Query | `GET api/v1/refunds/{id}/status` (customer-visible, tenancy-scoped) | The customer-facing status of *their* refund: `status`, `refund_channel`, `amount`, and **`expected_customer_refund_eta`** (the BNPL 710-business-day window) — so f10 can show "on its way, ~N days". Tenancy-scoped to the booking's customer via `ICurrentUser`. |
| **`GetInvoiceQuery`** | Query | `GET api/v1/invoices/{booking_id}` (customer/admin) | The booking's invoice: `invoice_number`, `gross_irr`, `platform_commission_irr`, `vat_rate`, `vat_irr`, `moadian_status`, and a `pdf_storage_key`-derived download URL when present. Tenancy-scoped. |
- **Cancellation integration (b9 → refund):** b9's `CancelBooking` / `CancelSession` resolves the policy
and computes the refundable amount per un-started session. **This phase exposes `CreateRefundCommand` as
the money-side of that flow** — b9 (or admin) calls it with the booking/session, the resolved %, and the
ticket. Do **not** duplicate the policy resolver; consume its snapshot.
- **Controllers:** `AdminRefundsController` (admin policy; refund endpoints **rate-limited**
refund-sensitive per `CONVENTIONS.md` §11), `AdminClawbacksController` (admin policy),
`AdminInvoicesController` (admin policy), and a customer-facing `RefundsController` / `InvoicesController`
(authenticated, tenancy-scoped) for `GetRefundStatusQuery` / `GetInvoiceQuery`. All `sealed :
BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case `[controller]` /
`[action]` routes, `CancellationToken` threaded.
- **Validators:** FluentValidation on `CreateRefundCommand` (positive `amount`; legs sum to `amount`;
`amount > 0`; `ticket_id` required when the gate is on; channel matches the transaction type) and the
id-bearing commands.
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **Clawback *recovery / netting* into a payout** — DEFERRED to [b13](./backend-phase-13.md). This phase
only opens the `pending` receivable + supports `write_off`. Leave `recovered_in_payout_id` /
`original_payout_id` as the (nullable) join points b13 fills.
- **Automated nurse no-show penalty / forfeiture** — a **manual admin action** at launch per
[`product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md) §(c);
do not automate. The admin uses `CreateRefundCommand` (full customer refund) and records the nurse
penalty manually.
- **Self-service partial-refund UI** and **holiday-specific cancellation overrides** — DEFERRED (no
customer refund-initiation path; the policy override model is out of scope).
- **Real مودیان automation** — DEFERRED; the seam returns a null/pending ref now (see §4). The
reconciliation job that flips `moadian_status` to `registered` and the BNPL-revert reconciliation job
that clears `refund_payable ↔ escrow_held` are **thin/manual-trigger** now; note the cron in the report.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IMoadianClient`** | **introduced here** | `SubmitAsync(InvoiceSubmission, ct)` → leaves `moadian_reference_number = null`, returns `moadian_status = pending` (no external call). A config switch can force a deterministic `registered` (with a fake 22-digit ref) so the reconciliation/`registered` path is testable. The real سامانه مودیان adapter is a drop-in. | **add a new row** (🟡) |
| `IPaymentProvider` | reuse from **b10** | `RefundAsync(gatewayReferenceCode, amountIrr, idempotencyKey, ct)` → deterministic `gateway_refund_reference`, instant `Succeeded`, echoes amount; channel `psp_card`. | reuse row |
| `IBnplProvider` | reuse from **b12** | `RevertAsync` / `UpdateAsync` → echoes the reverted/new amount, returns an `external_revert_reference` + nullable `provider_commission_reversed_amount`, `settledAt`-style lag; channel `bnpl_revert`. **Until b12 lands**, register a thin local mock behind this interface so the `bnpl_revert` path is exercised; b12 owns the real seam definition. | reuse row (note pre-b12) |
| `IWebhookVerifier` | reuse from **b10** | verifies the async BNPL cash-back/reconciliation callback that flips a `processing` refund to `succeeded` and posts the `refund_payable ↔ escrow_held` clearing leg. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(booking:{id}:refund)` around the whole refund money-path so a cancellation-driven and a webhook-driven refund can't both fire (keeps `Σ refunded ≤ captured`). | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; never logs plaintext. | reuse row |
| `INotificationDispatcher` | reuse from **b1** | in-app write; notifies the customer on refund issued/completed; raises a `support_alert` on every clawback. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (`vat_rate`, ETA config). | reuse row |
| `IObjectStorage` | reuse from **b0** | local-disk/in-memory; stores the optional invoice `pdf_storage_key`. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
provider/مودیان selection is **config-driven, never** an `if (mock)` branch in a handler. Append the
`IMoadianClient` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — سامانه مودیان enrollment,
the معاملات/invoice submission API, the 22-digit reference shape, the `pending → submitted → registered`
reconciliation callback). Confirm the BNPL `IBnplProvider` row notes the pre-b12 local stub if you add one.
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`amount`, `platform_fee_refunded_irr`,
`nurse_payout_refunded_irr`, `amount_irr`, `gross_irr`, `platform_commission_irr`, `vat_irr`) is
`long`/`BIGINT`. VAT is `round(platform_commission_irr * vat_rate)` computed integer-only; **no float
path**. Toman conversion happens only inside a provider adapter at its boundary.
- **Gross = commission + payout.** A refund **decomposes across both fee legs**
`amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`, derived pro-rata from the booking's
`balinyaar_commission_irr` / `nurse_payout_amount` at the resolved %. The two legs are never conflated
and must always sum to the refunded `amount`.
- **`Σ refunded ≤ captured` (handler invariant).** Refunds are **1:N** per `payment_transaction`; the sum
of all succeeded/processing refunds for a transaction may never exceed the captured amount. Enforce in
the handler under `lock(booking:{id}:refund)`; the lock is the fast first line, the summed check is the
authoritative backstop.
- **Append-only, balanced ledger.** Every refund/clawback posts balanced legs (Σdebit = Σcredit) under one
`transaction_group_id`, via b10's helper. **Never UPDATE/DELETE a ledger row;** corrections (e.g. a
write-off) are *new* balancing postings. Balances are derived by filtering `ledger_entries`, never a
stored column.
- **Refund-before-payout is a clean reversal; refund-after-payout drives a `nurse_clawbacks` receivable.**
Pre-payout: `DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable`. Post-payout:
`DEBIT nurse_clawback_receivable` (+ `DEBIT platform_revenue`) / `CREDIT refund_payable` **and** a
`pending` `nurse_clawbacks` row — **because an Iranian IBAN transfer is irreversible**, so the money is
already gone and must be recorded as owed-back, never silently absorbed. **Gate payout on
`dispute_window_ends_at`** (b9/b13) so the pre-payout path is the common one; the clawback is the
fallback, not the plan.
- **Refunds are admin-only (no customer self-service) and must link a `ticket_id`.** There is no
customer refund-initiation path — only `GetRefundStatusQuery` is customer-visible. The `ticket_id`
requirement is enforced (config-gated until b15 ships `tickets`); the FK is nullable now only for that
forward-dep.
- **VAT applies to the platform COMMISSION only — never the nurse's earnings.** `vat_irr` is computed on
`platform_commission_irr` with a **config-driven rate (default `0.10`)**; the nurse is the taxable
seller of the care service (Snapp/Tapsi precedent). A `vat_rate = 0` exemption sets `vat_irr = 0`.
Never apply VAT to `nurse_payout_amount`.
- **`invoice_number` is unique + sequential.** Generate gap-free from a concurrency-safe sequence/locked
counter — never random or timestamp-derived. One issued invoice per booking (idempotent).
- **Card refund and `bnpl_revert` post the SAME ledger legs.** The only differences are `refund_channel`,
the external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA
(`expected_customer_refund_eta` null for card vs ~710 business days for BNPL, with `status =
processing` until reconciled). Do not branch the *ledger* on channel — only the execution + metadata.
- **BNPL refunds go through the provider revert API only.** Money *always* flows
`customer ↔ provider ↔ Balinyaar`**never** nurse→customer or Balinyaar→customer direct. Full =
`revert`, partial/shortened = `update` (strictly-lower amount). The provider's own commission reversal
is `provider_commission_reversed_amount`**nullable, reconciled from the response, never hardcoded.**
- **Idempotency on the money path.** Channel calls carry an `idempotencyKey`; the async cash-back
confirmation flows through `payment_webhook_events` (`UNIQUE(provider_code, external_event_id)`) so a
replayed "refunded"/"reverted" callback can't double-clear `refund_payable` or double-post. The refund
`status` state machine (`requested → approved → processing → succeeded|failed`) is forward-only.
- **Tenancy & scope.** `GetRefundStatusQuery` / `GetInvoiceQuery` are scoped to the booking's customer via
`ICurrentUser`; a customer can never read another customer's refund/invoice. All create/write endpoints
sit behind the **admin** policy and are rate-limited.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The three tables (`refunds`, `nurse_clawbacks`, `invoices`) exist via one migration, each with its
`IEntityTypeConfiguration<T>`: `refunds` with the **nullable `ticket_id` FK**, the decomposition
columns, `refund_channel`, the `amount = fee_leg + payout_leg` CHECK where possible; `nurse_clawbacks`
with nullable `original_payout_id` / `recovered_in_payout_id`; `invoices` with **`invoice_number`
UNIQUE** + the sequential generator and `vat_irr` on the commission line; soft-delete/audit wiring.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with the admin + customer controllers.
- [ ] **`IMoadianClient`** introduced (Application interface, Infrastructure mock, DI registration via a
`ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. `IBnplProvider`
reused (with a noted pre-b12 local stub if b12 isn't merged), `IPaymentProvider`/`IWebhookVerifier`/
`IDistributedLock`/`INotificationDispatcher` reused.
- [ ] Refund decomposition + `Σ refunded ≤ captured` correct; the **pre-payout reversal** and the
**post-payout clawback** both post balanced ledger groups; the `refund_payable ↔ escrow_held`
clearing posts on confirm; the invoice computes `vat_irr` from config on the commission with a
sequential number.
- [ ] Handler unit tests (NSubstitute) for: pre-payout balanced reversal; partial-refund leg decomposition
+ `Σ refunded ≤ captured` rejection; post-payout clawback creation + receivable leg; invoice VAT
computed from config + sequential numbering; channel parity (card vs bnpl_revert same legs). ≥1
`WebApplicationFactory` integration test per controller (happy path, 401, validation 400, 409 on
over-refund). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Refunds/**` + `…/Invoices/**` areas are reflected in the **Project
map** in `server/CLAUDE.md`; the `IMoadianClient` seam noted where seams are documented; the
`tickets` forward-dep and the `manual`/`manual_bank` channel-code decision recorded.
- [ ] The contract `dev/contracts/domains/refunds-invoices.md` written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a booking with a **captured card payment** (b10) and a resolved
cancellation policy snapshot (b9), plus one booking flagged as **already-paid-to-nurse** and one **BNPL**
booking.
1. **Pre-payout full refund (clean reversal)**`POST api/v1/admin_refunds` for the captured card
booking with the resolved % and a ticket → a `refunds` row with `refund_channel = psp_card`, decomposed
`platform_fee_refunded_irr` + `nurse_payout_refunded_irr` summing to `amount`; the **ledger** shows a
balanced `DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable`, and on confirm a
`DEBIT refund_payable` / `CREDIT escrow_held` clearing leg (Σdebit = Σcredit); status → `succeeded`.
2. **Partial refund + over-refund guard** — issue a **partial** refund (e.g. 50%): legs decompose
correctly and sum to the partial `amount`; `Σ refunded` for the transaction stays ≤ captured. Then
attempt a second refund that would push the total **over** the captured amount → rejected with `409`
(or validation `400`); no ledger posting occurs.
3. **Issue an invoice**`POST api/v1/admin_invoices` for the booking → an `invoices` row with a
**sequential `invoice_number`**, `vat_irr = round(platform_commission_irr * 0.10)` (verify it is
computed from config on the **commission**, not the nurse payout, and `vat_irr = 0` when `vat_rate` is
set to 0); `moadian_reference_number = null`, `moadian_status = pending`. Issue a second invoice for a
second booking → the number is the **next** in sequence (gap-free, unique).
4. **Refund on an already-paid booking (clawback)**`POST api/v1/admin_refunds` for the
already-paid-to-nurse booking → instead of debiting `nurse_payable`, the ledger posts `DEBIT
nurse_clawback_receivable` (+ `DEBIT platform_revenue`) / `CREDIT refund_payable`, **a `nurse_clawbacks`
row is created in `pending`** (`amount_irr = nurse_payout_refunded_irr`), and a `support_alert` is
raised. Confirm it is **not** auto-recovered (recovery is b13).
5. **BNPL revert (channel parity + ETA)**`POST api/v1/admin_refunds` for the BNPL booking →
`refund_channel = bnpl_revert`, `IBnplProvider.RevertAsync` called, `external_revert_reference` stored,
`expected_customer_refund_eta` ≈ now + 10 business days, status `processing`; the **ledger legs are
identical** to the card case. `GET api/v1/refunds/{id}/status` as the customer shows the ETA window.
6. **Write-off**`POST api/v1/admin_clawbacks/{id}/write_off` → the `pending` clawback → `written_off`
with a balancing `DEBIT bad_debt` / `CREDIT nurse_clawback_receivable` and `resolved_at` set.
7. **Admin worklist + tenancy**`GET api/v1/admin_refunds?status=processing` lists channel/legs/ETA;
`GET api/v1/refunds/{id}/status` as a **different** customer is **not** visible (403/404).
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Refunds/**` + `Features/Invoices/**` areas + the `IMoadianClient` seam). If you discover/confirm
a rule the product docs don't capture — e.g. the canonical `manual` vs `manual_bank` channel code, the
`bnpl_refund_eta_business_days` default, the `vat_rate = 0` exemption behaviour, or the
`ticket_id`-config-gate until b15 — record it in
[`product/business/07-cancellation-and-refunds.md`](../../../product/business/07-cancellation-and-refunds.md)
/ [`product/data-model/06-payments-ledger-and-refunds.md`](../../../product/data-model/06-payments-ledger-and-refunds.md)
(and regenerate the HTML view per `product/CLAUDE.md`). **Don't invent rules.**
- **Contract to write:** **`dev/contracts/domains/refunds-invoices.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the admin refund/
clawback/invoice endpoints (create refund, write-off clawback, issue invoice, list refunds) and the
customer-facing `refunds/{id}/status` + `invoices/{booking_id}`; the `refund_status` /
`refund_channel` / `clawback_status` / `moadian_status` enums; the refund/invoice DTO shapes (IRR
`BIGINT` as digit-strings, the decomposed legs, **masked** references, `expected_customer_refund_eta`);
auth/rate-limit/idempotency notes; the admin-only + ticket-link + dispute-window/clawback side-effects.
Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f10-b11**
consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-11.md` (the
refund/clawback/invoice engine is live, what f10 can now build — admin refund console + the
customer-facing cancellation/refund-status + invoice views — which endpoints/contracts are live, that
مودیان is mocked behind `IMoadianClient`, that clawback *recovery* waits on b13 and `tickets` on b15),
append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-11-report.md` (what was built, **what is now testable
and exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: the
مودیان reconciliation cron, the BNPL-revert reconciliation cron clearing `refund_payable ↔ escrow_held`,
clawback netting in b13, the `tickets` FK wire-up in b15), and update
`dev/shared-working-context/reports/mocks-registry.md` (the `IMoadianClient` row → 🟡; reconfirm the
reused seam rows).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
fee-leg/payout-leg decomposition, the **pre-payout reversal vs post-payout clawback fork** (and *why*
Iranian transfers are irreversible), the `refund_payable ↔ escrow_held` two-step clearing, channel
parity (card vs bnpl_revert post the same legs), VAT-on-commission-only with a config rate, the
sequential `invoice_number` generator, and the `tickets`/`nurse_payouts` forward-dep nullable FKs — with
a one-line pointer in `MEMORY.md`.
+361
View File
@@ -0,0 +1,361 @@
# Backend Phase 12 — BNPL: provider-financed installments (mocked)
> **Mission:** let a family pay for a booking with a provider-financed BNPL plan (SnappPay / Digipay /
> Tara / Torob Pay) — and record it correctly. The decisive, verified truth is that an Iranian BNPL order
> **settles the full booking amount to Balinyaar in one inbound lump, net of the provider's merchant
> commission**, and the provider owns the customer's installments and **100% of default risk**. So in our
> books a BNPL order is **a card payment that lands net-of-fee**: one `bnpl_transactions` row (1:1 with its
> `payment_transaction`) that drives an idempotent `eligible → token_issued → verified → settled` state
> machine, a settle that posts the **card-capture ledger legs plus a `bnpl_fee_expense` leg** so escrow
> reflects the *net* cash actually received, and a provider-mediated revert path. We **do not** model the
> customer's repayment schedule or default — that subsystem was deleted. The nurse's payout is **invariant
> to payment method**.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (`payment_transactions`, `ledger_entries`, `payment_webhook_events`, the card-capture posting, `IWebhookVerifier`, `IDistributedLock`), [b11](./backend-phase-11.md) (`refunds` 1:N, fee/payout decomposition, `refund_channel`) · **Unlocks:** BNPL checkout; frontend **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
This is **backend phase b12**, the third leg of the payments arc (b10 ledger/txn/webhook/capture → b11
refunds/clawbacks/invoices → **b12 BNPL** → b13 payouts). The platform never custodies cash: "escrow" is
an internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and BNPL is **not a new money model** — it collapses to the existing inbound-capture rail with one extra
fact: the cash that lands is **net of the provider's merchant discount**. This phase records that single
inbound settlement, the provider's commission (a **platform expense**, never the nurse's), and the
provider-mediated reversal — nothing about the customer's 4-installment repayment, which the provider owns
end to end.
**What already exists (do not rebuild) — built by prior phases:**
- **The ledger, transactions & webhook idempotency** — [b10](./backend-phase-10.md) built
`ledger_entries` (append-only, balanced, `transaction_group_id`, the six `account_type`s incl.
`escrow_held`, `platform_revenue`, `nurse_payable`, `refund_payable`, **`bnpl_fee_expense`**,
`nurse_clawback_receivable`), `payment_transactions` (filtered `UNIQUE(gateway_reference_code) WHERE NOT
NULL` and `UNIQUE(booking_id) WHERE status='succeeded'`), **`payment_webhook_events`**
(`UNIQUE(provider_code, external_event_id)` — the idempotency anchor), the **card-capture ledger
posting** (`DEBIT escrow_held` gross / `CREDIT platform_revenue` commission + `CREDIT nurse_payable`
payout), the **`IWebhookVerifier`** seam, and the **`IDistributedLock`** Redis-lock pattern on the money
path (`lock(booking:{id}:payment)`, `lock(booking:{id}:refund)`). **Reuse the ledger posting helper, the
webhook-event dedup, the lock, and `IWebhookVerifier` — do not re-implement any of them.**
- **The card-capture posting structure** — b10's `ConfirmPaymentAndPostLedger` posts the card-capture
group. **The BNPL settle is that same group PLUS a `bnpl_fee_expense` leg** — extend/reuse the helper,
do not fork it.
- **Refunds** — [b11](./backend-phase-11.md) built `refunds` (1:N per `payment_transaction`, fee-leg vs
payout-leg decomposition, `refund_channel``psp_card`|`bnpl_revert`|`manual`,
`external_revert_reference`, `expected_customer_refund_eta`, ticket-linked, admin-only) and the
refund ledger posting. **The BNPL revert path creates a `refund` row with `refund_channel='bnpl_revert'`
and posts the refund ledger legs via b11's helper** — it does not redefine refunds.
- **Bookings & the three-amount split** — [b9](./backend-phase-9.md)'s `bookings` carry
`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount` and `platform_fee_rate`. **The BNPL
`order_amount_irr` is the booking's `gross_price_irr`**; the nurse's payout is computed from the
booking split, never from `settled_amount_irr`.
- **`payment_gateways`** — [b10](./backend-phase-10.md)'s per-provider config (encrypted `config_json`,
`type` selects flow). BNPL providers are rows with `type='bnpl'`; provider selection is config-driven.
- **The platform config accessor** — [b1](./backend-phase-1.md)'s typed, cached `platform_configs`
reader. Read the mock commission %, settlement-timing class, and currency through it; **never hardcode**.
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`**, `IFieldEncryptor`, `ICurrentUser` + audit interceptor, rate limiting,
`IDateTimeProvider`, `ICacheService`.
**What this phase introduces:** the `bnpl_transactions` table + its status state machine, the
eligibility/initiate/verify/settle/revert/callback/status capabilities, and **two new seams —
`IBnplProvider`** (the mocked provider, one impl per `provider_code`) and **`ICurrencyNormalizer`**
(Toman→IRR at the boundary). `bnpl_settlement_entries` (tranched settlement) is **DEFERRED** — do not
build it.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
ledger, idempotent money writes, webhook dedup, Redis lock on the money path).
- [`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md) —
**the business rules**: full-upfront provider-financed settlement; a BNPL order is a card payment that
lands net-of-fee; **do not track customer installments / per-installment webhooks / default
propagation**; refunds flow **only** customer ↔ provider ↔ Balinyaar; the nurse's payout is **unchanged
by BNPL**; MVP vs DEFERRED (no in-house credit, single provider, no tranched settlement).
- [`product/payments/bnpl-landscape.md`](../../../product/payments/bnpl-landscape.md) — **the provider
mechanics**: the SnappPay verb set (eligibility → token → verify → settle → revert/cancel/update),
commission-as-config (anecdotal 715%; Torob Pay's published 6.6%; **read the actual deducted amount
from the settlement, never hardcode**), **settlement timing is NOT instant** (daily/T+13/weekly/15-day,
per-transaction `settled_at`), Toman↔Rial conversion at the boundary, and the async ~710-business-day
customer refund window.
- [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) — **the canonical schema** for
`bnpl_transactions` (every column + the state machine) and the `bnpl_settlement_entries` DEFERRED note.
Mirror these field names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **BNPL-settle
ledger posting** (card-capture legs PLUS `DEBIT bnpl_fee_expense` / `CREDIT escrow_held` for the
commission, so escrow reflects net cash) and the refund/revert legs.
- **Code to mirror:** b10's `Features/Payments/**` command structure, the `ConfirmPaymentAndPostLedger`
ledger helper, the `payment_webhook_events` upsert-first-then-mutate idempotency pattern, the
`IWebhookVerifier` usage, and the `IDistributedLock` lock helper; b11's `Features/Refunds/**`,
`refund_channel`, and the refund ledger posting; b9's booking three-amount split; b1's typed config
accessor.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT` as a string on
the wire, the envelope, `refund_channel` enum, Toman is display-only).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md` and
`…-11.md`, and `reports/mocks-registry.md` (the `IWebhookVerifier`/`IPaymentProvider`/`IDistributedLock`
rows you reuse, the new rows you add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Bnpl/{Commands|Queries}/<Name>/`; the entity in
`Baya.Domain/Entities/Bnpl/`; one `IEntityTypeConfiguration<T>` in `Persistence/Configuration/BnplConfig/`;
one EF migration for the single table.
### 3.1 Entity + migration
**`bnpl_transactions`** [MVP] — one row per BNPL order, **1:1 with its `payment_transaction`**; the single
inbound settlement to reconcile, plus the revert path. (Replaces the deleted `installment_plans`; there is
nothing to amortize on our side.)
- Fields (mirror [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) exactly):
- `id` (BIGINT PK).
- `payment_transaction_id` (BIGINT FK → `payment_transactions`) **`UNIQUE`** — the strict 1:1 guard.
- `provider_code` (NVARCHAR(50)) — `snapppay` | `digipay` | `tara` | `torobpay` (selects the provider impl).
- `merchant_of_record` (NVARCHAR(40)) — Balinyaar entity or partner center.
- `external_payment_token` (NVARCHAR(200)) — for verify/settle/revert; issued at initiate.
- `external_transaction_id` (NVARCHAR(200), nullable) — the provider's order/txn id.
- `eligibility_status` (NVARCHAR(30), nullable) — recorded by the eligibility check.
- `order_amount_irr` (BIGINT) — gross order = the booking's `gross_price_irr`.
- `settled_amount_irr` (BIGINT, nullable) — **net of provider commission actually received** (set at settle).
- `bnpl_commission_irr` (BIGINT, nullable) — the provider's merchant discount = **platform expense**, set at settle.
- `currency` (NVARCHAR(5)) — `IRR`/`TOMAN` at the boundary; **normalized to IRR on the way in**.
- `installment_count` (TINYINT, default 4) — **informational only** (owned by the provider).
- `status` (NVARCHAR(30)) — the state machine (see §3.2.0).
- `settled_at` (DATETIME2, **nullable**) — **per-transaction**, contract-defined (daily/T+13/weekly); never assume instant.
- `revert_transaction_id` (NVARCHAR(200), nullable), `reverted_amount_irr` (BIGINT, nullable),
`reverted_at` (DATETIME2, nullable) — the reversal path.
- `provider_commission_reversed_amount` (BIGINT, **nullable**) — the provider's own commission reversal,
reconciled **from the provider response**; **do not hardcode** (may be null/partial).
- `refund_channel` (NVARCHAR(20), nullable) — `bnpl_revert` on a reversal.
- `callback_payload_json` (NVARCHAR(MAX), nullable) — raw verify/settle/revert payload.
- audit + soft-delete fields per conventions.
- **Constraints / invariants:**
- `payment_transaction_id` **UNIQUE** (strict 1:1) — the structural one-BNPL-row-per-order guard.
- **State-machine guard on `status`** (forward-only; see §3.2.0) — illegal transitions are rejected; a
replayed `settle`/`revert` is a no-op, not a double-post.
- Money invariant (handler, on settle): `settled_amount_irr = order_amount_irr bnpl_commission_irr`;
all amounts ≥ 0.
- Relations: 1:1 → `payment_transactions`; shares `payment_webhook_events` for callback idempotency;
the revert creates a `refunds` row (b11).
### 3.2 Status state machine & commands/queries (CQRS, `OperationResult`, never throw for expected failures)
#### 3.2.0 The status state machine (the idempotency spine)
Define `BnplStatus` as a proper enum (persist as its stable string code):
`eligible` | `token_issued` | `verified` | `settled` | `reverted` | `cancelled` | `failed`.
Allowed forward transitions — enforce centrally (a `TransitionTo` guard on the entity / a small transition
table), **reject anything else, and treat an already-in-target-state transition as an idempotent no-op**:
```
eligible → token_issued | failed | cancelled
token_issued → verified | failed | cancelled
verified → settled | failed | reverted
settled → reverted
(any active) → cancelled (before settle)
```
A replayed callback that would re-drive a completed transition **must not** re-post the ledger — the guard
plus the `payment_webhook_events` dedup are the two backstops.
#### 3.2.1 Capabilities
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`CheckBnplEligibilityQuery`** | Query | `POST api/v1/checkout_bnpl/eligibility` | Calls `IBnplProvider.CheckEligibilityAsync(customerMobile, order_amount_irr, ct)` for the chosen `provider_code` and records `eligibility_status` (and `status='eligible'`) on a created/updated `bnpl_transactions` row tied to the booking's `payment_transaction`. Returns `eligible`/`not_eligible`/`ceiling_exceeded` + the plan summary (default 4 installments, "0% interest, provider-financed") so the client can show the plan or fall back to card. Amount comes from the booking's `gross_price_irr`. |
| **`InitiateBnplOrderCommand`** | Command | `POST api/v1/checkout_bnpl/initiate` | Creates the `bnpl_transactions` row **1:1** with a `payment_transaction` (under the `UNIQUE(payment_transaction_id)` guard), normalizes `order_amount_irr` to IRR via **`ICurrencyNormalizer`**, calls `IBnplProvider.CreatePaymentTokenAsync(...)` to issue `external_payment_token`, transitions `eligible → token_issued`, and returns the token + provider redirect URL. Under `lock(booking:{id}:payment)` (reuse b10's lock). Carries an `idempotencyKey`. |
| **`VerifyBnplOrderCommand`** | Command | (driven by `HandleBnplCallback`, also `POST api/v1/admin_bnpl/{id}/verify`) | Calls `IBnplProvider.VerifyAsync(token, expected order_amount_irr, ct)`, re-checks amount + reference (**never trust the callback alone**), persists `callback_payload_json`, transitions `token_issued → verified`. Idempotent via the state guard. |
| **`SettleBnplOrderCommand`** | Command | (driven by `HandleBnplCallback`, also `POST api/v1/admin_bnpl/{id}/settle`) | Calls `IBnplProvider.SettleAsync(token, idempotencyKey, ct)`; records `settled_amount_irr`, `bnpl_commission_irr`, `settled_at` (**nullable — read from the provider response, never assume now**) from the **actual settlement**; **posts the BNPL-settle ledger group** (§5) — the card-capture legs **plus** `DEBIT bnpl_fee_expense = bnpl_commission_irr` / `CREDIT escrow_held = bnpl_commission_irr` so escrow reflects **net** cash — via b10's helper; transitions `verified → settled` and confirms the parent `payment_transaction` (`succeeded`, under b10's filtered-unique guard) which triggers the booking conversion. Under `lock(booking:{id}:payment)`; carries an `idempotencyKey`. **A replayed settle is a no-op** (state guard + webhook dedup). |
| **`RevertBnplOrderCommand`** | Command | `POST api/v1/admin_bnpl/{id}/revert` | Full reversal via the stored token: calls `IBnplProvider.RevertAsync(token, idempotencyKey, ct)` (partial/shortened-visit maps to `UpdateAsync(newAmount strictly-lower)`), writes `revert_transaction_id`, `reverted_amount_irr`, `reverted_at`, `provider_commission_reversed_amount` (from the provider response, nullable), sets `refund_channel='bnpl_revert'`, **creates a `refunds` row** (b11) with `refund_channel='bnpl_revert'`, `external_revert_reference`, `expected_customer_refund_eta` (~710 business days), **posts the refund ledger legs** (b11's helper — fee-leg + payout-leg decomposition; if the nurse was already paid, a clawback), and transitions `… → reverted`. Under `lock(booking:{id}:refund)`. Money **always** flows customer ↔ provider ↔ Balinyaar — **never** direct-to-customer or nurse→customer. |
| **`HandleBnplCallbackCommand`** | Command | `POST api/v1/webhooks_bnpl/{provider}` | The inbound provider-callback entry point. **`IWebhookVerifier`** (reuse, b10) validates signature + extracts `(externalEventId, eventType, payload)`; **upsert `payment_webhook_events` keyed `UNIQUE(provider_code, external_event_id)` FIRST, no-op on duplicate, inside the same DB transaction that mutates state**; stores `callback_payload_json`; dispatches to `VerifyBnplOrderCommand`/`SettleBnplOrderCommand`/`RevertBnplOrderCommand` per `eventType`, all gated by the status state machine so a re-delivered callback never double-settles or double-posts. Rate-limited. |
| **`GetBnplOrderStatusQuery`** | Query | `GET api/v1/admin_bnpl/{id}` (+ tenancy-scoped customer view of their own order) | Surfaces status, `order_amount_irr`, `settled_amount_irr`, `bnpl_commission_irr`, **settlement timing** (`settled_at` / the contract-defined class, "not instant"), and revert audit (`reverted_amount_irr`, `external_revert_reference`, `expected_customer_refund_eta`). Projected (`AsNoTracking` + `.Select`). |
- **Controllers:** `CheckoutBnplController` (customer policy, tenancy-scoped, checkout endpoints
**rate-limited**), `WebhooksBnplController` (anonymous but signature-verified + rate-limited), and
`AdminBnplController` (admin policy, payout/refund-sensitive endpoints rate-limited). All
`sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case
`[controller]`/`[action]` routes, `CancellationToken` threaded.
- **Validators:** FluentValidation on `InitiateBnplOrderCommand` (valid `provider_code`, positive amount,
booking in `pending_payment`) and the id-bearing commands; `RevertBnplOrderCommand` validates a
partial/update amount is **strictly lower** than the settled amount.
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **`bnpl_settlement_entries`** — tranched-settlement child rows, only needed if a future provider pays the
platform over time. **Modeled-but-inactive: do not build the table.** Note in the report that adding it
later is a purely additive migration. (Ref [`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md).)
- **Customer installment tracking** (`installment_entries` / `installment_plans`) — **cut entirely**; the
provider owns the schedule and 100% default risk. **Never reintroduce.** `installment_count` is
informational only.
- **Multiple-provider BNPL routing / failover** — DEFERRED; this phase ships the mock with one impl per
`provider_code` and config-driven selection, but the active route is a single provider. Note in the
report.
- The **BNPL `settled_at`-gates-payout** coupling lives in **b13** (the `require_bnpl_settlement_for_payout`
config flag) — **do not** couple payout to BNPL settlement here; just record `settled_at` faithfully.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBnplProvider`** | **introduced here** | The SnappPay-superset verb set: `CheckEligibilityAsync` (always **eligible**), `CreatePaymentTokenAsync` (**fixed deterministic** `external_payment_token` + redirect URL), `VerifyAsync` (instant **verified**, echoes amount), `SettleAsync` (instant **settled**: returns `settledAmountIrr = order commission`, `bnplCommissionIrr` from a **configurable mock commission %**, `settledAt = now`), `RevertAsync`/`UpdateAsync`/`CancelAsync` (echo amounts, drive the reversal), `GetStatusAsync`. **Drives the full `eligible → token_issued → verified → settled → reverted/cancelled` state machine with no network.** One impl **per `provider_code`** (`snapppay`/`digipay`/`tara`/`torobpay`), selected by config / a `provider_code`-keyed resolver. | **add a new row** (🟡) |
| **`ICurrencyNormalizer`** | **introduced here** | Toman↔IRR at the boundary: mock multiplies Toman ×10 → IRR (and back for display). Config-driven. **Conversion happens ONLY here, at the provider boundary — never internally.** | **add a new row** (🟡) |
| `IWebhookVerifier` | reuse from **b10** | signature `valid=true`, extracts a test `externalEventId`/`eventType` from the body; lets tests replay duplicate callbacks to prove idempotency. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(booking:{id}:payment)` on initiate/verify/settle, `lock(booking:{id}:refund)` on revert. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; for any PII echoed in the callback payload — never log plaintext. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (commission %, currency, timing class). | reuse row |
The mocks live behind **DI-registered interfaces** in Infrastructure (real impl is a drop-in later); a real
`SnappPayBnplProvider` / `DigipayBnplProvider` selection is config-driven, **never** an `if (mock)` branch
in a handler. Append the `IBnplProvider` and `ICurrencyNormalizer` rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — for `IBnplProvider`:
SnappPay OAuth `api/online/v1/oauth/token` + `offer/v1/eligible` + `payment/v1/token|verify|settle|revert|
cancel|update|status`, or Digipay UPG `tickets/business?type=13` + `purchases/verify` +
`purchases/deliver?type=13` + `refunds`/`reverse`; credentials from the encrypted `payment_gateways.config_json`;
Toman↔Rial conversion; per-contract commission read from the settle response; **warn: do not use the
unrelated Canadian `SnapPayInc/open-api-java-sdk`**).
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`order_amount_irr`, `settled_amount_irr`,
`bnpl_commission_irr`, `reverted_amount_irr`, `provider_commission_reversed_amount`) is `long`/`BIGINT`.
No float path. **Currency is normalized to IRR at the provider boundary** (`ICurrencyNormalizer`) — the
provider speaks Toman; conversion happens **only** in the adapter, never internally.
- **A BNPL order is, in our books, a card payment landing net-of-fee.** **Do NOT model the customer's
repayment schedule or default risk** — the provider owns the installments and 100% default risk; the
`installment_entries` subsystem was deleted. `installment_count` is informational only.
- **`bnpl_commission_irr` is the provider's merchant discount = a PLATFORM EXPENSE** (the `bnpl_fee_expense`
leg) and **NEVER touches the nurse's payout.** The settle ledger reflects **NET cash** — escrow shows
`settled_amount_irr`, **not** `order_amount_irr`.
- **The nurse's payout is invariant to payment method** — computed from `gross_price_irr
balinyaar_commission_irr` (the booking split), **never** from `settled_amount_irr`. (b13 pays the
identical amount whether the family paid by card or BNPL.)
- **The settle ledger group (balanced, append-only, one `transaction_group_id`, Σdebit = Σcredit)** — the
card-capture legs **plus** the provider-fee leg, posted once via b10's helper:
```
DEBIT escrow_held order_amount_irr (= gross_price_irr)
CREDIT platform_revenue balinyaar_commission_irr
CREDIT nurse_payable nurse_payout_amount
DEBIT bnpl_fee_expense bnpl_commission_irr
CREDIT escrow_held bnpl_commission_irr (escrow reflects NET cash received)
```
Never UPDATE/DELETE a ledger row; corrections are new balancing postings.
- **`settled_amount_irr = order_amount_irr bnpl_commission_irr`**, and the commission + settlement timing
are read from the **actual settlement record**, **never hardcoded**.
- **`settled_at` is per-transaction and contract-defined (daily/T+13/weekly) — never assume instant.**
Model it nullable; "full amount" does not mean "instant cash." Do not let b13 assume BNPL cash funds a
payout (payout is decoupled).
- **Idempotency:** every callback upserts `payment_webhook_events` (`UNIQUE(provider_code, external_event_id)`)
**first, inside the money-mutating DB transaction, and no-ops on duplicate**; the **status state machine
is forward-only** so a **replayed settle must not double-count or double-post the ledger**, and a
replayed revert must not double-refund. Redis `lock(booking:{id}:payment)`/`lock(booking:{id}:refund)`
is the fast first line; the webhook UNIQUE + state machine are the authoritative backstop.
- **Strict 1:1:** `bnpl_transactions.payment_transaction_id` is **UNIQUE** — exactly one BNPL row per order.
Do not drop it.
- **Refund routing:** BNPL refunds flow **only** customer ↔ provider ↔ Balinyaar via `RevertAsync`
(full) / `UpdateAsync` (partial, **strictly lower** amount) using the **stored token** — **never**
nurse→customer or Balinyaar→customer directly. The refund still decomposes across the platform-fee and
nurse-payout legs in the ledger (b11), `refund_channel='bnpl_revert'`, and the customer's cash-back is
**async ~710 business days** (surface `expected_customer_refund_eta`).
- **Escrow is a ledger, not a status flag** — every BNPL inbound/reversal is double-entry `ledger_entries`.
- **Never trust the callback alone** — `SettleBnplOrderCommand`/`VerifyBnplOrderCommand` re-check amount +
reference server-side against the stored `order_amount_irr` before posting money.
- **Tenancy:** the customer view of `GetBnplOrderStatusQuery` is scoped to `ICurrentUser`; a customer can
never read another's BNPL order. Admin/webhook endpoints sit behind their policies and are rate-limited.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `bnpl_transactions` exists via one migration, with its `IEntityTypeConfiguration<T>`, the
`UNIQUE(payment_transaction_id)` 1:1 guard, the `BnplStatus` state-machine enum + central transition
guard, the `settled_amount_irr = order_amount_irr bnpl_commission_irr` invariant, nullable
`settled_at`/`provider_commission_reversed_amount`, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `CheckoutBnplController` + `WebhooksBnplController` + `AdminBnplController`.
- [ ] **`IBnplProvider`** (one impl per `provider_code`) and **`ICurrencyNormalizer`** introduced
(Application interfaces, Infrastructure mocks, DI registration via a `ServiceConfiguration/`
extension, config-selected). No `if (mock)` in handlers.
- [ ] The settle posts the **net-of-fee ledger group including the `bnpl_fee_expense` leg** via b10's
helper; a **replayed settle webhook is a no-op** (webhook dedup + state guard); the revert posts the
reversal via b11's helper with `refund_channel='bnpl_revert'`.
- [ ] **The `nurse_payable` accrual equals the card-path amount** (payout invariant to method) — covered
by a test that settles a BNPL order and asserts `nurse_payable` matches the card-capture path.
- [ ] Handler unit tests (NSubstitute) for eligibility, the initiate→verify→settle posting (incl. the
`bnpl_fee_expense` leg and the payout-invariance assertion), the replayed-settle no-op, the
revert/reversal posting, and the strict-1:1 + state-machine guards; ≥1 `WebApplicationFactory`
integration test per controller (happy path, 401/403, validation 400). `dotnet build Baya.sln` zero
new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Bnpl/**` area is reflected in the **Project map** in
`server/CLAUDE.md`; the `IBnplProvider` + `ICurrencyNormalizer` seams noted where seams are documented.
- [ ] The contract `dev/contracts/domains/bnpl.md` written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a **`pending_payment`** booking with a known three-amount split
(`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount`) and a `payment_gateways` row with
`type='bnpl'`, `provider_code='snapppay'`. Set the mock commission % (config) to a known value (e.g. 10%).
1. **Eligibility** — `POST api/v1/checkout_bnpl/eligibility` for the booking → `eligible` with the plan
summary (4 installments, 0% interest, provider-financed); a `bnpl_transactions` row exists with
`eligibility_status` set and `status='eligible'`.
2. **Initiate** — `POST api/v1/checkout_bnpl/initiate` → `status='token_issued'`, a deterministic
`external_payment_token` + redirect URL returned; the row is 1:1 with the `payment_transaction`; a
second initiate for the same `payment_transaction` is rejected by the `UNIQUE` guard.
3. **Verify → settle (the ledger)** — drive the callback `POST api/v1/webhooks_bnpl/snapppay` (or the admin
settle) → `status` walks `verified → settled`; `settled_amount_irr = order_amount_irr bnpl_commission_irr`
(e.g. 10% commission), `bnpl_commission_irr` and `settled_at` recorded; the **ledger** shows the
balanced group: `DEBIT escrow_held` gross / `CREDIT platform_revenue` commission + `CREDIT nurse_payable`
payout **plus** `DEBIT bnpl_fee_expense` commission / `CREDIT escrow_held` commission — so the net
`escrow_held` equals `settled_amount_irr`.
4. **Payout invariance** — assert the `nurse_payable` credited equals `gross_price_irr
balinyaar_commission_irr`, i.e. **identical to the card path** and **independent of** `settled_amount_irr`
/ the BNPL commission.
5. **Replayed settle is a no-op** — re-deliver the same settle callback (same `external_event_id`) → the
`payment_webhook_events` dedup + the state guard reject it; **no second ledger group**, balances unchanged.
6. **Revert** — `POST api/v1/admin_bnpl/{id}/revert` → `status='reverted'`, `reverted_amount_irr`/
`revert_transaction_id`/`reverted_at` set; a `refunds` row appears with `refund_channel='bnpl_revert'`,
`external_revert_reference`, and `expected_customer_refund_eta` (~710 business days); the **reversal
ledger legs** post (fee-leg + payout-leg; clawback if the nurse was already paid).
7. **Status** — `GET api/v1/admin_bnpl/{id}` → surfaces settlement amount/commission, the non-instant
`settled_at`, and the revert audit; the customer can read **only their own** order (another customer's
is 403/not visible).
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Bnpl/**` area + the `IBnplProvider` / `ICurrencyNormalizer` seams); if you discover/confirm a
rule the product docs don't capture (e.g. the mock commission % config key, the `provider_code`-keyed
resolver, the exact transition table), record it in
[`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md) or
[`product/data-model/08-bnpl.md`](../../../product/data-model/08-bnpl.md) — don't invent rules.
- **Contract to write:** **`dev/contracts/domains/bnpl.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the checkout endpoints
(eligibility, initiate), the webhook endpoint, the admin verify/settle/revert/status endpoints; the
`BnplStatus` and `refund_channel` enums; the `bnpl_transactions` DTO shape (IRR `BIGINT` as a string,
nullable `settled_at`, the revert fields); auth/rate-limit/idempotency notes; the net-of-fee settle and
the customer ↔ provider ↔ Balinyaar refund routing as documented side effects; the async refund-ETA copy.
Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f11-b12** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-12.md` (BNPL
checkout is live, what f11 can now build — the "pay with installments" option, eligibility/plan states,
provider handoff, declined→fall-back-to-card, the admin BNPL revert path with the ~710-day ETA — which
endpoints/contracts are live, that the provider + currency are mocked behind `IBnplProvider` /
`ICurrencyNormalizer`), append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-12-report.md` (what was built, **what is now testable
and exactly how** per §7, what is mocked + how to make it real, contracts produced/consumed, follow-ups:
tranched settlement `bnpl_settlement_entries`, multi-provider routing, the b13 `settled_at` payout
guard), and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBnplProvider` +
`ICurrencyNormalizer` rows → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — a BNPL order is
a net-of-fee card payment (no installment tracking), the `bnpl_fee_expense` settle leg so escrow shows
net cash, the payout-invariant-to-method rule, the forward-only state machine + webhook dedup
idempotency, the strict 1:1 `payment_transaction_id` UNIQUE, the customer↔provider↔Balinyaar revert
routing, and the `IBnplProvider` (per-`provider_code`) + `ICurrencyNormalizer` seams — with a one-line
pointer in `MEMORY.md`.
+305
View File
@@ -0,0 +1,305 @@
# Backend Phase 13 — Weekly nurse payouts (mocked bank transfer)
> **Mission:** pay nurses what they have earned. Build the weekly payout engine that aggregates
> payout-**eligible**, unpaid bookings/sessions into a `nurse_payout_batches` run, fans them out to **one
> `nurse_payouts` row per nurse** (netting any pending clawback the nurse owes back), links each booking
> under a **`UNIQUE` guard so it can never be paid twice**, snapshots the nurse's verified primary IBAN,
> submits the transfers through a **mocked PAYA/SATNA bank rail**, and posts the outbound `nurse_payable`
> ledger movement — all **holiday-aware** so a Nowruz-landing batch shifts off bank-closed days. This is
> the last money-out phase; after this a nurse's earnings are real.
>
> **Track:** backend · **Depends on:** [b10](./backend-phase-10.md) (ledger / `nurse_payable`), [b11](./backend-phase-11.md) (clawbacks), [b9](./backend-phase-9.md) (booking/session eligibility, dispute window), [b3](./backend-phase-3.md) (nurse bank accounts), [b1](./backend-phase-1.md) (`iranian_holidays`) · **Unlocks:** nurse earnings; frontend **f12-b13**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b13**, the final money-out leg of the payments arc (b10 ledger → b11
refunds/clawbacks/invoices → b12 BNPL → **b13 payouts**). The platform never custodies cash: "escrow" is
an internal **double-entry ledger state** ([`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md)),
and a nurse's owed balance lives in `ledger_entries` as the `nurse_payable` account. This phase **drains
that accrual to a real bank transfer**, once per booking, only after the dispute window has closed — the
one irreversible step in the whole money flow. Because an Iranian PAYA/SATNA transfer cannot be charged
back, eligibility gating and the clawback fallback (b11) are what protect the platform from overpaying.
**What already exists (do not rebuild) — built by prior phases:**
- **The ledger & `nurse_payable` accrual** — [b10](./backend-phase-10.md) built `ledger_entries`
(append-only, balanced, `transaction_group_id`, the six `account_type`s incl. `escrow_held`,
`nurse_payable`, `nurse_clawback_receivable`), `payment_transactions`, `payment_webhook_events`,
the card-capture posting (`DEBIT escrow_held` / `CREDIT platform_revenue` + `nurse_payable`), the
`IDistributedLock` Redis-lock pattern on the money path, and `GetNursePayableBalance` (sum of
`nurse_payable` legs). **Reuse the ledger posting helper and the lock — do not re-implement them.**
- **Clawbacks** — [b11](./backend-phase-11.md) built `nurse_clawbacks` (`nurse_id`, `booking_id`,
`refund_id`, `original_payout_id`, `amount_irr`, `status``pending|recovered|written_off`,
`recovered_in_payout_id`, `resolved_at`) and the `nurse_clawback_receivable` ledger leg. This phase
**nets `pending` clawbacks into a payout and marks them `recovered`** — it does not create them.
- **Bookings, sessions & the dispute window** — [b9](./backend-phase-9.md) built `bookings` (the
three-amount split `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`),
`booking_sessions` (per-visit `visit_payout_amount`, `payout_eligible_at`), `visit_verifications`
(EVV), and the `dispute_window_ends_at` set on completion (`completed_at + config(dispute_window_hours, 72)`).
**The `payout_released` boolean was deliberately CUT — never reintroduce it.**
- **Nurse bank accounts** — [b3](./backend-phase-3.md) built `nurse_bank_accounts` (`iban` enc,
`iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`,
`account_holder_from_bank`, `ownership_vendor_ref`) and the `IBankAccountOwnershipVerifier` seam. This
phase **reads the verified primary account and snapshots its IBAN** — it does not register or verify.
- **`iranian_holidays`** — [b1](./backend-phase-1.md) seeded the holiday calendar (`holiday_date`,
`is_bank_closed`) behind the **`IHolidayCalendar`** seam. **Reuse `IHolidayCalendar`** for date shifting.
- **The platform config accessor** — [b1](./backend-phase-1.md)'s typed, cached `platform_configs`
reader. Read `dispute_window_hours` (already used by b9) and any payout-window config through it; never
hardcode.
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`**, `IFieldEncryptor` (for `iban_snapshot`), `ICurrentUser` + audit
interceptor, rate limiting, `IDateTimeProvider`.
**What this phase introduces:** the three payout tables, the eligibility/build/link/execute capabilities,
and **one new seam — `IBankTransferProvider`** (the mocked PAYA/SATNA rail). The weekly **cron scheduler is
DEFERRED** — batches are triggered manually by an admin endpoint now (see §3, `SchedulePayoutJob` DEFERRED).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, append-only balanced
ledger, idempotent money writes, Redis lock on the money path).
- [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) — **the business rules**:
weekly batches, EVV + dispute-window gating, one-payout-per-booking, clawback netting, holiday-aware
scheduling, verified-primary-IBAN destination, MVP vs DEFERRED (no on-demand withdrawal).
- [`product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md) —
**Q2 "who pays the nurse, and when"**: the nurse payout is `gross_price_irr balinyaar_commission_irr`,
identical and on the identical weekly timing whether the family paid by card or BNPL; the BNPL
provider's commission **never** touches the nurse; the optional `settled_at` timing guard.
- [`product/data-model/07-payouts.md`](../../../product/data-model/07-payouts.md) — **the canonical
schema** for `nurse_payout_batches`, `nurse_payouts` (incl. the `gross_earnings_irr` /
`clawback_applied_irr` / `net_amount_irr` additions), and `nurse_payout_booking_links` (the `booking_id`
UNIQUE guard). Mirror these field names exactly.
- [`product/payments/escrow-ledger.md`](../../../product/payments/escrow-ledger.md) — the **payout ledger
posting** (`DEBIT nurse_payable` / `CREDIT escrow_held` for `nurse_payout_amount`) and the clawback leg.
- **Code to mirror:** b10's ledger posting helper + `IDistributedLock` usage, the `payment_webhook_events`
idempotency pattern, and any `Features/Payments/**` command structure; b11's `nurse_clawbacks` config &
`Features/Refunds/**`; b9's `bookings`/`booking_sessions` configs and the eligibility columns; b3's
`nurse_bank_accounts` config; b1's `IHolidayCalendar` and the typed config accessor.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, envelope).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-10.md`, `…-11.md`,
`…-9.md`, `…-3.md`, `…-1.md`, and `reports/mocks-registry.md` (seam rows you reuse/add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Payouts/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Payouts/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/PayoutsConfig/`; one EF migration for the three tables.
### 3.1 Entities + migration
**`nurse_payout_batches`** [CORE] — weekly aggregation, admin/job-initiated, holiday-aware.
- Fields: `id`, `period_start`, `period_end` (holiday-shifted off `is_bank_closed` days),
`processing_date` (holiday-shifted), `total_amount` (BIGINT), `payout_count` (int),
`status` (enum, see below), `initiated_by_admin_id` (FK `users`), `processed_at` (nullable),
`failure_notes` (nullable), audit fields.
- **CHECK / invariant:** `total_amount = Σ(nurse_payouts.net_amount_irr)` for the batch — enforce in the
handler when materializing rows; add a DB CHECK where SQL Server allows (else a verified invariant in
`ExecutePayoutBatch`). `payout_count = COUNT(nurse_payouts)`.
- Relations: 1:N → `nurse_payouts`.
**`nurse_payouts`** [CORE] — one row per nurse per batch.
- Fields: `id`, `batch_id` (FK), `nurse_id` (FK `nurse_profiles`), `bank_account_id` (FK
`nurse_bank_accounts`), `iban_snapshot` (**encrypted** via `IFieldEncryptor`, frozen at build time),
`gross_earnings_irr` (BIGINT — Σ eligible booking/session payouts), `clawback_applied_irr` (BIGINT —
pending clawbacks netted this batch, ≥ 0), `net_amount_irr` (BIGINT — `gross_earnings_irr
clawback_applied_irr`), `amount` (BIGINT — actually transferred net; equals `net_amount_irr` on
success), `booking_count` (int), `status` (enum), `transfer_reference` (nullable — the bank track id),
`paid_at` (nullable), `failure_reason` (nullable), audit fields.
- **Invariant (handler + CHECK where possible):** `net_amount_irr = gross_earnings_irr
clawback_applied_irr`; all amounts ≥ 0; `net_amount_irr ≥ 0` (a nurse whose clawback exceeds earnings
nets to **zero this batch with the remainder staying `pending`** — see §5; never produce a negative
transfer).
- Relations: N:1 → `nurse_payout_batches`, `nurse_profiles`, `nurse_bank_accounts`; 1:N →
`nurse_payout_booking_links`; referenced by `nurse_clawbacks.recovered_in_payout_id`.
**`nurse_payout_booking_links`** [CORE] — the structural anti-double-pay guard.
- Fields: `id`, `payout_id` (FK `nurse_payouts`), `booking_id` (FK `bookings`) **`UNIQUE`**,
`session_id` (nullable FK `booking_sessions` — set when paying per-session accrual),
`payout_amount_irr` (BIGINT — the portion of this booking/session in this payout), audit fields.
- **The `booking_id` UNIQUE index is the hard guard** — a booking can be linked to exactly one payout
across all batches, ever. **Do not drop it.** (When paying per-session, the unique guard is on
`(booking_id, session_id)` so each *session* is paid once — confirm against b9's session model; the
booking-level `booking_id` UNIQUE still holds for single-session bookings.)
- Relations: N:1 → `nurse_payouts`; 1:1 → `bookings` (and per-session → `booking_sessions`).
**Status enums** (define as proper enums, persist as string/byte per project convention):
- `PayoutBatchStatus`: `draft` | `processing` | `partially_failed` | `completed` | `failed`.
- `PayoutStatus`: `pending` | `submitted` | `paid` | `failed`.
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route (admin/nurse) | What it does |
| --- | --- | --- | --- |
| **`ComputeEligibleEarningsQuery`** | Query | `GET api/v1/admin_payouts/eligible?period_start=&period_end=` | Projects (AsNoTracking + `.Select`) the **payout-eligible, unpaid** bookings/sessions for the window and groups them by nurse, returning a preview (per-nurse `gross_earnings_irr`, pending `clawback_applied_irr`, `net_amount_irr`, booking count). Eligible = `status='completed'` **AND** `dispute_window_ends_at < now` (per-session: `payout_eligible_at < now`) **AND no open dispute AND** not already in a `nurse_payout_booking_links` row. Paginated. |
| **`GeneratePayoutBatchCommand`** | Command | `POST api/v1/admin_payouts/batches` | Opens a `nurse_payout_batches` row in `draft`. Computes `period_end`/`processing_date` and **shifts them off `is_bank_closed` days via `IHolidayCalendar`** to the next business day. Selects the eligible set (same predicate as the query) under a `lock(payout:batch)` so two runs can't grab the same bookings. Orchestrates `BuildNursePayouts` + `LinkPayoutBookings` inside one unit of work. Returns the draft batch with materialized payouts for admin preview. **Idempotent:** re-running for an overlapping window cannot re-select an already-linked booking (the UNIQUE link is the backstop). |
| **`BuildNursePayouts`** | Command (internal step) | — | Groups the eligible bookings per nurse; computes `gross_earnings_irr = Σ(gross_price_irr balinyaar_commission_irr)` (per-session: `Σ visit_payout_amount`); reads the nurse's **`pending` `nurse_clawbacks`**, nets them into `clawback_applied_irr` (capped at `gross_earnings_irr`), sets `net_amount_irr` and `amount`; **snapshots `iban_snapshot`** from the nurse's **verified primary** `nurse_bank_accounts` (`is_primary=1 AND is_verified=1 AND matched_national_id=1`). A nurse with no verified primary account is **skipped with a recorded reason** (not silently dropped). |
| **`LinkPayoutBookings`** | Command (internal step) | — | Inserts `nurse_payout_booking_links` rows under the `booking_id` UNIQUE constraint. **A duplicate-key violation is the already-paid guard** — catch it, treat that booking as not-eligible, and continue (never let it abort the batch or double-pay). |
| **`ExecutePayoutBatchCommand`** | Command | `POST api/v1/admin_payouts/batches/{id}/process` | Transitions `draft → processing`. Under `lock(payout:batch)` (and `lock(nurse:{id}:payout)` per row), submits the batch to **`IBankTransferProvider.SubmitPayoutBatchAsync`** with one `PayoutInstruction` per payout (nurse IBAN, `net_amount_irr`, PAYA/SATNA method), stores each `transfer_reference`, transitions payouts to `submitted` then `paid`, **posts the payout ledger group** (`DEBIT nurse_payable` / `CREDIT escrow_held` for `nurse_payout_amount`, per payout, balanced, append-only) via b10's helper, and **marks each netted clawback `recovered`** with `recovered_in_payout_id` + `resolved_at`. Batch ends `completed` (all paid) or `partially_failed` (some failed). **Idempotent:** carries an `idempotencyKey` so a retried call never re-submits an already-`paid` payout or double-posts the ledger. |
| **`RetryFailedPayoutCommand`** | Command | `POST api/v1/admin_payouts/{payout_id}/retry` | Re-submits a single `failed` payout (holiday-aware: won't submit on a closed day), updating `transfer_reference`/`status`. Idempotent on the same key. |
| **`MarkPayoutFailedCommand`** | Command | `POST api/v1/admin_payouts/{payout_id}/mark_failed` | Records `failure_reason`/`failure_notes`, sets `status='failed'`. Used on a reconciled bank rejection. Does **not** post a ledger movement (no money left). |
| **`GetBatchDetailQuery`** | Query | `GET api/v1/admin_payouts/batches/{id}` | Batch header + its payouts (status, net, transfer_reference) + per-payout linked bookings. Projected, paginated. |
| **`ListPayoutBatchesQuery`** | Query | `GET api/v1/admin_payouts/batches?status=&page=&page_size=` | Admin reconciliation list. Projected + paginated. |
| **`GetNursePayoutHistoryQuery`** | Query | `GET api/v1/nurse_payouts/history?page=&page_size=` | The **nurse's own** payouts (tenancy-scoped to `ICurrentUser`): status, `net_amount_irr`, `transfer_reference`, `paid_at`, masked IBAN, any clawback applied. Projected + paginated. Feeds f12's earnings screen. |
- **Controllers:** `AdminPayoutsController` (admin policy, payout-sensitive endpoints **rate-limited**)
and `NursePayoutsController` (nurse policy, tenancy-scoped). Both `sealed : BaseController`, inject
`ISender`, return `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes,
`CancellationToken` threaded.
- **Validators:** FluentValidation on `GeneratePayoutBatchCommand` (period_start ≤ period_end, not in the
future) and the id-bearing commands.
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **`SchedulePayoutJob`** — the recurring **weekly cron trigger** (PAYA-aligned). DEFERRED: batches are
admin-triggered now. Leave a clean entry point (the `GeneratePayoutBatchCommand` the cron will call) and
a config key for the cadence; note it in the report. (Roadmap: a hosted scheduler later.)
- **On-demand / instant nurse withdrawal**, **per-nurse configurable payout frequency**, **automated
clawback recovery beyond next-batch netting** — DEFERRED per [`product/business/10-payouts.md`](../../../product/business/10-payouts.md) §(c).
- The **optional BNPL `settled_at` timing guard** (don't pay before BNPL cash is actually received) —
expose it as a **config flag** (`require_bnpl_settlement_for_payout`, default off) and apply it in the
eligibility predicate when set; do not hard-couple payouts to BNPL settlement. Note in the report.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBankTransferProvider`** | **introduced here** | `SubmitPayoutBatchAsync(PayoutBatchId, IReadOnlyList<PayoutInstruction>, idempotencyKey, ct)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference`, status `Submitted` then `Paid` for all rows (**no money moves**); `GetPayoutStatusAsync(externalBatchRef, ct)` echoes `Paid`. **PAYA vs SATNA selection:** mock honours the `method` on each `PayoutInstruction` (choose SATNA for high-value rows above a config threshold, else PAYA) and records it. A config switch can force a deterministic **failure** (closed-day / insufficient-provider-balance) so `partially_failed`/retry paths are testable. | **add a new row** (🟡) |
| `IHolidayCalendar` | reuse from **b1** | static seeded `iranian_holidays`; used to shift `period_end`/`processing_date` off `is_bank_closed` days. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `iban_snapshot`, never logs plaintext. | reuse row |
| `IDistributedLock` | reuse from **b10** | in-memory mock lock; `lock(payout:batch)` + `lock(nurse:{id}:payout)`. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later); a
real `JibitBankTransferProvider` / `VandarPayoutProvider` selection is config-driven, **never** an
`if (mock)` branch in a handler. Append the `IBankTransferProvider` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — Jibit transferor / Vandar
payout API, registered source settlement account, each nurse's verified Sheba, PAYA-vs-SATNA selection,
batch caps/minimums, the reconciliation callback that flips `submitted → paid/failed`).
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** Every amount (`gross_earnings_irr`, `clawback_applied_irr`,
`net_amount_irr`, `amount`, `payout_amount_irr`, `total_amount`) is `long`/`BIGINT`. No float path.
- **One payout per booking — `nurse_payout_booking_links.booking_id` UNIQUE is the hard guard; never pay
a booking in two batches.** A duplicate insert is the already-paid signal; treat it as not-eligible and
continue. Do not bypass the constraint across batches.
- **Payout eligibility requires `dispute_window_ends_at` (or per-session `payout_eligible_at`) passed AND
no open dispute — never pay on `completed` alone.** EVV completion alone is not enough; the dispute
window must have closed.
- **Net prior clawbacks before transfer:** `net_amount = gross_earnings clawback`, and **mark recovered
clawbacks** (`status='recovered'`, `recovered_in_payout_id`, `resolved_at`). Don't overpay a nurse who
owes money back. If pending clawbacks exceed this batch's earnings, net to **zero** (never negative) and
leave the remainder `pending` for the next batch.
- **`'paid'` derives from a `nurse_payout_booking_links` link row + a ledger movement out of
`nurse_payable` — the `payout_released` boolean is gone.** Never reintroduce it; never infer paid-state
from a status flag alone.
- **Append-only, balanced ledger.** The payout posts `DEBIT nurse_payable nurse_payout_amount` /
`CREDIT escrow_held nurse_payout_amount` per payout, in one `transaction_group_id`, Σdebit = Σcredit,
via b10's helper. Never UPDATE/DELETE a ledger row; corrections are new balancing postings. The nurse's
payable balance is **derived from the ledger and may go negative** (clawbacks) — don't clamp it to zero.
- **Holiday-aware shifting:** a Nowruz batch must move off bank-closed days. Shift `period_end` and
`processing_date` to the next `is_bank_closed=0` day via `IHolidayCalendar`, or PAYA/SATNA fails.
- **`total_amount = Σ payouts`** must hold per batch (CHECK / verified invariant); `payout_count =
COUNT(payouts)`.
- **Gross = commission + payout:** the payout amount is `gross_price_irr balinyaar_commission_irr` (the
booking's own split), **never** a BNPL provider's `settled_amount_irr`; `bnpl_commission_irr` is a
platform expense and **never touches the nurse**. The nurse's pay is invariant to payment method.
- **Webhook / transfer idempotency:** the bank submit carries an `idempotencyKey` and the payout `status`
state machine (`pending → submitted → paid`) is forward-only, so a retried `ExecutePayoutBatchCommand`
**never double-sends an irreversible transfer** or double-posts the ledger. Redis `lock(payout:batch)`
is the fast first line; the status state machine + the link UNIQUE are the authoritative backstop.
- **First payout is gated on the nurse's `matched_national_id`** (b3): only a verified primary IBAN
(`is_primary=1 AND is_verified=1 AND matched_national_id=1`) may receive a transfer. Snapshot that IBAN
into `iban_snapshot` (encrypted) and store the `transfer_reference` for reconciliation.
- **Real bank transfers are effectively irreversible** — which is *why* payout is dispute-window-gated and
refund-after-payout falls back to a clawback (b11), not a transfer reversal. Treat the execute step as
the point of no return.
- **Tenancy:** `GetNursePayoutHistoryQuery` is scoped to the authenticated nurse via `ICurrentUser`; a
nurse can never read another nurse's payouts. Admin endpoints sit behind the admin policy and are
rate-limited.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The three tables (`nurse_payout_batches`, `nurse_payouts`, `nurse_payout_booking_links`) exist via
one migration, each with its `IEntityTypeConfiguration<T>`, the `booking_id` UNIQUE index, the
`net_amount_irr = gross_earnings_irr clawback_applied_irr` and `total_amount = Σ payouts`
invariants, encrypted `iban_snapshot`, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `AdminPayoutsController` + `NursePayoutsController`.
- [ ] **`IBankTransferProvider`** introduced (Application interface, Infrastructure mock, DI registration
via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Eligibility predicate is correct (completed + dispute-window/`payout_eligible_at` passed + no open
dispute + not already linked); clawback netting + `recovered` marking works; the payout ledger group
posts balanced out of `nurse_payable`; holiday shifting works.
- [ ] Handler unit tests (NSubstitute) for eligibility selection, clawback netting, the duplicate-link
guard, ledger posting, and holiday shifting; ≥1 `WebApplicationFactory` integration test per
controller (happy path, 401, validation 400). `dotnet build Baya.sln` zero new warnings;
`dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Payouts/**` area is reflected in the **Project map** in
`server/CLAUDE.md`; the `IBankTransferProvider` seam noted where seams are documented.
- [ ] The contract `dev/contracts/domains/payouts.md` written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a few **completed** bookings: some with `dispute_window_ends_at` in the
**past** (eligible), some in the **future** (not yet), one **disputed**, and one with a **pending
clawback** on the nurse. Ensure one nurse has a verified primary IBAN and one does not.
1. **Eligibility preview** — `GET api/v1/admin_payouts/eligible?period_start=…&period_end=…` →
**only** the completed-and-dispute-window-closed, unpaid bookings appear, grouped by nurse; the
future-window and disputed bookings are **excluded**; the nurse without a verified IBAN is flagged.
2. **Generate a batch** — `POST api/v1/admin_payouts/batches` → a `draft` batch with one `nurse_payouts`
row per eligible nurse; the nurse with a **pending clawback** shows `clawback_applied_irr > 0` and
`net_amount_irr = gross_earnings_irr clawback_applied_irr`; `total_amount = Σ net_amount_irr`;
`iban_snapshot` populated (encrypted).
3. **Double-pay guard** — attempt to generate a second batch covering the **same** bookings → those
bookings are not re-selected (the `booking_id` UNIQUE link blocks it); no booking appears in two
payouts.
4. **Holiday shift** — set `processing_date` to land on a seeded `is_bank_closed=1` Nowruz day → the batch
`period_end`/`processing_date` is shifted to the next business day.
5. **Execute** — `POST api/v1/admin_payouts/batches/{id}/process` → payouts go `submitted → paid` with a
`transfer_reference`; the **ledger** shows a balanced `DEBIT nurse_payable` / `CREDIT escrow_held` per
payout (verify `GetNursePayableBalance` drops by the paid amount); the **netted clawback is marked
`recovered`** with `recovered_in_payout_id` set.
6. **Idempotency** — re-`process` the same batch → no second transfer, no second ledger posting (statuses
already `paid`).
7. **Failure / retry** — flip the mock to force a failure → batch ends `partially_failed`; `POST
…/{payout_id}/retry` re-submits and (with the mock back to success) flips to `paid`.
8. **Nurse history** — `GET api/v1/nurse_payouts/history` as the nurse → their payouts with masked IBAN,
net amount, transfer reference, and clawback explanation; **another nurse's payouts are not visible**.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Payouts/**` area + the `IBankTransferProvider` seam); if you discover/confirm a rule the
product docs don't capture (e.g. the clawback-exceeds-earnings → net-to-zero behaviour, or the
`require_bnpl_settlement_for_payout` flag default), record it in
[`product/business/10-payouts.md`](../../../product/business/10-payouts.md) — don't invent rules.
- **Contract to write:** **`dev/contracts/domains/payouts.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the admin payout
endpoints (eligible/preview, create batch, process, retry, mark-failed, batch detail, list) and the
nurse `…/history` endpoint; the `PayoutBatchStatus` / `PayoutStatus` enums; the batch/payout/link DTO
shapes (IRR `BIGINT`, **masked** `iban_snapshot`); auth/rate-limit/idempotency notes; the
one-payout-per-booking and dispute-window-gating side-effects. Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f12-b13**
consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-13.md` (the
payout engine is live, what f12 can now build — nurse earnings/payout history, admin payout console —
which endpoints/contracts are live, that the bank rail is mocked behind `IBankTransferProvider`), append
to `backend/STATUS.md`, write `dev/shared-working-context/reports/backend-phase-13-report.md` (what was
built, **what is now testable and exactly how** per §7, what is mocked + how to make it real,
contracts produced, follow-ups: the cron scheduler, the BNPL `settled_at` guard, on-demand withdrawal),
and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankTransferProvider` row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
one-payout-per-booking UNIQUE guard, the clawback-netting + `recovered_in_payout_id` flow, the
`'paid'`-derives-from-link+ledger rule (no `payout_released`), holiday-aware shifting, and the
`IBankTransferProvider` seam — with a one-line pointer in `MEMORY.md`.
+339
View File
@@ -0,0 +1,339 @@
# Backend Phase 14 — Reviews, ratings & patient care records
> **Mission:** close the trust loop and the continuity-of-care loop. Let a customer leave **one
> moderated review per completed booking**, run that review through a moderation pipeline, and keep the
> nurse's public rating **honest** by recomputing it from source on *every* status transition — so hiding
> a 1-star never leaves an inflated average. Auto-raise an internal safety alert on low ratings. Separately,
> let nurses author **encrypted, patient-scoped clinical notes** that accumulate into a longitudinal care
> history a new nurse can read before taking over — under strict clinical access control. This is a
> brand-survival area: buyers are vulnerable people cared for unobserved at home.
>
> **Track:** backend · **Depends on:** [backend-phase-9](backend-phase-9.md) (completed bookings + dispute window), [backend-phase-3](backend-phase-3.md) (profiles/patients), [backend-phase-1](backend-phase-1.md) (`support_alerts`, `platform_configs`, `audit_logs`, notifications), [backend-phase-7](backend-phase-7.md) (search aggregates) · **Unlocks:** the reviews UI ([frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the trust-and-continuity phase. By now bookings can reach a **completed/closed** state (b9), the
nurse and customer profiles + patients exist (b3), the platform can raise internal `support_alerts` and
read config (b1), and the search index carries a denormalized nurse rating/count that must stay current
(b7). This phase turns those pieces into the two things families actually judge a marketplace on after the
visit: **a trustworthy public rating** and **clinical continuity across nurses**.
Two distinct sub-domains live here, and they must not be conflated:
1. **Reviews & ratings** — public, social-proof, moderated, aggregate-driving.
2. **Patient care records** — private, clinical, encrypted, **patient-scoped** (not booking-scoped),
accessed only by people with a clinical right to see them.
**What already exists (do not rebuild):**
- **Completed bookings + dispute window** — [backend-phase-9](backend-phase-9.md) built `bookings` with the
3-amount split, the lifecycle that reaches a **completed/closed** status, `booking_sessions`,
`booking_care_instructions` (the two-stage clinical disclosure gate), `visit_verifications` (EVV), and set
`dispute_window_ends_at = completed_at + dispute_window_hours` on completion. Read these statuses and
relations; do not re-model booking lifecycle.
- **Profiles & patients** — [backend-phase-3](backend-phase-3.md) built `customer_profiles`,
`nurse_profiles` (including the denormalized **aggregate rating/count fields** you recompute here), and
`patients` (with customer tenancy). The aggregate columns on `nurse_profiles` are *owned* by the nurse
domain but **written by this phase** on every review transition.
- **Platform signals & config** — [backend-phase-1](backend-phase-1.md) built `support_alerts` (the
internal-only staff worklist + `RaiseSupportAlert` API), `platform_configs` (the typed cached accessor —
including `min_rating_for_support_alert`), `audit_logs` (append-only, written by the SaveChanges
interceptor on sensitive entities), and the in-app `notifications` write. **Reuse all of these** — you
*raise* alerts, you do not define the table.
- **Search aggregates** — [backend-phase-7](backend-phase-7.md) built `nurse_search_index` behind the
`INurseSearch` seam, with **maintenance hooks** to refresh a nurse's denormalized rating/count. Every
review transition that changes the nurse aggregate must trigger that refresh — do not write the search
index directly; call the b7 maintenance hook.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `IFieldEncryptor` (the field
encryptor you use for clinical notes), `ICacheService`, `IDateTimeProvider`, and `INotificationDispatcher`.
**Reuse `IFieldEncryptor`** for `patient_care_records`; do not introduce a new encryption seam.
> The **ticket/messaging** system (`tickets`, `ticket_participants`, `ticket_messages`), `partner_centers`,
> and the admin support-alert *worklist console* land in [backend-phase-15](backend-phase-15.md). This phase
> *raises* alerts and *consumes* the existing `support_alerts` raise API; it does **not** build the ticket
> system or the alert worklist UI. **(DEFERRED → b15.)**
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
— the one-per-completed-booking rule, recompute-on-every-transition, the configurable low-rating threshold,
the "patient is not the sole information source" principle, and why this is a brand-survival area.
- **Product — data model (source of truth):**
[`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md) —
`reviews` (rating 15 CHECK, body, moderation status + fields; 1:1 → `bookings`, N:1 →
`customer_profiles`/`nurse_profiles`), `review_tags_master`/`review_tag_links` (N:N), and
`patient_care_records` (nurse-authored, encrypted, **patient-scoped**, strict access). Read the **"Why"**
notes — they encode the guards you must enforce.
- **Booking statuses you gate on** — re-read the `bookings` status enum and `dispute_window_ends_at` from
[backend-phase-9](backend-phase-9.md)'s contract (`dev/contracts/domains/bookings.md`) so you key review
eligibility off the *exact* completed/closed status values, not a guess.
- **Config & alerts you reuse** — [backend-phase-1](backend-phase-1.md)'s handoff and
`dev/contracts/domains/platform-signals.md` (or equivalent) for the `RaiseSupportAlert` signature, the
`support_alerts` shape, and the typed config accessor for `min_rating_for_support_alert`.
- **Search refresh you trigger** — [backend-phase-7](backend-phase-7.md)'s handoff for the
`INurseSearch` maintenance hook that refreshes a nurse's aggregate rating/count in `nurse_search_index`.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`), an `IEntityTypeConfiguration<T>` under
`Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/`
(`sealed`, `BaseController`, `ISender`, `base.OperationResult(...)`), and how prior phases call
`IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care instructions).
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routes, status codes) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md)
(this phase has **no money**, but follow the type/format rules for ids/enums/timestamps).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`.
### 3.1 Entities, configs & migration
Add these tables (exact names below) as a single additive EF Core migration. One
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/ReviewsConfig/`.
- **`reviews`** — one review per **completed** booking.
- Columns: `id` (BIGINT PK), `booking_id` (FK → `bookings`, **UNIQUE** — enforces 1:1),
`customer_profile_id` (FK → `customer_profiles`), `nurse_profile_id` (FK → `nurse_profiles`),
`rating` (TINYINT/INT, **CHECK `rating BETWEEN 1 AND 5`**), `body` (NVARCHAR, nullable free text),
`moderation_status` (enum: `pending_moderation` | `published` | `hidden` | `rejected`; default
`pending_moderation`), `moderation_reason` (NVARCHAR, nullable — set on hide/reject),
`moderated_by_id` (FK → users, nullable), `moderated_at` (datetimeoffset, nullable), plus the audit
fields stamped by the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
- Indexes: unique on `booking_id`; index on `(nurse_profile_id, moderation_status)` for the public
published-only list and the recompute query; index on `moderation_status` for the moderation queue.
- Soft-delete query filter (`!IsDeleted`).
- **`review_tags_master`** — standardized tag vocabulary.
- Columns: `id` (BIGINT PK), `code` (e.g. `punctual`, `professional`, `clean`, `kind`; UNIQUE),
`label_fa` (NVARCHAR), `label_en` (NVARCHAR), `is_active` (BIT), display order. **Seed** a starter
vocabulary (at minimum: `punctual`, `professional`, `clean`, `kind`, `communicative`).
- **`review_tag_links`** — N:N join `reviews``review_tags_master`.
- Columns: `id` (BIGINT PK) or composite key, `review_id` (FK), `review_tag_master_id` (FK),
**UNIQUE `(review_id, review_tag_master_id)`** (no duplicate tag on a review).
- **`patient_care_records`** — nurse-authored, **encrypted**, **patient-scoped** clinical notes.
- Columns: `id` (BIGINT PK), `patient_id` (FK → `patients`, **the scoping key — not `booking_id`**),
`booking_id` (FK → `bookings`, **nullable**, provenance only — which visit produced the note),
`nurse_profile_id` (FK → `nurse_profiles`, the author), `body_encrypted` (VARBINARY/NVARCHAR holding
the `IFieldEncryptor`-encrypted clinical note — **never plaintext**), optional encrypted structured
fields (e.g. `vitals_encrypted`) if the digest schema carries them, `recorded_at` (datetimeoffset),
plus audit + soft-delete.
- Indexes: `(patient_id, recorded_at DESC)` for the longitudinal history read.
> **Aggregate columns are not yours to add.** `nurse_profiles` already carries the denormalized
> `average_rating` / `review_count` (or equivalently named) columns from b3. You **write** them on every
> transition; you do not re-create them. If they are missing, add them to the b3 entity in place and note
> it in your report (do not fork a parallel aggregate table).
### 3.2 Reviews — commands & queries
Feature folder `Baya.Application/Features/Reviews/`.
- **`SubmitReviewCommand`** (`Commands/SubmitReview/`) — customer submits `rating` (15) + `body` (+ optional
tag codes) for a booking.
- Guards (return `OperationResult.FailureResult`/`NotFoundResult`, **never throw**): the booking exists
and is owned by the calling customer (tenancy via `ICurrentUser``customer_profiles`); the booking is
in a **completed/closed** status (reject `cancelled`/`expired`/in-progress); **no review already exists**
for that booking (1:1). Insert as `pending_moderation`. If tag codes were passed, also write
`review_tag_links` in the same transaction (validate codes against `review_tags_master`).
- FluentValidation: `rating` in 1..5, `body` length bound.
- On create, if `rating <= min_rating_for_support_alert` (config, default 2), **call the b1
`RaiseSupportAlert`** with a `low_rating` type and the `review_id`/`booking_id` linkage (see §3.4).
- **`ModerateReviewCommand`** (`Commands/ModerateReview/`) — admin/AI transition: `publish` | `hide` |
`reject` | `unpublish`. Sets `moderation_status`, `moderation_reason` (on hide/reject), `moderated_by_id`,
`moderated_at`. **In the same transaction**: (a) recompute the nurse aggregate from source (§3.3); (b) the
audit interceptor writes the transition to `audit_logs`. After commit, trigger the b7 search-index refresh
for that nurse (§3.4). Optionally notify the review author of the outcome via `INotificationDispatcher`.
- The AI verdict (auto pre-screen) runs through `IReviewModerationService` on `SubmitReview` (§4); the
*decision authority* is still this command — a verdict can pre-set `pending_moderation` with a flag, or
auto-`published`/auto-`hidden` per config, but the human path must always be able to override.
- **`AttachReviewTagsCommand`** (`Commands/AttachReviewTags/`) — add/replace `review_tag_links` for a review
the caller owns (or admin). Enforce the unique `(review_id, review_tag_master_id)`.
- **`ListReviewsForNurseQuery`** (`Queries/ListReviewsForNurse/`) — **public**, paginated, returns
**`published` only** + the nurse aggregate (avg rating + count). `AsNoTracking()` + `.Select()` projection;
cache the aggregate read through `ICacheService` with invalidation on transition.
- **`GetReviewModerationQueueQuery`** (`Queries/GetReviewModerationQueue/`) — **admin**, paginated, filter by
`moderation_status` (default `pending_moderation`), sortable, includes any linked low-rating alert id.
- **`GetTagAggregatesQuery`** (`Queries/GetTagAggregates/`) — per-nurse tag rollup ("% punctual" = links for
that tag over published reviews of that nurse). Paginated/bounded; from published reviews only.
### 3.3 Aggregate recompute (internal domain service)
- **`RecomputeNurseRating`** — an internal application service (not an endpoint), invoked by **every**
`ModerateReviewCommand` transition *and* on `SubmitReview` only insofar as a brand-new review is
`pending_moderation` and therefore must **not** yet count. Recompute `nurse_profiles.average_rating` and
`review_count` **from the source** — i.e. `AVG(rating)`/`COUNT(*)` over the nurse's **currently
`published`** reviews — never by incremental `+delta`/`-delta`. This is the fix for inflated-rating-after-
hide drift: hiding a 1-star *lowers* the count and re-derives the average from what remains public. Do it
inside the same transaction as the status change.
### 3.4 Patient care records — commands & queries
Feature folder `Baya.Application/Features/PatientCareRecords/`.
- **`WritePatientCareRecordCommand`** (`Commands/WritePatientCareRecord/`) — a nurse authors a note for a
**patient** (optionally tagged with the `booking_id` that produced it). Encrypt `body` via
`IFieldEncryptor.Encrypt(...)` before persisting to `body_encrypted`. Guard: the calling nurse must have a
**confirmed** (or active/completed) booking for that patient — a nurse cannot write notes for a patient
they were never assigned to.
- **`GetPatientHistoryQuery`** (`Queries/GetPatientHistory/`) — patient-scoped longitudinal history,
paginated, ordered `recorded_at DESC`. Decrypt each `body_encrypted` via `IFieldEncryptor.Decrypt(...)`
**only after** the access check passes. **Strict access (§5):** the owning customer (patient's
`customer_profile`), any nurse with a **confirmed** booking for that patient, and admin — *nobody else*.
### 3.5 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, OTP/
refund-grade rate limiting not required here but keep public review-read sensibly limited):
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/bookings/{booking_id}/review` | `SubmitReviewCommand` | customer (owns booking) |
| `POST /v1/reviews/{id}/tags` | `AttachReviewTagsCommand` | review owner / admin |
| `PATCH /v1/reviews/{id}/status` | `ModerateReviewCommand` | admin / moderator |
| `GET /v1/nurses/{nurse_profile_id}/reviews` | `ListReviewsForNurseQuery` | public |
| `GET /v1/nurses/{nurse_profile_id}/review-tags` | `GetTagAggregatesQuery` | public |
| `GET /v1/admin/reviews/moderation-queue` | `GetReviewModerationQueueQuery` | admin |
| `POST /v1/patients/{patient_id}/care-records` | `WritePatientCareRecordCommand` | nurse (confirmed booking) |
| `GET /v1/patients/{patient_id}/care-records` | `GetPatientHistoryQuery` | owning customer / nurse w/ confirmed booking / admin |
### 3.6 Out of scope (DEFERRED — build the seam/hook, not the feature)
- Two-way (nurse-reviews-customer) double-blind reviews with timed reveal — **(DEFERRED)**, see
`product/business/11-reviews-trust-and-safety.md` (c).
- First-class `incidents` entity + ML fraud scoring — **(DEFERRED)**; manual suspension + `support_alerts`
cover it now.
- The ticket system, partner centers, and the admin **support-alert worklist console**
**(DEFERRED → [backend-phase-15](backend-phase-15.md))**. You *raise* alerts here; b15 builds the worklist.
- `SuspendNurse` / `ResolveSupportAlert` / `FlagConcern` admin actions — **(DEFERRED → b15)** with the
support backoffice.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IReviewModerationService`** (AI moderation) — **INTRODUCED here** | this phase | `Task<ModerationVerdict> ScreenAsync(string reviewText, CancellationToken)` returning a verdict (`Approve`/`Flag`/`Reject` + reason). Mock = a **keyword filter / pass-through**: clean text → `Approve` (or "needs human review" per config), banned-word hit → `Flag`. No external call. Selection by config/registration. | **add row** |
| `IFieldEncryptor` (field encryption) — **REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Clinical notes go through it. Do not redefine. | reuse |
| `INurseSearch` maintenance hook — **REUSE from [b7](backend-phase-7.md)** | b7 | refreshes the nurse aggregate in `nurse_search_index`. Call it after every aggregate-changing transition. | reuse |
| `support_alerts` `RaiseSupportAlert`**REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row. Call it on low ratings. | reuse |
| `INotificationDispatcher`**REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). Optional review-outcome notice. | reuse |
Register `IReviewModerationService` (interface in `Application/Contracts/`, mock impl in Infrastructure) via
a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked,
config keys (e.g. banned-word list, auto-approve toggle), how to make it real (point `ScreenAsync` at a real
text classifier/LLM endpoint without touching `ModerateReviewCommand`), status 🟡.
## 5. Critical rules you must not get wrong
- **Review eligibility — completed/closed bookings only.** Create a review **only** for a booking in the
completed/closed status, owned by the calling customer. **Never** for `cancelled`/`expired`/in-progress
bookings, non-existent bookings, or another customer's booking. (Anti-fraud, brand integrity.)
- **Enforce 1:1 — no duplicate reviews.** A unique constraint on `reviews.booking_id` is the authoritative
backstop; the handler also checks first and returns a clean `OperationResult` failure, never a raw DB
exception, on a second submit.
- **Recompute the nurse aggregate on EVERY transition, FROM SOURCE.** Publish, hide, reject, **and**
unpublish must all recompute `nurse_profiles.average_rating`/`review_count` from the nurse's **currently
`published`** reviews — `AVG`/`COUNT` over the source set, **not** an incremental `+delta`/`-delta`. This
is the explicit fix for the inflated-rating-after-hide drift: hiding a 1-star lowers the count and
re-derives the average. Do it transactionally with the status change, then refresh the b7 search index.
- **Publish gate — `pending_moderation` is NEVER public.** Reviews default to `pending_moderation` and must
never be rendered by any public/customer-facing query. `ListReviewsForNurseQuery` returns **`published`
only**; the aggregate counts **`published` only**. Filter at the query layer, not just the UI.
- **`patient_care_records` is PATIENT-scoped, not booking-scoped.** A new nurse taking over **must** read the
prior history before accepting — do not silo notes per booking. The scoping key is `patient_id`;
`booking_id` is nullable provenance only.
- **Strict clinical access + encrypted at rest.** `patient_care_records` are readable **only** by: the
owning customer (the patient's `customer_profile`), nurses with a **confirmed** booking for that patient,
and admin. Enforce in the authorization layer (not just the route policy). All clinical fields are
**encrypted via `IFieldEncryptor`** — never store, log, or project plaintext clinical content; decrypt only
after the access check passes. A nurse **without** a confirmed booking for that patient is **denied** read
and write.
- **Low-rating → `support_alerts` must fire reliably.** It is a **safety signal**. On a review at/below
`min_rating_for_support_alert` (config, default 2), raise the alert in the same flow; a failure to raise
must surface (it is not a best-effort fire-and-forget that can be silently swallowed).
- **`support_alerts` are internal-only.** They must never appear in any user-facing response or join. You
*raise* them; their worklist UI is b15.
- **Append-only audit.** Every review transition writes `audit_logs` via the interceptor — never mutate or
delete prior audit rows. The low-rating threshold is **config-driven** (read via the b1 typed accessor),
never hard-coded.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `reviews`, `review_tags_master`, `review_tag_links`, `patient_care_records` exist via one additive
migration with the constraints in §3.1 (unique `booking_id`, `rating` CHECK 15, unique
`(review_id, review_tag_master_id)`); `review_tags_master` is seeded.
- [ ] `SubmitReview`/`ModerateReview`/`AttachReviewTags`/`ListReviewsForNurse`/`GetReviewModerationQueue`/
`GetTagAggregates`/`WritePatientCareRecord`/`GetPatientHistory` are implemented as CQRS features with
validators and the §3.5 endpoints, returning the standard `OperationResult` envelope.
- [ ] Every moderation transition recomputes `nurse_profiles` **from source** and triggers the b7 search
refresh; the recompute is covered by a test that proves hide lowers both count and average.
- [ ] Low rating raises a `support_alerts` row using the b1 API and the config threshold; verified by a test.
- [ ] `patient_care_records` are encrypted at rest via `IFieldEncryptor` and gated by the strict clinical
access rule; a nurse without a confirmed booking is denied (tested).
- [ ] `IReviewModerationService` is introduced behind a DI seam with a keyword/pass-through mock and a
registry row.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/reviews-records.md` is written and the `swagger.json` snapshot is
refreshed; the `server/CLAUDE.md` *Project map* notes the two new feature areas + the
`IReviewModerationService` seam.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. Expected results below become the "what can be tested" section of your report.
1. **Submit on a completed booking → accepted.** As the owning customer, `POST /v1/bookings/{completed_id}/review`
with `rating: 5`. → `200`, review created with `moderation_status: pending_moderation`. It does **not**
appear in `GET /v1/nurses/{id}/reviews` yet (publish gate).
2. **Submit on a cancelled booking → rejected.** Same call against a `cancelled`/`expired` booking → an
`OperationResult` failure (not a 500). A second submit on the already-reviewed booking → failure (1:1).
3. **Moderate publish recomputes up.** `PATCH /v1/reviews/{id}/status` `publish` → review appears in the
public list; `GET /v1/nurses/{id}/reviews` aggregate `review_count` increments and `average_rating`
reflects it.
4. **Moderate hide recomputes down.** Publish a 5★ and a 1★, then `hide` the 1★ → public list drops it and
the aggregate `average_rating` rises / `review_count` decrements (re-derived from source — not stale).
5. **Low rating raises an alert.** Submit `rating: 1` → a `support_alerts` row of the low-rating type exists
(visible only on the admin/internal path, never in any user response).
6. **Write + read a care record with access control.** As a nurse **with a confirmed booking** for patient
P, `POST /v1/patients/{P}/care-records` with a clinical note → `200`; the stored column is ciphertext (not
plaintext). As the owning customer or that nurse, `GET /v1/patients/{P}/care-records` → decrypted note
returned, newest first.
7. **Unauthorized nurse denied.** As a nurse **without** any confirmed booking for patient P, both the write
and the read of P's care records → `403`/access-denied `OperationResult`.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Reviews` and
`Features/PatientCareRecords` areas, the four new tables + their config folder, and the
`IReviewModerationService` seam (where it's registered). If you had to add the aggregate columns to
`nurse_profiles`, note it. If you discovered/decided any business rule not already in the product docs,
reflect it in [`product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
or [`product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/reviews-records.md`** (the §3.5 routes, request/
response shapes, the `moderation_status` enum, the `review_tag` codes, the care-record access-rule matrix,
status codes, examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
so [frontend-phase-13-b14](../frontend/frontend-phase-13-b14.md) can derive its types (it does not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-14.md` (reviews +
care-records endpoints are live; what f13 can now build; what's mocked — the AI moderation seam; the
publish-gate and clinical-access rules the frontend must respect), append your phase summary to
`shared-working-context/backend/STATUS.md`, write `reports/backend-phase-14-report.md` (what was built,
what is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts
produced, follow-ups for b15), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
`IReviewModerationService` row → 🟡.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **recompute-from-
source (not delta)** rule and where it's invoked, the **patient-scoped (not booking-scoped)** care-record
access matrix, and the `IReviewModerationService` seam selection — with a one-line `MEMORY.md` pointer.
+507
View File
@@ -0,0 +1,507 @@
# Backend Phase 15 — Messaging (tickets), partner centers & admin backoffice
> **Mission:** close the platform's operational loop. Build the **ticket system** that is the *only*
> sanctioned post-booking communication channel — nurse and customer coordinate under full admin
> visibility, with admin-only **internal notes** that can never leak to users and **no direct
> nurse↔customer side-channel**. Stand up **`partner_centers`** — the licensed home-nursing center that
> sponsors nurses and, when it is the **merchant-of-record**, becomes the legal invoice issuer and
> settlement target (not the platform). Finally, **consolidate the admin backoffice**: tie the verification
> queue (b6), refund tooling (b11), payout dashboard (b13), review moderation queue (b14), config/holiday/
> audit (b1), and the support-alert worklist (b1) into one RBAC-gated, fully audited admin surface. This is
> the last backend phase — it makes the support, admin, and partner UIs buildable.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (users/profiles/`nurse_profiles`), [backend-phase-1](backend-phase-1.md) (`notifications`, `support_alerts`, `platform_configs`, `audit_logs`, RBAC, holidays), [backend-phase-11](backend-phase-11.md) (`refunds` + the ticket-link hook, `invoices`), [backend-phase-13](backend-phase-13.md) (payout batches/dashboard), [backend-phase-14](backend-phase-14.md) (review moderation queue) · **Unlocks:** the support + admin + partner UIs ([frontend-phase-14-b15](../frontend/frontend-phase-14-b15.md), [frontend-phase-15-b15](../frontend/frontend-phase-15-b15.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **final backend phase**. Every domain the admin backoffice acts on already exists; every signal
the support team triages is already being raised. This phase adds the three remaining pieces and then
*wires them together*:
1. **Messaging (tickets)** — the structured, admin-readable communication channel. A booking-scoped
coordination ticket lets the nurse and customer arrange logistics under admin visibility; tickets also
anchor refund conversations and support requests. There is deliberately **no live chat and no direct
nurse↔customer messaging** — this is an anti-disintermediation and patient-safety design.
2. **Partner centers** — the licensed center (Asanism-style) that **sponsors** nurses and, at launch, is
plausibly the **merchant-of-record**. When it is, *it* is the legal invoice issuer and the IPG settles to
*its* IBAN — invoices and settlement follow `partner_centers`, never a hardcoded platform.
3. **Admin backoffice consolidation** — the verification queue, refunds, payouts, review moderation, config/
holiday/audit, and the support-alert worklist are each already built in their own phases. This phase does
**not** rebuild them; it exposes them under a single **RBAC-gated, audited** admin surface and adds the
one missing admin worklist — the **support-alert console** (raise API exists since b1; the assign/resolve/
list worklist is built here).
**What already exists (do not rebuild):**
- **Users, roles & profiles** — [backend-phase-3](backend-phase-3.md) built `nurse_profiles`,
`customer_profiles`, `patients`, and `nurse_bank_accounts`. **`nurse_profiles.partner_center_id`** is the
sponsorship FK this phase *sets* (add the column to the b3 entity in place if it is not already there, and
note it in your report — do **not** fork a parallel table). [backend-phase-2](backend-phase-2.md) built
`users` (+ gender, national_id) and sessions; [backend-phase-1](backend-phase-1.md) built the admin **RBAC**
(`roles`/`user_roles`, scopes `super_admin`/`admin`/`support`/`finance`/`moderator`) you authorize every
admin endpoint against.
- **Platform signals, config, audit, holidays** — [backend-phase-1](backend-phase-1.md) built
**`notifications`** (typed in-app write + list/unread-count/mark-read + 90-day retention),
**`support_alerts`** (the internal-only worklist *table* + the `RaiseSupportAlert` raise API),
`platform_configs` (typed cached accessor — incl. `platform_fee_rate`, `vat_rate`),
**`audit_logs`** (append-only, written by the SaveChanges interceptor on sensitive entities), and
`iranian_holidays`. **Reuse all of these.** This phase *consumes* them — it builds the `support_alerts`
assign/resolve/list **worklist** and the audit **viewer**, it does not redefine the tables.
- **Refunds, invoices & the ticket-link hook** — [backend-phase-11](backend-phase-11.md) built `refunds`
(admin-only, ticket-linked, fee/payout decomposition, channel-aware), `nurse_clawbacks`, and `invoices`
(VAT on commission, `issuing_entity_type`). b11 created `refunds.ticket_id` and the *expectation* that a
ticket anchors every admin refund; **this phase ships the ticket system that link points at** and wires
`OpenTicket` into the refund flow (§3.2). The `invoices.issuing_entity_type` / center-issuer resolution
that `partner_centers` drives is consumed by b11's `GenerateCommissionInvoice` — you provide
`GetCenterForBooking` (§3.4) as the resolver.
- **Payouts** — [backend-phase-13](backend-phase-13.md) built `nurse_payout_batches`, `nurse_payouts`,
`nurse_payout_booking_links` (booking_id UNIQUE), the weekly holiday-aware batch, clawback netting, and the
payout dashboard queries. The admin backoffice **surfaces** these read models — it does not re-implement
payout logic.
- **Review moderation** — [backend-phase-14](backend-phase-14.md) built `GetReviewModerationQueueQuery` +
`ModerateReviewCommand` and raises low-rating `support_alerts`. The backoffice **surfaces** the moderation
queue and the alerts it raises.
- **Verification** — [backend-phase-6](backend-phase-6.md) built `nurse_verifications` + the admin review
queue (`ListVerificationQueue`, the guarded `is_verified` flip). The backoffice **surfaces** that queue.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced **`IFieldEncryptor`** (used here
for `partner_centers.settlement_iban`), `ICacheService`, `IDateTimeProvider`, and
**`INotificationDispatcher`**; [backend-phase-3](backend-phase-3.md) introduced
**`IBankAccountOwnershipVerifier`** (IBAN ownership — reused if you verify a center's settlement IBAN).
**Reuse these; do not redefine them.**
> **DEFERRED — modeled-but-inactive, do NOT build/migrate in this phase** (they are pure additive migrations
> for future features and must stay unreferenced by launch flows): `organizations`, `organization_nurses`
> (the future *employer* model — distinct from `partner_centers`, the launch *sponsor*), `fraud_flags` (ML
> output; rule-based `support_alerts` `fraud_signal` covers it manually), `recurring_booking_schedules`
> (`booking_sessions` already meets the concrete multi-day need). See
> [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
- [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md)
— the no-direct-channel rule, the auto-created booking-coordination ticket, internal notes, tickets as the
**mandatory anchor for admin refunds**, and the on-site **emergency playbook** (call the surfaced
emergency contact, then open a ticket — operational, not a schema feature).
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md)
— the RBAC scopes that gate verify/refund/payout/moderate, the append-only audit requirement, the
support-alert console, and the holiday-aware backoffice reasoning.
- [`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md)
— the seller split (platform invoices its **commission** only), the **partner licensed-center** launch
vehicle, merchant-of-record resolution, and why operating outside a licensed vehicle is the real legal
risk.
- **Product — data model (source of truth):**
- [`product/data-model/09-messaging.md`](../../../product/data-model/09-messaging.md) — `tickets` /
`ticket_participants` / `ticket_messages`, `is_internal`, `reference_code`, `UNIQUE(ticket_id, user_id)`,
optional `bookings`/`refunds` links.
- [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md)
— the exact `partner_centers` field list (read the **"Why"** notes — they encode the merchant-of-record
and partner-vs-organization guards), and the four DEFERRED tables that stay inactive.
- **Prior contracts you consume / extend:**
- [backend-phase-11](backend-phase-11.md)'s contract `dev/contracts/domains/payments-refunds.md` (or
equivalent) for the `refunds.ticket_id` link and `invoices.issuing_entity_type` you resolve.
- [backend-phase-1](backend-phase-1.md)'s handoff + contract for the `RaiseSupportAlert` signature, the
`support_alerts` shape, the `notifications` write, RBAC scopes, and the typed config accessor.
- [backend-phase-6](backend-phase-6.md) / [backend-phase-13](backend-phase-13.md) /
[backend-phase-14](backend-phase-14.md) handoffs for the verification-queue, payout-dashboard, and
moderation-queue read models the backoffice surfaces.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult` — never throw), an `IEntityTypeConfiguration<T>` under
`Persistence/Configuration/<Area>Config/`, a controller under `Baya.Web.Api/Controllers/V1/` (`sealed`,
`BaseController`, inject `ISender`, `[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`,
narrowest authorize policy), how prior phases call `IFieldEncryptor` for encrypted columns (b3 IBAN, b9 care
instructions), and how b14 *raised* a `support_alert` (you build the worklist that resolves them).
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routes, status codes, pagination) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (the merchant-of-record / settlement
fields touch money — keep IRR `BIGINT`, no floats, and follow the id/enum/timestamp format rules).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`. Admin endpoints are
**RBAC-gated and audited** (the SaveChanges interceptor writes `audit_logs` on every state change).
### 3.1 Entities, configs & migration
Add these tables as a single additive EF Core migration. One `IEntityTypeConfiguration<T>` per entity, in
`Persistence/Configuration/MessagingConfig/` and `Persistence/Configuration/PartnerCentersConfig/`.
- **`tickets`** — root of all post-booking communication.
- Columns: `id` (BIGINT PK), `reference_code` (NVARCHAR, **UNIQUE**, human-facing support id —
stable once minted, quoted to users), `subject` (NVARCHAR, nullable), `status` (enum:
`open` | `closed`; default `open`), `category`/`type` (enum, e.g. `coordination` | `support` |
`refund` | `emergency`), `booking_id` (FK → `bookings`, **nullable** — optional link),
`refund_id` (FK → `refunds`, **nullable** — optional link), `opened_by_id` (FK → users),
`closed_at` (datetimeoffset, nullable), `closed_by_id` (FK → users, nullable), plus audit fields
(`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete.
- Indexes: unique on `reference_code`; index on `status`; index on `booking_id` and `refund_id` for the
"tickets for this booking/refund" lookups; the admin list filters by `(status, CreatedAt)`.
- Soft-delete query filter (`!IsDeleted`).
- **`ticket_participants`** — who is on a thread.
- Columns: `id` (BIGINT PK), `ticket_id` (FK → `tickets`), `user_id` (FK → users), `role_on_ticket`
(enum: `customer` | `nurse` | `admin`, nullable/derived), `added_by_id` (FK → users, nullable),
`removed_at` (datetimeoffset, nullable — soft-remove, or hard delete per your soft-delete convention),
plus audit.
- **UNIQUE `(ticket_id, user_id)`** — the authoritative backstop against adding a user twice.
- Index on `(user_id, ticket_id)` for `ListMyTickets`.
- **`ticket_messages`** — individual messages.
- Columns: `id` (BIGINT PK), `ticket_id` (FK → `tickets`), `sender_id` (FK → users), `body` (NVARCHAR),
**`is_internal` (BIT, default 0)** — admin-only note; the **hard visibility boundary**, `sent_at`
(datetimeoffset), plus audit and soft-delete.
- Index on `(ticket_id, sent_at)` for the thread read.
- **`partner_centers`** — the licensed sponsor center (fields from
[`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md),
exact):
- `id` (BIGINT PK), `name` (NVARCHAR(300)), `legal_entity_type` (NVARCHAR(30)),
`moh_establishment_permit_no` (NVARCHAR(100) — پروانه تأسیس),
`technical_director_nurse_user_id` (BIGINT FK → users, **NULL** — مسئول فنی),
`technical_director_license_no` (NVARCHAR(100), NULL), `enamad_code` (NVARCHAR(100), NULL — نماد اعتماد
الکترونیکی), **`settlement_iban` (NVARCHAR(34), encrypted via `IFieldEncryptor`, NULL)** — only when
merchant-of-record, `is_merchant_of_record` (BIT), `commission_rate` (DECIMAL(5,4), NULL — the **center's
cut**, separate from `platform_fee_rate`), `admin_user_id` (BIGINT FK → users — the center's dashboard
account), `is_active` (BIT), `verified_at` (datetimeoffset, nullable), plus audit + soft-delete.
- Relations: 1:N → `nurse_profiles` (sponsors, via `nurse_profiles.partner_center_id`), `bookings` (legally
covered by), `invoices` (issuer). Index on `is_active`; index on `admin_user_id` for the center portal
scope.
- **`nurse_profiles.partner_center_id`** — FK → `partner_centers`, **NULL** (NULL once Balinyaar holds its
own permit). Add to the b3 entity in place; note it in your report.
> **Do NOT create** `organizations`, `organization_nurses`, `fraud_flags`, or `recurring_booking_schedules`
> in this phase. They are DEFERRED and must stay unreferenced. (See §1.)
### 3.2 Messaging — commands & queries
Feature folder `Baya.Application/Features/Messaging/`.
- **`OpenTicketCommand`** (`Commands/OpenTicket/`) — create a ticket, **mint a unique stable
`reference_code`** (e.g. a collision-checked short code; the UNIQUE index is the backstop), attach the
optional `booking_id`/`refund_id` (handle **null** — both links are optional), set `category`, and add the
**opener as the first `ticket_participant`**. Used by: the customer/nurse "Contact support" flow, the
refund flow (b11 anchors its `refunds.ticket_id` here), and `LogEmergencyTicket` (below).
- FluentValidation: subject/body length bounds; if `booking_id`/`refund_id` supplied, they must exist and
the opener must be a party to them (tenancy).
- **`AutoCreateCoordinationTicketCommand`** (`Commands/AutoCreateCoordinationTicket/`) — on **booking
confirmation** (invoked by the b9 confirmation flow, or by a domain hook), auto-create a
`category=coordination` ticket linked to the `booking_id` and add **nurse + customer** as participants. One
coordination ticket per booking (idempotent — re-confirmation must not create a second).
- **`PostMessageCommand`** (`Commands/PostMessage/`) — a participant appends a `ticket_message`. **Admins may
set `is_internal=true`**; a **non-admin caller can never set `is_internal` and can never post to a closed
ticket**. Guard: only ticket participants (or admins) may post. Optionally raise an in-app notification to
the *other* (non-internal) participants via `INotificationDispatcher` (reuse — no push at MVP).
- **`AddParticipantCommand`** / **`RemoveParticipantCommand`** (`Commands/AddParticipant/`,
`Commands/RemoveParticipant/`) — admin (or the ticket owner, per policy) attaches/detaches a user. Enforce
the **`UNIQUE(ticket_id, user_id)`**: a duplicate add returns a clean `OperationResult` failure, never a raw
DB exception. Admin may attach to any ticket for full read.
- **`CloseTicketCommand`** / **`ReopenTicketCommand`** (`Commands/CloseTicket/`, `Commands/ReopenTicket/`) —
status transitions `open``closed`/`closed``open`, stamping `closed_at`/`closed_by_id`; the owner trail
comes from the audit interceptor.
- **`LogEmergencyTicketCommand`** (`Commands/LogEmergencyTicket/`) — convenience path: after an emergency
call, a nurse opens a `category=emergency` ticket and **optionally raises a `support_alerts` row** (reuse
the b1 raise API). This is the *operational* side of the emergency playbook — it does **not** dial anyone
and does **not** expose a phone number (the emergency contact is surfaced from encrypted
`booking_care_instructions`, owned by b9; see §5).
- **`GetTicketThreadQuery`** (`Queries/GetTicketThread/`) — returns the ordered messages for a ticket the
caller may see. **Role-aware at the QUERY layer:** the **USER view filters out every `is_internal`
message**; the **ADMIN view returns all**. This filter lives in the query projection, not the UI — an
internal note must never appear in any user-facing payload (see §5). `AsNoTracking()` + `.Select()`.
- **`ListMyTicketsQuery`** (`Queries/ListMyTickets/`) — paginated tickets where the caller is a participant,
filterable by `status` and searchable by `reference_code`, newest first.
- **`ListTicketsForAdminQuery`** (`Queries/ListTicketsForAdmin/`) — **admin**, paginated global queue;
filter by `status`/`category`, search by `reference_code`, optional `booking_id`/`refund_id`.
### 3.3 Partner centers — commands & queries
Feature folder `Baya.Application/Features/PartnerCenters/`.
- **`CreatePartnerCenterCommand`** / **`UpdatePartnerCenterCommand`** (`Commands/CreatePartnerCenter/`,
`Commands/UpdatePartnerCenter/`) — capture `name`, `legal_entity_type`, `moh_establishment_permit_no`,
`technical_director_nurse_user_id` + `technical_director_license_no`, `enamad_code`,
**`settlement_iban` (encrypt via `IFieldEncryptor` before persisting — never plaintext)**,
`is_merchant_of_record`, **`commission_rate`** (the center's cut), `admin_user_id`. Admin-only. If a
settlement IBAN is provided and the center is merchant-of-record, optionally verify ownership via the
reused **`IBankAccountOwnershipVerifier`** (b3) before activation.
- FluentValidation: `commission_rate` in `[0, 1)`; `settlement_iban` required when
`is_merchant_of_record=1`; `moh_establishment_permit_no` non-empty.
- **`VerifyPartnerCenterCommand`** (`Commands/VerifyPartnerCenter/`) — sets `verified_at` and `is_active`.
At MVP the licensing check (eNamad / MoH establishment-permit) is a **manual admin approval** behind the
new **`ILicenseVerificationService`** seam (§4) — the command records the decision; the seam call is the
swap point for a real registry/API later.
- **`SponsorNurseCommand`** (`Commands/SponsorNurse/`) — set `nurse_profiles.partner_center_id` to link a
nurse to its sponsoring center (and a corresponding unlink/`null` path once Balinyaar holds its own
permit). Admin-only. The center's dashboard account (`admin_user_id`) may sponsor within its own center.
- **`GetCenterForBookingQuery`** (`Queries/GetCenterForBooking/`) — resolve **which center legally covers a
booking** (via the booking's nurse → `nurse_profiles.partner_center_id`, falling back to platform when
`NULL`). Returns the issuer/settlement decision: `issuing_entity_type` (`platform` | `partner_center`),
the center id (when applicable), and whether the center is merchant-of-record. **This is the resolver b11's
`GenerateCommissionInvoice` calls** to set the invoice issuer and the settlement target — invoices and
settlement follow `partner_centers`, never a hardcoded platform.
- **`ListPartnerCentersQuery`** / **`GetPartnerCenterByIdQuery`** (`Queries/ListPartnerCenters/`,
`Queries/GetPartnerCenterById/`) — admin, paginated, with the **sponsored-nurse count**. The detail view
**never returns the plaintext or full `settlement_iban`** — mask it (last 4) per the money-and-types masking
convention.
- **Center dashboard read models** (`Queries/GetCenterDashboard/` and friends, scoped to the center's
`admin_user_id`): list **sponsored nurses**, **sponsored bookings** (bookings whose nurse the center
sponsors), and the **settlement/invoice view** (the center's invoices when it is merchant-of-record). These
surface b13 payout / b11 invoice read models filtered to the center — they do not re-implement that logic.
### 3.4 Refund↔ticket link (wire b11 to the ticket system)
b11 created `refunds.ticket_id` and the rule that **every admin refund hangs off a ticket** (dispute paper
trail). This phase ships the ticket system that link targets. Wire it: when a refund is initiated (b11's
`InitiateRefund`), ensure a ticket exists (open a `category=refund` ticket via `OpenTicketCommand` if the
flow didn't already, or attach to the existing one) and set `refunds.ticket_id`. **Do not move refund money
logic into this phase** — only provide/confirm the ticket the refund anchors to. If b11 already opens the
ticket itself, this phase just provides the `OpenTicketCommand` it calls and confirms the link is non-null.
### 3.5 Support-alert worklist (build the console on b1's raise API)
Feature folder `Baya.Application/Features/SupportAlerts/`. b1 built the `support_alerts` *table* and the
`RaiseSupportAlert` API (and b14/b6/b9/b13 are the producers). Build the **admin worklist**:
- **`ListSupportAlertsQuery`** (`Queries/ListSupportAlerts/`) — **admin/support**, paginated, filter by
`type`/`status`/`owner`. `AsNoTracking()` + `.Select()`. **Internal-only** — these must never appear in any
user-facing endpoint or join.
- **`AssignSupportAlertCommand`** (`Commands/AssignSupportAlert/`) — set the owner.
- **`ResolveSupportAlertCommand`** (`Commands/ResolveSupportAlert/`) — set status + resolution trail.
### 3.6 Admin backoffice consolidation (surface, don't rebuild)
Expose the existing read models / commands under one **admin RBAC** surface (the b1 scopes). **Do not
re-implement** any of the underlying logic — these endpoints delegate to handlers built in prior phases:
| Backoffice area | Surfaces (built in) | RBAC scope |
| --- | --- | --- |
| Verification review queue | `ListVerificationQueue` + approve/reject ([b6](backend-phase-6.md)) | `admin` (verify) |
| Refund tooling | `InitiateRefund`/`ApproveRefund`/`RejectRefund` ([b11](backend-phase-11.md)) | `finance` (refund) |
| Payout dashboard | `ListPayoutBatches`/`GetPayoutBatch` ([b13](backend-phase-13.md)) | `finance` (payout) |
| Review moderation queue | `GetReviewModerationQueue` + `ModerateReview` ([b14](backend-phase-14.md)) | `moderator` |
| Config / holiday / audit | config CRUD, holiday calendar, **audit-log viewer** ([b1](backend-phase-1.md)) | `super_admin`/`admin` |
| Support-alert worklist | §3.5 (this phase) | `support`/`admin` |
| Tickets (admin) | §3.2 admin queries (this phase) | `support`/`admin` |
| Partner centers | §3.3 (this phase) | `admin`/`super_admin` |
Add the **audit-log viewer** query if b1 did not already expose it: **`ListAuditLogsQuery`**
(`Queries/ListAuditLogs/`) — read-only, paginated, filter by `entity_type`/`entity_id`/`actor`/date. (If b1
already shipped it, reuse it and note so — do not duplicate.)
### 3.7 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy). Admin
endpoints are **internal-only** (admin RBAC) and audited.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/tickets` | `OpenTicketCommand` | authenticated |
| `POST /v1/tickets/{id}/messages` | `PostMessageCommand` | participant (admin may set internal) |
| `POST /v1/tickets/{id}/participants` | `AddParticipantCommand` | admin / owner |
| `DELETE /v1/tickets/{id}/participants/{user_id}` | `RemoveParticipantCommand` | admin / owner |
| `POST /v1/tickets/{id}/close` | `CloseTicketCommand` | participant / admin |
| `POST /v1/tickets/{id}/reopen` | `ReopenTicketCommand` | participant / admin |
| `POST /v1/tickets/emergency` | `LogEmergencyTicketCommand` | nurse (assigned) |
| `GET /v1/tickets` | `ListMyTicketsQuery` | authenticated (own) |
| `GET /v1/tickets/{id}` | `GetTicketThreadQuery` (**user view: internal stripped**) | participant |
| `GET /v1/admin/tickets` | `ListTicketsForAdminQuery` | `support`/`admin` |
| `GET /v1/admin/tickets/{id}` | `GetTicketThreadQuery` (**admin view: all**) | `support`/`admin` |
| `POST /v1/admin/partner-centers` | `CreatePartnerCenterCommand` | `admin`/`super_admin` |
| `PATCH /v1/admin/partner-centers/{id}` | `UpdatePartnerCenterCommand` | `admin`/`super_admin` |
| `POST /v1/admin/partner-centers/{id}/verify` | `VerifyPartnerCenterCommand` | `admin`/`super_admin` |
| `POST /v1/admin/partner-centers/{id}/sponsor-nurse` | `SponsorNurseCommand` | `admin`/`super_admin` |
| `GET /v1/admin/partner-centers` | `ListPartnerCentersQuery` | `admin`/`super_admin` |
| `GET /v1/admin/partner-centers/{id}` | `GetPartnerCenterByIdQuery` (**IBAN masked**) | `admin`/`super_admin` |
| `GET /v1/centers/{id}/dashboard` | `GetCenterDashboardQuery` | center `admin_user_id` |
| `GET /v1/internal/bookings/{booking_id}/center` | `GetCenterForBookingQuery` | internal/admin |
| `GET /v1/admin/support-alerts` | `ListSupportAlertsQuery` | `support`/`admin` |
| `PATCH /v1/admin/support-alerts/{id}/assign` | `AssignSupportAlertCommand` | `support`/`admin` |
| `PATCH /v1/admin/support-alerts/{id}/resolve` | `ResolveSupportAlertCommand` | `support`/`admin` |
| `GET /v1/admin/audit-logs` | `ListAuditLogsQuery` | `super_admin`/`admin` |
> Verification-queue, refund, payout-dashboard, moderation-queue, and config/holiday endpoints are the ones
> their own phases already published — surface them under the admin route group with the RBAC scope in §3.6;
> do **not** redefine their handlers.
### 3.8 Out of scope (DEFERRED — do not build/migrate)
- **`organizations` / `organization_nurses`** (future employer model) — **(DEFERRED)**; distinct from
`partner_centers`. See [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md).
- **`fraud_flags`** (ML fraud output) — **(DEFERRED)**; rule-based `support_alerts` `fraud_signal` covers it.
- **`recurring_booking_schedules`** (RFC-5545 recurrence) — **(DEFERRED)**; `booking_sessions` meets the need.
- **Real-time chat / SLA-bearing `incidents` entity / push delivery** — **(DEFERRED)**; emergencies stay an
operational playbook ([`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md) (c)).
- **Real eNamad / MoH establishment-permit / مودیان integrations** — mocked behind seams (this phase
introduces `ILicenseVerificationService`; `IMoadianClient` belongs to [b11](backend-phase-11.md)).
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`ILicenseVerificationService`** (eNamad / MoH establishment-permit) — **INTRODUCED here** | this phase | e.g. `Task<LicenseVerdict> VerifyEstablishmentPermitAsync(string permitNo, CancellationToken)` and `VerifyENamadAsync(string enamadCode, …)` returning a verdict (`Valid`/`Invalid`/`NeedsManualReview` + reason). Mock = **manual admin approve** (returns `NeedsManualReview`, so `VerifyPartnerCenter` records the human decision). No external call. Selection by config/registration. | **add row** |
| `IFieldEncryptor`**REUSE from [b0](backend-phase-0.md)** | b0 | local symmetric key; `Encrypt`/`Decrypt`. Used for `partner_centers.settlement_iban`. Do not redefine. | reuse |
| `IBankAccountOwnershipVerifier`**REUSE from [b3](backend-phase-3.md)** | b3 | IBAN ownership inquiry; optionally used to verify a center's `settlement_iban` when merchant-of-record. | reuse |
| `INotificationDispatcher`**REUSE from [b0](backend-phase-0.md)/[b1](backend-phase-1.md)** | b0/b1 | in-app write (no push at MVP). New-ticket-message alerts to the other participant. | reuse |
| `RaiseSupportAlert` (on `support_alerts`) — **REUSE from [b1](backend-phase-1.md)** | b1 | inserts an internal alert row; used by `LogEmergencyTicket`. The worklist that resolves them is built here. | reuse |
Register `ILicenseVerificationService` (interface in `Application/Contracts/`, mock impl in Infrastructure)
via a `ServiceConfiguration/` extension — never inline in `Program.cs`. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with: seam, file, what's faked
(manual approval at MVP), config keys (auto-approve toggle), how to make it real (point the methods at a real
eNamad / MoH registry/API without touching `VerifyPartnerCenter`), status 🟡. There is **no telephony/VoIP
seam** — the emergency call is out-of-platform by design (a `tel:` link in the UI); do not build a calling
seam.
## 5. Critical rules you must not get wrong
- **No direct nurse↔customer channel.** All post-booking communication is **ticket-mediated and
admin-readable**. **Never expose the family's phone number to the nurse or vice-versa**, and never build a
side-channel. The **emergency-contact playbook is the only sanctioned bypass** — it is *operational*, not a
contact-sharing feature: the emergency contact lives in encrypted `booking_care_instructions` (owned by b9,
surfaced post-confirmation to the assigned nurse only via the two-stage clinical disclosure gate); this
phase's `LogEmergencyTicket` records the *aftermath* (a ticket, optionally an alert), it does **not** widen
that disclosure. Keep it scoped to emergencies; do not turn it into a general contact directory.
- **`is_internal` is a HARD visibility boundary, enforced at the QUERY layer.** Admin-only internal notes
must **never** leak into any user-facing query, payload, or join. `GetTicketThreadQuery`'s **user view
strips every `is_internal` message** in the projection; only the **admin view** returns them. A non-admin
caller can never *set* `is_internal` on `PostMessage` and can never *read* one. This is enforced in the
query/serialization layer, not just the UI.
- **`reference_code` is unique + stable.** It is quoted to users; mint it once (collision-checked), back it
with a UNIQUE index, and never mutate it.
- **Ticket↔booking/refund links are optional — handle null.** `booking_id` and `refund_id` are both nullable;
every read/write path must tolerate a ticket with neither link (a pure support ticket).
- **Authorization/tenancy on every ticket read and write.** Only ticket participants (and admins) can read or
post; enforce on both. Admin may attach to any ticket for full read.
- **`UNIQUE(ticket_id, user_id)` on participants** — a duplicate add returns a clean `OperationResult`
failure, never a raw DB exception; the unique index is the authoritative backstop.
- **Merchant-of-record correctness.** If `partner_centers.is_merchant_of_record = 1`, the **CENTER** (not
Balinyaar, not the nurse) is the legal **invoice issuer** and the **settlement target** — invoices and
settlement follow `partner_centers`, **not** a hardcoded platform. `GetCenterForBooking` is the single
resolver b11's invoice issuance calls; it must return the center as issuer/settlement target when the
booking's nurse is sponsored by a merchant-of-record center, and `platform` otherwise. This is a legal/tax
correctness issue, not cosmetic.
- **`partner_centers``organizations`.** Keep the **launch licensing sponsor** (`partner_centers`) distinct
from the future **employer** (`organizations`). Do not conflate "sponsor for legality" with "employer," and
do not repurpose `organizations` for launch (it stays DEFERRED/inactive).
- **`commission_rate` (the center's cut) is separate from `platform_fee_rate`.** Money math must account for
an additional center cut where present; never collapse the two. **Money is IRR `BIGINT` — no floats,
anywhere**; the three booking amounts always satisfy **`gross = commission + payout`**; the `ledger_entries`
on the money path stay **append-only and balanced** (Σdebit = Σcredit per `transaction_group_id`); webhook
handling is **idempotent**; payouts are **dispute-window gated** and **one payout per booking**
(`nurse_payout_booking_links.booking_id` UNIQUE). This phase does not *post* ledger entries, but the
merchant-of-record / settlement-target it resolves feeds those invariants — get the issuer/target right or
the downstream money is wrong.
- **`settlement_iban` is encrypted PII.** Encrypt via `IFieldEncryptor` before persisting; never store, log,
or project it in plaintext; mask it (last 4) in any read response.
- **Deferred tables stay inactive/unreferenced.** `organizations`, `organization_nurses`, `fraud_flags`,
`recurring_booking_schedules` are **not** created or referenced — adding them later must remain a pure
additive migration.
- **Admin endpoints are internal-only, RBAC-scoped, and audited.** Authorize every admin command/query with
the narrowest fitting b1 scope (`support` cannot pay out, `moderator` cannot refund, etc.); every
state-changing admin op writes an **append-only `audit_logs`** row via the interceptor — never mutate or
delete prior audit rows. **`support_alerts` are internal-only** and must never surface in any user-facing
response.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `tickets`, `ticket_participants`, `ticket_messages`, `partner_centers` exist via one additive migration
with the constraints in §3.1 (unique `reference_code`, `UNIQUE(ticket_id, user_id)`, `is_internal`
column, `settlement_iban` encrypted); `nurse_profiles.partner_center_id` FK added; the four DEFERRED
tables are **not** created.
- [ ] `OpenTicket`/`AutoCreateCoordinationTicket`/`PostMessage`/`AddParticipant`/`RemoveParticipant`/
`CloseTicket`/`ReopenTicket`/`LogEmergencyTicket`/`GetTicketThread`/`ListMyTickets`/`ListTicketsForAdmin`
are implemented as CQRS features with validators and the §3.7 endpoints, returning the standard
`OperationResult` envelope.
- [ ] `GetTicketThread` strips `is_internal` in the **user** view and returns it in the **admin** view —
proven by a test (an internal message is hidden from the user thread, shown in the admin thread).
- [ ] `CreatePartnerCenter`/`UpdatePartnerCenter`/`VerifyPartnerCenter`/`SponsorNurse`/`GetCenterForBooking`/
`ListPartnerCenters`/`GetPartnerCenterById` + the center dashboard are implemented; `settlement_iban` is
encrypted at rest and masked in reads; `SponsorNurse` sets `nurse_profiles.partner_center_id`;
`GetCenterForBooking` returns the **merchant-of-record** issuer/settlement target.
- [ ] The refund↔ticket link is wired (§3.4): every admin refund has a non-null `ticket_id`.
- [ ] The support-alert worklist (`ListSupportAlerts`/`AssignSupportAlert`/`ResolveSupportAlert`) is built on
b1's raise API; the admin backoffice **surfaces** verification/refund/payout/moderation/config/holiday/
audit under RBAC scopes without rebuilding their handlers.
- [ ] `ILicenseVerificationService` is introduced behind a DI seam with a manual-approve mock and a registry
row.
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/messaging-notifications-admin.md` is written and the `swagger.json`
snapshot is refreshed; the `server/CLAUDE.md` *Project map* notes the new feature areas (`Messaging`,
`PartnerCenters`, `SupportAlerts`, the admin backoffice route group), the four new tables + the
`nurse_profiles.partner_center_id` FK, and the `ILicenseVerificationService` seam.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. Expected results below become the "what can be tested" section of your report.
1. **Open a ticket + post a message.** `POST /v1/tickets` (no `booking_id`/`refund_id`) → `200`, a ticket
with a unique `reference_code`, the opener added as a participant. `POST /v1/tickets/{id}/messages` with a
normal body → `200`, message appears in the thread.
2. **Internal note is hidden in the user view, shown in the admin view.** As an admin, `POST
/v1/tickets/{id}/messages` with `is_internal: true` → `200`. `GET /v1/tickets/{id}` (user view) → the
internal message is **absent**. `GET /v1/admin/tickets/{id}` (admin view) → the internal message **is
present**. A non-admin attempt to set `is_internal: true` → rejected.
3. **Add/remove participant with uniqueness.** `POST /v1/tickets/{id}/participants` adds a user → `200`;
adding the **same** user again → an `OperationResult` failure (not a 500) from the `UNIQUE(ticket_id,
user_id)`. `DELETE /v1/tickets/{id}/participants/{user_id}` removes them.
4. **Create a partner center + sponsor a nurse.** `POST /v1/admin/partner-centers` with
`is_merchant_of_record: true` and a `settlement_iban` → `200`; `GET /v1/admin/partner-centers/{id}` shows
the IBAN **masked** (last 4), never plaintext. `POST /v1/admin/partner-centers/{id}/sponsor-nurse` →
`nurse_profiles.partner_center_id` is set for that nurse.
5. **`GetCenterForBooking` returns the merchant-of-record.** `GET /v1/internal/bookings/{booking_id}/center`
for a booking whose nurse is sponsored by the merchant-of-record center → `issuing_entity_type:
partner_center` + the center id; for a booking whose nurse is unsponsored → `platform`.
6. **Refund anchors to a ticket.** Initiate a refund (b11 flow) → the resulting `refunds.ticket_id` is
non-null and the ticket is a `category=refund` ticket.
7. **Admin worklists list the pending items.** `GET /v1/admin/support-alerts` lists the alerts b14/b6/b9/b13
raised (internal-only — never in a user response); `PATCH …/assign` then `…/resolve` updates owner +
status. The verification queue, refunds, payout dashboard, and moderation queue are reachable under their
admin routes with the correct RBAC scope (a `support` token is denied the payout route → `403`).
8. **Audit trail.** Any admin state change (e.g. `VerifyPartnerCenter`, `ResolveSupportAlert`) writes an
`audit_logs` row visible via `GET /v1/admin/audit-logs`; prior rows are never mutated.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Messaging`,
`Features/PartnerCenters`, `Features/SupportAlerts` areas, the admin backoffice route group, the four new
tables + their config folders, the `nurse_profiles.partner_center_id` FK, and the
`ILicenseVerificationService` seam (where it's registered). If you discovered/decided any business rule not
already in the product docs, reflect it in
[`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md),
[`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md),
[`product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md),
or [`product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/messaging-notifications-admin.md`** (the §3.7
routes, request/response shapes, the `ticket.status`/`category` and `support_alerts.status` enums, the
`is_internal` user-vs-admin view rule, the `partner_centers` shape with the **masked** `settlement_iban`,
the `GetCenterForBooking` issuer-resolution response, the RBAC scope per admin route, status codes, and
examples) per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md),
and refresh the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md)
so [frontend-phase-14-b15](../frontend/frontend-phase-14-b15.md) (messaging/notifications) and
[frontend-phase-15-b15](../frontend/frontend-phase-15-b15.md) (admin + partner consoles) can derive their
types (they do not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-15.md` (tickets,
partner centers, the support-alert worklist, and the consolidated admin backoffice are live; what f14/f15
can now build; the `is_internal` user-vs-admin rule and the RBAC scope per route the frontend must respect;
what's mocked — the `ILicenseVerificationService` manual-approve seam), append your phase summary to
`shared-working-context/backend/STATUS.md`, write `reports/backend-phase-15-report.md` (what was built, what
is now testable and exactly how — the §7 steps — what is mocked + how to make it real, contracts produced,
follow-ups), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) with the
`ILicenseVerificationService` row → 🟡.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **`is_internal`
hard-boundary at the query layer** (and the user-vs-admin `GetTicketThread` views), the
**merchant-of-record resolution** via `GetCenterForBooking` (issuer + settlement target follow
`partner_centers`, not the platform), the **`partner_centers` ≠ `organizations`** distinction, and the
list of **DEFERRED-inactive tables** — with a one-line `MEMORY.md` pointer. This is the last backend phase;
the memory note should also record that the backend chain is complete.
+334
View File
@@ -0,0 +1,334 @@
# Backend Phase 2 — Identity: phone-OTP auth, sessions & roles (REST)
> **Mission:** give the marketplace its front door. The server already has a working
> phone-OTP/JWE/passwordless-TOTP engine and a dynamic-permission RBAC system — but it is reachable
> **only over gRPC**, with OTP delivery stubbed to a log line and no session, refresh-rotation, or
> role-selection surface. This phase **wraps that existing machinery in REST**: `/auth/otp/request`,
> `/auth/otp/verify`, `/auth/refresh`, `/auth/logout`, `/me`, `/me/role`; adds revocable
> **`user_sessions`** with refresh-token rotation + stolen-token (reuse) detection; extends `users` with
> the load-bearing identity columns (`gender`, `national_id` NULL-until-KYC, `shahkar_verified_at`); and
> wires the real **`ISmsSender`** seam so an OTP actually leaves the building (mock = log the code). Do
> **not** rebuild auth — reuse `JwtService`, `AppUserManager` OTP, and the RBAC. After this phase every
> authenticated feature in the chain has something to authenticate against.
>
> **Track:** backend · **Depends on:** [b0](./backend-phase-0.md) (REST surface, rate limiter, cross-cutting seams), [b1](./backend-phase-1.md) (config accessor, `notifications`, marketplace migration baseline) · **Unlocks:** every authenticated backend feature; frontend **f1-b2**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b2**, the root of the human-actor identity domain and the gate everything else
hangs off — no booking, profile, verification, or payment work can exist without it. The platform has
three actor types — **customer** (payer/family), **nurse** (caregiver/seller), **admin** (back-office) —
and **phone number is the primary login credential** via phone-OTP; email is optional and only required
for admin ([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)).
KYC is role-staged: a customer registers and browses with a verified phone alone; a nurse must later pass
the full verification pipeline (b6) before becoming bookable. This phase delivers **login + session +
role selection** — it deliberately stops short of profiles, patients, and bank accounts (those are b3).
**What already exists (do not rebuild) — confirmed in the codebase:**
- **The whole auth engine.** [b0](./backend-phase-0.md) kept the working spine: ASP.NET Core Identity,
**JWE** access tokens (`Jwt/JwtService : IJwtService``GenerateAsync(User)`,
`GenerateByPhoneNumberAsync`, `GetPrincipalFromExpiredToken`, `RefreshToken(Guid)`), **phone-OTP**
passwordless TOTP (`AppUserManagerImplementation.GenerateOtpCode` / `VerifyUserCode`,
`GeneratePhoneNumberConfirmationToken`, `ChangePhoneNumber`; provider constants in
`Identity/Dtos/CustomIdentityConstants`), and the **dynamic-permission RBAC**
(`DynamicPermissionRequirement`/`Handler`, `DynamicPermissionService.CanAccess`,
`ConstantPolicies.DynamicPermission`, `IRoleManagerService`). **Wrap these — never reimplement them.**
- **The REST surface & rate limiter.** [b0](./backend-phase-0.md) created the first versioned controllers
under `Baya.Web.Api/Controllers/V1/` (sealed, `BaseController`, inject `ISender`, `[controller]`/`[action]`
snake_case tokens, `base.OperationResult(...)`), registered `LoggingBehavior`, and wired `AddRateLimiter`
+ `UseRateLimiter()` with **named per-IP policies** ready for auth/OTP to apply. This phase **applies**
those policies — it does not define new middleware.
- **Cross-cutting seams.** [b0](./backend-phase-0.md) introduced and DI-registered `IDateTimeProvider`,
`IFieldEncryptor` (encrypt/decrypt + deterministic `Hash` for lookups), `ICacheService`,
`IObjectStorage`, and `INotificationDispatcher`. **Reuse `IFieldEncryptor`** for the encrypted `phone`/
`email`/`national_id` columns and for the `refresh_token_hash`; **reuse `IDateTimeProvider`** for all
expiry/timestamp math. Do not introduce a second clock or encryptor.
- **Config & the first migration baseline.** [b1](./backend-phase-1.md) created the marketplace migration
baseline, the typed **cached `platform_configs` accessor**, and the `notifications` write path behind
`INotificationDispatcher`. Read OTP TTL / session lifetime / max-attempt knobs through the config
accessor — never hardcode. Your new tables migrate **onto** b1's baseline.
- **`ICurrentUser` + audit interceptor.** [b0](./backend-phase-0.md) wired `ICurrentUser` (Scoped, HTTP-
context-backed, null-object for jobs/tests) and the SaveChanges interceptor that stamps
`CreatedAt/ModifiedAt/CreatedById/ModifiedById`. Handlers never set audit fields by hand.
- **Identity tables & their config.** `User : IdentityUser<int>`, `Role`, `UserRole`, `UserRefreshToken`,
the 8 `IEntityTypeConfiguration`s mapping Identity into the **`usr`** schema, and `Baya.Tests.Setup`
(in-memory SQLite, full Identity stack incl. passwordless TOTP, real `JwtService`/`AppUserManager`).
**You extend these in place**`users`/`roles`/`user_roles` keep their baseline columns and gain new
ones; `user_sessions` is a new table off `users`.
**What this phase introduces:** the REST auth controllers, the `users` column extensions,
**`user_sessions`** (rotation + reuse detection), the role-selection flow over the existing RBAC, and
**one new seam — `ISmsSender`** (the OTP delivery rail; mock = log the code). The customer national-ID
KYC path, push/social login, and org self-onboarding are **(DEFERRED)** per the product doc — do not
build them.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business truth.** [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— phone-as-primary-credential, the customer↔patient split, role-staged KYC (national_id NULL until KYC),
revocable sessions, MVP-vs-DEFERRED. [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md)
— the four ground truths and where identity sits in the actor model.
- **Product — data model.** [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
— the exact `users` / `user_sessions` / `roles` / `user_roles` columns and constraints you must mirror
(`gender` NVARCHAR(10) NULL, `shahkar_verified_at` reset-on-phone-change, `phone` enc UNIQUE,
`user_sessions.refresh_token_hash`/`is_revoked`/`revoked_at`/`expires_at`, `user_roles.granted_by`/
`granted_at`/`revoked_at`).
- **Engineering truth.** [`server/CLAUDE.md`](../../../server/CLAUDE.md) — *Identity & auth*, *Persistence*,
*Startup wiring*, *Project map*; [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) — §1 routing,
§4 controllers, §6 persistence (audit fields, soft-delete, encrypted PII), §11 security (rate limiting),
§12 service registration.
- **Contract conventions you must honour.** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, status codes, auth header, pagination) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
**gender enum is load-bearing**`male`/`female`, never defaulted/dropped). Section 8 publishes
`dev/contracts/domains/identity-auth.md` from the [`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md).
- **Prior handoff & registry.** `dev/shared-working-context/backend/handoff/after-backend-phase-0.md` and
`after-backend-phase-1.md` (what is live), and
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (the `ISmsSender`
row you will fill).
- **Code to read and mirror** (do not duplicate): `Baya.Application/Features/Users/**` (the existing
`UserCreateCommand`, `RefreshUserTokenCommand`, `RequestLogout`, `GenerateUserToken`,
`TokenRequest/UserTokenRequestQuery` — these already drive OTP/JWE and are your reuse targets; note the
`//TODO Send Code Via Sms Provider` log line you are replacing), `Baya.Application/Features/Admin/**`
and `Baya.Application/Features/Role/**` (role/RBAC patterns), `Baya.Infrastructure.Identity/Jwt/JwtService.cs`,
`Baya.Infrastructure.Identity/AppUserManagerImplementation.cs`, the existing `UserGrpcServices` (shows
the request→`IMediator`→token shape you are re-exposing over REST), `BaseController`, and
`WebFramework/Swagger/RequireTokenWithoutAuthorizationAttribute` (mark `/auth/refresh` with it).
## 3. Scope — build this
Vertical slices: **entity/migration → command/query handler → controller endpoint → contract**. All
amounts/IDs follow project types (`User.Id` is `int`; `UserRefreshToken` is `Guid`-keyed). Reuse the
existing OTP/JWE/RBAC plumbing throughout — your handlers orchestrate `IAppUserManager`, `IJwtService`,
and the new session repository; they do not re-derive tokens or hash passwords.
### 3.1 Entity & migration changes
Add one EF migration (onto b1's baseline) covering:
- **Extend `users`** (the `usr` schema Identity table — keep all baseline columns, add via the
`UserConfig` configuration + entity properties on `User`):
- `gender` NVARCHAR(10) NULL — `male` / `female`. **Load-bearing** for same-gender matching downstream;
never defaulted or silently dropped. Not collected at OTP signup; settable later via profile (b3) —
here it must merely *exist* and round-trip.
- `national_id` (enc) **NULLABLE** — stays NULL until KYC (b6). Do **not** add it to any signup/verify
request. `national_id_verified_at` DATETIME2 NULL.
- `shahkar_verified_at` DATETIME2 NULL — **reset to NULL on phone change** (see §5). b6 sets it.
- `phone` (enc) — must be **UNIQUE** (one identity per phone). `email` (enc) NULLABLE.
- `is_active` BIT and `deleted_at` DATETIME2 NULL (soft-delete) with a **global query filter**
(`deleted_at IS NULL`) — if the baseline `User` lacks these, add them; otherwise confirm they map.
- Encrypted columns route through `IFieldEncryptor`; the UNIQUE on `phone` uses a deterministic
`phone_hash` (via `IFieldEncryptor.Hash`) so an encrypted column can still be uniquely indexed —
mirror the `iban_hash` pattern documented for b3.
- **New `user_sessions`** entity + `IEntityTypeConfiguration` (in a `Persistence/Configuration/UserConfig/`
sibling, `usr` schema), off `users`:
- `id` (PK), `user_id` (FK → users), `refresh_token_hash` NVARCHAR (store the **hash** via
`IFieldEncryptor.Hash`, never the raw token), `device_info` NVARCHAR NULL, `ip_address` NVARCHAR NULL,
`is_revoked` BIT, `revoked_at` DATETIME2 NULL, `expires_at` DATETIME2 NOT NULL, `created_at`.
- Index `(user_id, is_revoked)` for fast active-session lookup; index on `refresh_token_hash` for
rotation lookups.
- **Extend `roles` / `user_roles`** (keep baseline RBAC columns): `user_roles` gains the **grant/revoke
audit trail** — `granted_by` (FK → users NULL), `granted_at` DATETIME2, `revoked_at` DATETIME2 NULL.
Ensure the actor roles `customer` / `nurse` and the admin sub-roles exist (seed `customer`/`nurse` if
the b1 seed did not; admin sub-roles `support`/`finance`/`moderation`/`super_admin` are seeded but only
ever assigned internally).
Add an `IUserSessionRepository` contract in `Application/Contracts/Persistence/` and expose it on
`IUnitOfWork` (mirror `IUserRefreshTokenRepository`); implement in Persistence.
### 3.2 Commands / queries (`Baya.Application/Features/Identity/` — new area, or extend `Users/`)
Each is a `record` request + `internal sealed` handler; input-bearing commands get a FluentValidation
validator; expected failures return `OperationResult` (never throw). Reads are `AsNoTracking()` +
`.Select()` projections.
- **`RequestOtpCommand(phone)``RequestOtpResult`** — normalize/validate the Iranian phone; **upsert**
the `users` row (create an inactive-until-verified user if new, via the existing `UserCreateCommand`
path / `IAppUserManager`); call `GenerateOtpCode`; **deliver via `ISmsSender`** (replacing the
`//TODO Send Code Via Sms Provider` log). Return a non-enumerating result (e.g. `otp_sent: true`,
`resend_available_in_seconds` from config) — **do not leak whether the phone already existed**.
- **`VerifyOtpCommand(phone, code, device_info?)``AuthTokensResult`** — `VerifyUserCode`; on success
mark `phone_verified_at`/`is_active`, mint a **JWE access token** (`IJwtService.GenerateByPhoneNumberAsync`)
**and a refresh token**, and **create a `user_sessions` row** storing `refresh_token_hash`,
`expires_at` (from config), `device_info`, `ip_address` (from `ICurrentUser`/HTTP context). Return
`{ access_token, refresh_token, access_expires_at, refresh_expires_at, is_new_user, roles[] }`. On a
fresh user `roles` is empty (drives the f1 role-router to `/me/role`).
- **`RefreshTokenCommand(refresh_token)``AuthTokensResult`** — look up the session by
`Hash(refresh_token)`. **Rotation + reuse detection:** if the session is found and active → mark it
revoked, mint a new access+refresh pair, create a **new** session (rotation). If the presented token
hashes to a session that is **already revoked** → treat as **stolen-token reuse**: revoke **all** of
that user's active sessions (logout-everywhere) and return `401`. Expired session → `401`. Mark the
controller action with `RequireTokenWithoutAuthorizationAttribute` (it runs without a valid access
token).
- **`LogoutCommand(refresh_token? | current session)``OperationResult`** — revoke the current session
(`is_revoked=true`, `revoked_at=now`); rotate the security stamp via the existing `RequestLogout` path
so the JWE `OnTokenValidated` security-stamp check rejects the old access token too. Optional
`everywhere: true` revokes all the user's sessions.
- **`GetMeQuery()``MeResult`** — current user (id, phone-masked, first/last name, gender, `is_active`),
**roles[]**, **profile-completion** flags (e.g. `has_customer_profile`, `has_nurse_profile` — false for
now; b3 populates the underlying tables), and **nurse verification status** (read from
`nurse_verifications.status` when present; until b6 it returns `not_started`/`null` — never read a
removed `nurse_profiles.verification_status`). Projection only; `AsNoTracking()`.
- **`SelectRoleCommand(role)``MeResult`** — assign **`customer` or `nurse`** to the current user via
`user_roles` (audit `granted_by` = self / system, `granted_at`). **Reject any admin sub-role** with
`403` — admin roles are internal-only and never self-assignable (§5). Idempotent if the role is already
held. A user may hold both `customer` and `nurse`.
### 3.3 Controllers (`Baya.Web.Api/Controllers/V1/`)
Sealed, inherit `BaseController`, inject `ISender`, return `base.OperationResult(...)`, snake_case
`[controller]`/`[action]` routes, `CancellationToken` threaded. Apply the b0 rate-limit policies as noted.
| Method & route (snake_case) | Request → handler | Auth | Rate-limited |
| --- | --- | --- | --- |
| `POST api/v1/auth/otp/request` | `RequestOtpCommand` | none | **yes** (per-phone + per-IP OTP policy) |
| `POST api/v1/auth/otp/verify` | `VerifyOtpCommand` | none | **yes** (verify/brute-force policy) |
| `POST api/v1/auth/refresh` | `RefreshTokenCommand` | `RequireTokenWithoutAuthorization` | **yes** (refresh policy) |
| `POST api/v1/auth/logout` | `LogoutCommand` | authenticated | no |
| `GET api/v1/me` | `GetMeQuery` | authenticated | no |
| `POST api/v1/me/role` | `SelectRoleCommand` | authenticated | no |
> Routes render through the snake_case transformer; document the exact emitted paths in the contract from
> the published `swagger.json` (don't assume the token expansion).
### 3.4 Wire `ISmsSender` for OTP delivery
Introduce **`ISmsSender`** (Application contract) with a real-shaped signature
(`Task SendOtpAsync(string phone, string code, CancellationToken ct)` plus a generic
`SendAsync(string phone, string message, ...)` for later transactional SMS). The **mock** Infrastructure
implementation **logs the code** (structured, never the full PII number in plaintext beyond what logging
policy allows) and returns success; selection is by config/registration (`ServiceConfiguration/`
extension), never an `if (mock)` in the handler. `RequestOtpCommand` calls it after `GenerateOtpCode`.
### 3.5 Out of scope (DEFERRED — do not build)
- Customer national-ID KYC (`customer_profiles.national_id_verified_at`) — column deferred; never gate
browsing on it. → deferred per [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) §(c).
- Push-notification registration, social login, nursing-company (org) self-onboarding. → same doc.
- Profiles, patients, addresses, nurse bank accounts → **[b3](./backend-phase-3.md)**.
- The `is_verified` flip, Shahkar re-verification on phone change (the *handler* that re-runs Shahkar) →
**[b6](./backend-phase-6.md)**. This phase only **resets `shahkar_verified_at` to NULL** when the phone
changes; it does not run Shahkar.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`ISmsSender`** | **introduced here** | logs the OTP code (structured); returns success. Selected by config; real Kavenegar/Ghasedak client swaps in later (implement the same interface, keep idempotency/rate-limit logic). | **add row → 🟡** |
| `IFieldEncryptor` | reuse (b0) | encrypt/decrypt `phone`/`email`/`national_id`, hash `phone`/`refresh_token`. | no change |
| `IDateTimeProvider` | reuse (b0) | clock for OTP/session expiry. | no change |
| `ICacheService` | reuse (b0) | config-accessor cache (b1) only; not new here. | no change |
| `INotificationDispatcher` | reuse (b0/b1) | optional "new login" notification (in-app, b1 write). | no change |
Record `ISmsSender` in [`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md):
seam name + file, what's faked, config keys (gateway api-key, sender line, OTP TTL), step-by-step how to
make it real (pick gateway, add client package to `Directory.Packages.props`, implement `SendOtpAsync`,
register over the mock by config), status 🟡.
## 5. Critical rules you must not get wrong
- **Phone is the primary credential.** OTP is the only public login. **Email is optional** and required
only for admin — never make email mandatory, never make it a login key.
- **`national_id` is NOT collected at signup** and stays **NULL until KYC** (b6). An unverified
registration must never look KYC-complete. Do not add `national_id` to any auth request body.
- **`shahkar_verified_at` resets to NULL on phone change.** Whenever the stored phone changes (via
`ChangePhoneNumber`), null out `shahkar_verified_at` so b6 re-verifies. Don't silently keep a stale
binding.
- **Sessions are revocable with refresh-token rotation + reuse detection.** Each refresh **rotates**
(old session revoked, new one issued). A refresh presented against an **already-revoked** session is a
**stolen-token signal** → revoke all the user's sessions and `401`. Store only the **hash** of the
refresh token (`IFieldEncryptor.Hash`), never the raw token. Logout must invalidate the session
server-side (and rotate the security stamp so the access token dies too).
- **Admin roles are NEVER self-assignable** via the public `/me/role`. `SelectRoleCommand` accepts only
`customer` / `nurse`; any admin sub-role → `403`. Admin provisioning is internal-only and goes through
the RBAC grant path (audited `granted_by`).
- **OTP is rate-limited and codes expire.** Apply the b0 rate-limit policies to `otp/request`,
`otp/verify`, and `refresh`. Read TTL/max-attempt/lockout knobs from the b1 config accessor. Treat
brute-force / lockout as an explicit handled `400`/`429` state — never an unhandled exception. Do not
enumerate accounts: `otp/request` returns the same shape whether or not the phone existed.
- **`users.gender` is load-bearing** (`male`/`female`) for same-gender matching — the column must exist
and round-trip cleanly now even though it is populated later; never default it to a value or drop it.
- **Encrypt PII at rest** (`phone`, `email`, `national_id`) via `IFieldEncryptor`; never log plaintext PII
or project it into non-authorized responses (`/me` masks the phone).
- **Reuse, don't rebuild.** Tokens come from `IJwtService`; OTP from `IAppUserManager`; RBAC from the
existing permission system. No parallel JWT issuer, no second OTP generator, no `if (mock)` branches.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The six endpoints exist, are reachable through Swagger, and return the `OperationResult` envelope;
`otp/request`/`otp/verify`/`refresh` are rate-limited; `refresh` carries
`RequireTokenWithoutAuthorization`.
- [ ] `users` is extended (`gender`, `national_id` enc NULL, `national_id_verified_at`,
`shahkar_verified_at`, `phone` enc UNIQUE via hash, `email` enc NULL, `is_active`, `deleted_at`
+ soft-delete query filter); `user_sessions` and the `user_roles` audit columns exist; one clean
migration onto b1's baseline (`dotnet build` zero new warnings, `dotnet ef migrations` applies).
- [ ] Refresh rotation works and a **replayed/old refresh token is rejected** with all sessions revoked;
logout invalidates the session **and** the access token (security-stamp rotation).
- [ ] `SelectRoleCommand` assigns `customer`/`nurse` only; an admin sub-role attempt returns `403`.
- [ ] `ISmsSender` is introduced behind DI, the mock logs the code, the registry row is added (🟡), and
`RequestOtpCommand` calls it (the old TODO log is gone).
- [ ] Handler unit tests (NSubstitute) for verify/refresh/role + at least one `WebApplicationFactory`
integration test per endpoint area (happy path, 401 on `/me` unauthenticated, 400 on bad OTP,
reuse-detection 401 on refresh, 403 on admin self-assign). `dotnet test Baya.sln` green.
- [ ] The **Project map** in `server/CLAUDE.md` notes the new `Identity` feature area, `user_sessions`,
the auth controllers, and the `ISmsSender` seam.
## 7. How to test (what a human can verify after this phase)
A reachable SQL Server is required. Run the API (`dotnet run --project src/API/Baya.Web.Api/...`), open
`/swagger`.
1. **OTP request logs a code.** `POST api/v1/auth/otp/request` with `{ "phone": "09120000000" }``200`
`{ otp_sent: true, ... }`; the **OTP code appears in the server log** (the `ISmsSender` mock). Repeat
immediately past the limit → `429`.
2. **Verify mints tokens + creates a session.** `POST api/v1/auth/otp/verify` with the phone + the logged
code → `200` with `access_token`, `refresh_token`, expiries, `is_new_user: true`, `roles: []`. Confirm
a `user_sessions` row exists (`is_revoked=false`).
3. **`/me` returns the user + roles.** `GET api/v1/me` with `Authorization: Bearer <access_token>``200`
with masked phone, empty `roles`, profile-completion flags false, nurse verification `not_started`/null.
Without the header → `401`.
4. **Role selection.** `POST api/v1/me/role` `{ "role": "customer" }``200`, `/me` now shows
`["customer"]`. `{ "role": "nurse" }` also succeeds (a user can be both). `{ "role": "super_admin" }`
`403`.
5. **Refresh rotates; replay is rejected.** `POST api/v1/auth/refresh` with the refresh token → `200` new
pair (old session now revoked). Replay the **same old** refresh token → `401`, and confirm **all** that
user's sessions are revoked (stolen-token detection).
6. **Logout kills the session and the access token.** `POST api/v1/auth/logout``200`; the prior
`access_token` now fails `/me` with `401` (security-stamp rotation), and the session is `is_revoked`.
7. **Bad OTP.** `otp/verify` with a wrong/expired code → `400` with a safe message (no enumeration).
These steps become the "what is now testable" section of your report.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md`*Project map* (new `Features/Identity` area,
`user_sessions` table + config, the V1 auth controllers, the `ISmsSender` seam and where it's
registered) and *Identity & auth* (note OTP delivery is now real-seamed, sessions exist, role-selection
flow). If you decided any rule not already in the product docs (e.g. the exact reuse-detection response),
reflect it in [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— record decisions, don't invent rules.
- **Contract to write:** publish **`dev/contracts/domains/identity-auth.md`** from the
[`_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md) — the six endpoints (routes, request/response
shapes, auth, rate-limited flags, status codes incl. `401` reuse-detection and `403` admin self-assign),
the `role` enum (`customer`/`nurse` public; admin sub-roles internal), the `AuthTokensResult` /
`MeResult` / `RequestOtpResult` shared shapes (mark the masked phone), and the gender enum note. Then
**publish the `swagger.json` snapshot** per [`openapi/README.md`](../../contracts/openapi/README.md) so
f1-b2 derives types from it, not by guessing.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-2.md` (auth
is live over REST; what f1-b2 can now build — login, OTP, role router, `AuthContext` roles; what's
mocked = SMS delivery), append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-2-report.md` (what was built, what is testable and
exactly how — the §7 steps, what is mocked + how to make `ISmsSender` real, the `identity-auth` contract
produced, follow-ups: profiles/patients/bank in b3, Shahkar/`is_verified` in b6), and update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (the `ISmsSender`
row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions — how REST wraps the existing
gRPC/OTP/JWE engine without duplication, the refresh-rotation + reuse-detection design (revoke-all on
replay), the `phone_hash` UNIQUE-on-encrypted pattern, and the rule that admin roles are never
self-assignable — with a one-line `MEMORY.md` pointer.
+341
View File
@@ -0,0 +1,341 @@
# Backend Phase 3 — Identity: profiles, patients & nurse bank accounts
> **Mission:** put the *people* behind the accounts. On top of the bare `users`/sessions/auth spine that
> b2 shipped, build the role-specific profile extensions a marketplace actually transacts against: a
> nurse's seller profile (bio, experience, the `is_accepting_bookings` toggle, and the **guarded**
> `is_verified` flag), a customer's thin payer profile with its emergency contact, the first-class
> **patient** records a customer manages on behalf of the people who can't self-advocate, and the
> **payout bank account** that is the single place real money will one day leave the platform. Every PII
> column here is encrypted; tenancy is enforced so a customer can only ever touch their own patients; and
> the IBAN is hardened with a uniqueness hash and an automated **استعلام شبا** ownership inquiry — not an
> admin's eyeballs. This is the data catalog, verification, booking, and payouts all build directly on.
>
> **Track:** backend · **Depends on:** [b2](./backend-phase-2.md) (users/auth/OTP/sessions/roles), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST surface, seam pattern) · **Unlocks:** geography & addresses (b4), catalog (b5), nurse verification (b6), booking (b8); frontend **f2-b3**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b3**, the second identity phase. b2 turned the inherited auth spine into a real
phone-OTP login: `users` (phone primary, `gender`, nullable `national_id`), `user_sessions` (rotation +
reuse detection), `roles`/`user_roles`, and the role-selection flow. What b2 deliberately did **not**
build is everything *attached* to a user once they pick a role — the seller profile, the payer profile,
the care recipients, and the payout destination. That is this phase. After b3, a nurse has a profile that
verification (b6) can flip to verified and that the catalog (b5) can hang priced service variants off; a
customer has patients and a profile that booking (b8) can act on behalf of; and a nurse has a bank
account that payouts (b13) can pay into. **No geography here** — saved service addresses
(`customer_addresses`) and nurse coverage areas need the province/city/district tables, so they belong to
**b4** (see §3 DEFERRED).
**What already exists (do not rebuild) — built by prior phases:**
- **`users`, auth, sessions, roles** — [b2](./backend-phase-2.md) extended `users` (phone enc UNIQUE,
`email` enc nullable, `national_id` enc nullable, `gender` NVARCHAR(10) `male`/`female`,
`national_id_verified_at`, `shahkar_verified_at`, `role`, `is_active`, `deleted_at`), built
`user_sessions` (refresh-token rotation + reuse detection), `roles`/`user_roles` (admin RBAC), the
`/auth/otp/*`, `/auth/refresh`, `/auth/logout`, `/me`, and `/me/role` endpoints, and the `ISmsSender`
seam. **Read the current user from `ICurrentUser`; resolve the customer/nurse profile off
`users.id` — never re-create the user or session machinery.**
- **`IFieldEncryptor`** — [b0](./backend-phase-0.md) introduced the field-encryption seam:
`Encrypt(string)` / `Decrypt(string)` plus a deterministic `Hash(string)` for lookup columns. **Every
PII column in this phase goes through it** (national_id was wired by b2; you add IBAN, account-holder
name, emergency contacts, medical notes, and the deterministic `iban_hash` here). Never store or log
plaintext PII.
- **`ICurrentUser` + the audit-field SaveChanges interceptor** — [b0](./backend-phase-0.md). Handlers
never set `CreatedById`/`CreatedAt`/`ModifiedById`/`ModifiedAt`; the interceptor stamps them from
`ICurrentUser`. Use `ICurrentUser.UserId` for tenancy resolution.
- **The REST surface & CQRS pipeline** — [b0](./backend-phase-0.md): versioned `sealed : BaseController`
controllers, `ISender`, `base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, rate
limiting, `LoggingBehavior`, `ValidateCommandBehavior`, `OperationResult<T>`, Mapster, FluentValidation,
CQRS via **`martinothamar/Mediator`** (not MediatR), `IDateTimeProvider`.
- **`platform_configs` typed cached accessor** — [b1](./backend-phase-1.md). If you need a tunable (e.g. a
max-patients-per-customer guard), read it through the accessor; never hardcode.
**What this phase introduces:** the four new domain tables (`nurse_profiles`, `customer_profiles`,
`patients`, `nurse_bank_accounts`), their CRUD/management capabilities, and **one new seam —
`IBankAccountOwnershipVerifier`** (the mocked استعلام شبا IBAN-owner ↔ national-id inquiry). The
`partner_center_id` FK on `nurse_profiles` is a **forward dependency** on b15 — declare it nullable and do
not enforce or build `partner_centers` here.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially *Persistence* (AsNoTracking + `.Select` projection, pagination, one
`IEntityTypeConfiguration<T>` per entity, soft-delete filters, the encrypted-PII rule) and *CQRS*.
- [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
**the business rules**: phone is the primary credential; the **customer ≠ patient** split (the payer
is frequently not the care recipient); patient `gender` is load-bearing for same-gender matching;
customer national-ID KYC is **DEFERRED**; a nurse is not bookable until verified.
- [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
**the canonical schema** for `nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`.
Mirror the field names and constraints exactly: the guarded `is_verified`, the filtered uniques, the
`iban_hash` UNIQUE, the `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref` trio, and
the **CUT** columns (`nurse_profiles.verification_status`, `response_rate`, `avg_response_time_hours`,
`profile_completion_score`; `customer_profiles.national_id_verified_at`) — **do not reintroduce them.**
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four
ground truths and the encrypt-PII-at-rest expectation that shapes every column here.
- **Code to mirror:** b2's `users`/`user_sessions` entity configs and the `Features/Identity/**` (or
`Features/Auth/**`) command/handler structure — your new features sit alongside them; b0's
`IFieldEncryptor` usage and any existing encrypted-column converter pattern; the existing
`IEntityTypeConfiguration<T>` files in `Persistence/Configuration/` for the filtered-index and
value-converter syntax.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, pagination, auth) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (the
**PII & sensitive fields** masking rule — IBAN returned masked, last-4 only — and `gender` as
load-bearing).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-2.md`,
`…-after-backend-phase-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse + the new one you add).
## 3. Scope — build this
All features live under `Baya.Application/Features/Identity/{Commands|Queries}/<Name>/`; entities under
`Baya.Domain/Entities/Identity/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/IdentityConfig/`; **one EF migration** for the four new tables. Reads use
`AsNoTracking()` + `.Select(...)` to a DTO and are paginated where they list; writes go through
`IUnitOfWork` with a single `CommitAsync` per command and never throw for expected failures (return
`OperationResult.SuccessResult/FailureResult/NotFoundResult`). Encrypted columns route through
`IFieldEncryptor` (an EF value converter or in-handler encrypt — mirror b2's national_id approach).
### 3.1 Entities + migration
**`nurse_profiles`** [CORE] — the nurse's seller profile + denormalized search/quality aggregates.
- Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `partner_center_id` (FK →
`partner_centers` **NULL** — forward dependency on b15, nullable, **no FK enforcement target built
here**); `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`;
**`is_verified`** (BIT NOT NULL DEFAULT 0 — **GUARDED**, see §5); `is_accepting_bookings` (BIT NOT NULL
DEFAULT 0); denormalized read-only aggregates `average_rating`, `total_reviews`,
`total_completed_bookings`; `created_at`, `updated_at`, `deleted_at` (soft-delete).
- **`is_verified` is write-guarded** — model it with **no public setter** (private/internal-set, or a
`MarkVerified()`/`MarkUnverified()` domain method only the b6 verification-confirm transaction calls).
No command, controller, or mapping in *this* phase may set it. See §5.
- **Aggregates are read-only here** — `average_rating`/`total_reviews`/`total_completed_bookings` default
to 0 and are recomputed by reviews/bookings phases; never accept them from a request body.
- **Do NOT add** `verification_status`, `response_rate`, `avg_response_time_hours`,
`profile_completion_score` (CUT — `nurse_verifications.status` from b6 is the sole verification truth).
- Soft-delete global query filter on `deleted_at IS NULL`.
- Relations: 1:1 → `users`; (forward) 1:N → `nurse_service_variants` (b5), `nurse_service_areas` (b4),
`nurse_bank_accounts` (this phase), `nurse_verifications` (b6), `bookings`/`nurse_payouts` (later).
**`customer_profiles`** [CORE] — the thin payer extension.
- Fields: `id` (BIGINT PK); `user_id` (FK → `users` **UNIQUE**, 1:1); `default_emergency_contact_name`
(**enc**); `default_emergency_contact_phone` (**enc**); `created_at`, `updated_at`.
- **Do NOT add** `national_id_verified_at` (CUT for MVP — customer national-ID KYC is DEFERRED; the column
is not even created at launch).
- Relations: 1:1 → `users`; 1:N → `patients` (this phase), `customer_addresses` (b4), `booking_requests`
(b8).
**`patients`** [CORE] — the care recipient, a first-class entity separate from the payer.
- Fields: `id` (BIGINT PK); `customer_id` (FK → `customer_profiles`); `display_name`, `first_name`,
`last_name`; `birth_date` (DATE); `gender` (NVARCHAR(10) — `male`/`female`, **required**,
same-gender-matching signal); `blood_type` (nullable); `initial_medical_notes` (**enc**); `is_active`
(BIT, archive flag); `created_at`, `updated_at`.
- **Tenancy invariant** (see §5): a patient belongs to exactly one `customer_id`; every read/write is
scoped to the signed-in customer; a patient referenced by a booking must belong to that booking's
`customer_id` (enforced fully in b8, but the ownership scoping starts here).
- Relations: N:1 → `customer_profiles`; (forward) 1:N → `booking_requests` (b8), `patient_care_records` (b14).
**`nurse_bank_accounts`** [CORE] — the payout destination; the single place real money leaves the platform.
- Fields: `id` (BIGINT PK); `nurse_id` (FK → `nurse_profiles`); `bank_name`; `account_holder_name`
(**enc**); `iban` (**enc**); **`iban_hash`** (NVARCHAR(64) — deterministic `IFieldEncryptor.Hash(iban)`,
**`UNIQUE`** so one IBAN can't silently serve two nurses); `is_primary` (BIT); **`matched_national_id`**
(BIT **NULL** — result of the استعلام شبا IBAN-owner ↔ national-id inquiry; NULL until the inquiry runs;
first payout is gated on `true` in b13); `account_holder_from_bank` (NVARCHAR(200) NULL — name the bank
returned, snapshot); `ownership_vendor_ref` (NVARCHAR(200) NULL — vendor transaction id for audit);
`is_verified` (BIT); `verified_by_admin_id` (FK → `users` NULL); `verified_at` (NULL); `created_at`,
`updated_at`.
- **Constraints:** `UNIQUE(iban_hash)`; **filtered `UNIQUE(nurse_id) WHERE is_primary = 1`** (exactly one
primary account per nurse). Both are DB indexes in the migration — the authoritative backstop, not just
handler logic.
- Relations: N:1 → `nurse_profiles`; (forward) 1:N → `nurse_payouts` (b13). The b6
`bank_account_verification` step couples to this table — build the account + inquiry here, the
verification step there.
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`UpsertNurseProfileCommand`** | Command | `POST api/v1/nurse_profiles/upsert` | Creates (on first call) or updates the signed-in nurse's profile: `bio`, `years_of_experience`, `education_level`, `education_field`, `specializations_json`. Resolves `user_id` from `ICurrentUser`; rejects if the user's role isn't `nurse`. **Never** accepts `is_verified` or the aggregates. Idempotent on `user_id` (the UNIQUE 1:1 is the backstop). |
| **`SetNurseAcceptingBookingsCommand`** | Command | `POST api/v1/nurse_profiles/set_accepting_bookings` | Toggles `is_accepting_bookings` for the signed-in nurse (pause/resume without touching verified status). |
| **`GetMyNurseProfileQuery`** | Query | `GET api/v1/nurse_profiles/me` | Projects the signed-in nurse's profile incl. read-only `is_verified` + aggregates. AsNoTracking + `.Select`. |
| **`UpsertCustomerProfileCommand`** | Command | `POST api/v1/customer_profiles/upsert` | Creates/updates the signed-in customer's profile + `default_emergency_contact_name`/`_phone` (**encrypted**). Resolves `user_id` from `ICurrentUser`; rejects non-`customer` role. |
| **`GetMyCustomerProfileQuery`** | Query | `GET api/v1/customer_profiles/me` | Projects the customer's profile; emergency-contact phone returned **masked** to non-self callers per the masking rule (self sees full). |
| **`CreatePatientCommand`** | Command | `POST api/v1/patients/create` | Creates a patient under the signed-in customer (`customer_id` derived from `ICurrentUser``customer_profiles`, **never** from the request). Validates required `gender`, name, `birth_date`; `initial_medical_notes` encrypted. |
| **`ListPatientsQuery`** | Query | `GET api/v1/patients/list?page=&page_size=` | Lists the signed-in customer's **own** patients only (tenancy-scoped). Projected + paginated; `initial_medical_notes` returned to the owning customer only. |
| **`GetPatientQuery`** | Query | `GET api/v1/patients/get/{id}` | Returns one patient **only if it belongs to the signed-in customer** — otherwise `NotFoundResult` (don't leak existence). |
| **`UpdatePatientCommand`** | Command | `POST api/v1/patients/update/{id}` | Updates a patient the signed-in customer owns; tenancy-checked; re-encrypts changed PII. |
| **`ArchivePatientCommand`** | Command | `POST api/v1/patients/archive/{id}` | Sets `is_active = false` (soft archive, not hard delete — preserves longitudinal history for b14). Tenancy-checked. |
| **`AddNurseBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/add` | Adds a payout account for the signed-in nurse: `bank_name`, `account_holder_name` (enc), `iban` (enc) + computed `iban_hash`. **Rejects a duplicate IBAN** via the `iban_hash` UNIQUE (return a clean `409`/`FailureResult`, not an unhandled DB exception). Then **triggers `IBankAccountOwnershipVerifier`** (see §4) and persists `matched_national_id`, `account_holder_from_bank`, `ownership_vendor_ref`. If the nurse has no other account, this one may default to `is_primary` (subject to the filtered-unique rule). |
| **`SetPrimaryBankAccountCommand`** | Command | `POST api/v1/nurse_bank_accounts/set_primary/{id}` | Makes one of the nurse's accounts primary: clears the prior primary and sets this one, in **one transaction**, so the filtered `UNIQUE(nurse_id) WHERE is_primary=1` never trips. A duplicate-primary attempt without clearing the old one must be blocked (the filtered unique is the backstop). Tenancy-checked. |
| **`ListNurseBankAccountsQuery`** | Query | `GET api/v1/nurse_bank_accounts/list` | Lists the signed-in nurse's accounts with **masked IBAN** (last 4 only), `is_primary`, `is_verified`, `matched_national_id`. Projected. |
| **`TriggerBankAccountOwnershipInquiryCommand`** | Command | `POST api/v1/nurse_bank_accounts/verify_ownership/{id}` | Re-runs the استعلام شبا inquiry for an existing account (e.g. after a failed/NULL first attempt) and updates `matched_national_id`/`account_holder_from_bank`/`ownership_vendor_ref`. Idempotent — re-running with the same input yields the same vendor ref from the mock. |
- **Controllers:** `NurseProfilesController`, `CustomerProfilesController`, `PatientsController`,
`NurseBankAccountsController` — each `sealed : BaseController`, inject `ISender`, return
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
Authorize with the narrowest fitting policy: nurse-scoped endpoints require the nurse role, customer-scoped
the customer role; the bank-account ownership-inquiry endpoints are **rate-limited** (they call a vendor seam).
- **Validators:** FluentValidation on every input-bearing command — `gender` required + in
(`male`,`female`) on `CreatePatientCommand`; IBAN format (IR + 24 digits, Sheba) on
`AddNurseBankAccountCommand`; non-empty names; phone format on the emergency contact.
- **Mapping:** Mapster in the handler *after* the projected query — never hydrate an entity to map it.
### 3.3 DEFERRED (build the seam/flag, not the feature — with a pointer)
- **`customer_addresses`** + nurse **`nurse_service_areas`** — need the province/city/district hierarchy
and the geocoder. **DEFERRED to [b4](./backend-phase-4.md).** Do not create these tables or their CRUD
here; the digest is explicit that addresses need geography first.
- **Customer national-ID KYC** (`customer_profiles.national_id_verified_at`) — DEFERRED per
[`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
§(c). The column is **not created** at MVP; do not collect or gate browsing/booking on it.
- **Admin staff-role grant/revoke** (`roles`/`user_roles` management UI) — the tables exist from b2; the
admin management endpoints land with the admin backoffice in [b15](./backend-phase-15.md). Don't build
them here.
- **Nurse aggregate recompute** (writing `average_rating`/`total_reviews`/`total_completed_bookings`) —
the *columns* are created here read-only; the recompute writes come from reviews/bookings phases (b9/b14).
- **`is_verified` flip** — the *guarded model* is built here; the flip itself is the b6 verification-confirm
transaction. Expose **no** write path for it in this phase.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IBankAccountOwnershipVerifier`** | **introduced here** | `VerifyOwnershipAsync(iban, nurseNationalId, ct)` returns an `OwnershipInquiryResult` with `MatchedNationalId` (bool), `AccountHolderFromBank` (string), `VendorRef` (string). Mock = **deterministic fake match**: for a normal seeded/test IBAN it returns `MatchedNationalId = true`, echoes a plausible `AccountHolderFromBank` (e.g. the submitted holder name), and a fake `VendorRef` (e.g. `MOCK-SHEBA-{hash}`); for a designated **mismatch** test IBAN it returns `MatchedNationalId = false` (and a different holder name) so the ownership-mismatch path is testable. **No real bank/KYC call, no money moves.** | **add a new row** (🟡) |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key from config; encrypts `iban`, `account_holder_name`, `initial_medical_notes`, emergency contacts, and computes the deterministic `iban_hash` via `Hash(iban)`. Never logs plaintext. | reuse row |
| `ICurrentUser` | reuse from **b0** | resolves `UserId` for tenancy scoping. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | testable `UtcNow` for `verified_at`/audit. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
selection is **config-driven, never an `if (mock)` branch** in a handler. Append the
`IBankAccountOwnershipVerifier` row to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — pick a Finnotech/banking-bridge
استعلام شبا provider, add its client + package + settings, implement `VerifyOwnershipAsync` against the real
Sheba-owner inquiry, persist the real `ownership_vendor_ref`/`external_response_json`, and what to test).
> Do **not** pre-build the verification-pipeline seams (`IShahkarVerifier`, `IIdentityKycProvider`,
> `ICredentialVerifier`) — those are introduced in [b6](./backend-phase-6.md). This phase owns only
> `IBankAccountOwnershipVerifier`.
## 5. Critical rules you must not get wrong
- **`is_verified` is write-guarded — never expose a setter.** Model `nurse_profiles.is_verified` with no
public setter (private-set + a domain method the verification transaction calls). It is flipped **ONLY**
inside the b6 verification-confirm transaction once every required `verification_steps.status='passed'`.
No command, controller, mapping, or update in *this* phase may set it; a profile is created with
`is_verified = 0` and stays there until b6. A nurse is **not bookable** until verified — onboarding a
profile must never imply bookability.
- **Tenancy invariant — a patient belongs to its customer, always.** Resolve `customer_id` from
`ICurrentUser`, **never** from the request body. Every patient read/write is scoped to the signed-in
customer; reading or mutating another customer's patient returns `NotFoundResult` (don't leak existence).
A patient used in a booking must belong to the same `customer_id` (fully enforced in b8, scoping begins
here). The same ownership scoping applies to `nurse_bank_accounts` (a nurse only ever touches their own).
- **Encrypt all PII at rest.** `national_id` (already, from b2), `iban`, `account_holder_name`,
`default_emergency_contact_name`, `default_emergency_contact_phone`, and `initial_medical_notes` route
through `IFieldEncryptor`. Never store or log plaintext; never project plaintext PII into a non-authorized
response. On the wire, IBAN is **masked** (last 4) in list responses per the masking rule.
- **`iban_hash` is UNIQUE — one IBAN can't serve two nurses.** Compute it deterministically via
`IFieldEncryptor.Hash(iban)` and rely on the `UNIQUE(iban_hash)` index as the authoritative duplicate
guard. A duplicate add is a **clean** `409`/`FailureResult`, not an unhandled `DbUpdateException`. (The
encrypted `iban` column itself is non-deterministic and can't be uniquely indexed — that's *why* the hash
exists.)
- **Exactly one primary bank account per nurse.** The filtered `UNIQUE(nurse_id) WHERE is_primary = 1`
index is the backstop; `SetPrimaryBankAccountCommand` clears the old primary and sets the new one in **one
transaction** so the constraint never trips. Never let two `is_primary = 1` rows exist for a nurse.
- **First payout is gated on `matched_national_id = true`** — set here by the استعلام شبا inquiry
(`IBankAccountOwnershipVerifier`), enforced at payout time in [b13](./backend-phase-13.md). Never mark a
payout-ready state off admin eyeballing; the holder-national-id ↔ verified-nurse-national-id match (money-mule
prevention) is the gate. `matched_national_id` starts NULL and only the inquiry sets it.
- **Customer national-ID KYC is DEFERRED** — do not create `national_id_verified_at` on
`customer_profiles`, do not collect it, and **never gate customer browsing or booking on it.**
- **Aggregates and `is_verified` are read-only inputs to this phase.** `average_rating`, `total_reviews`,
`total_completed_bookings` default to 0 and are written only by later phases; reject any attempt to set
them (or `is_verified`) from a request body.
- **Reads are projected + paginated; money/PII never leaks.** AsNoTracking + `.Select` to a DTO on every
read; `ListPatientsQuery`/`ListNurseBankAccountsQuery` paginate; no unbounded `ToListAsync()`. Audit fields
are stamped by the interceptor, not the handler.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The four tables (`nurse_profiles`, `customer_profiles`, `patients`, `nurse_bank_accounts`) exist via
**one migration**, each with its `IEntityTypeConfiguration<T>`: the 1:1 UNIQUE on `nurse_profiles.user_id`
and `customer_profiles.user_id`, the `UNIQUE(iban_hash)`, the filtered `UNIQUE(nurse_id) WHERE is_primary=1`,
the guarded (no-public-setter) `is_verified`, encrypted PII columns, soft-delete filter on
`nurse_profiles`, and audit wiring. **None** of the CUT columns are present.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
FluentValidation validators), with the four controllers, each role-scoped and tenancy-enforced.
- [ ] **`IBankAccountOwnershipVerifier`** introduced (Application interface, Infrastructure mock, DI
registration via a `ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Tenancy is provably enforced (a customer cannot read/mutate another customer's patient; a nurse cannot
touch another nurse's bank account); duplicate-IBAN and duplicate-primary are blocked by the DB
indexes and surfaced as clean failures.
- [ ] Handler unit tests (NSubstitute) for: nurse/customer profile upsert; patient CRUD + the
**cross-customer rejection**; bank-account add → ownership-inquiry sets `matched_national_id`; the
mismatch-IBAN path; `set_primary` flips the filtered-unique correctly; duplicate-IBAN rejected via
`iban_hash`. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401, validation 400).
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Identity/**` profile/patient/bank areas and the
`IBankAccountOwnershipVerifier` seam are reflected in the **Project map** in `server/CLAUDE.md`.
- [ ] The contract `dev/contracts/domains/identity-profiles.md` is written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Log in as a nurse and as a customer (reuse b2's OTP flow / a seeded session). Then:
1. **Nurse profile**`POST api/v1/nurse_profiles/upsert` with bio/experience → a `nurse_profiles` row is
created with `is_verified = 0` and `is_accepting_bookings = 0`; `GET api/v1/nurse_profiles/me` returns it
with read-only aggregates at 0. Confirm there is **no** endpoint or body field that can set `is_verified`.
2. **Accepting-bookings toggle**`POST api/v1/nurse_profiles/set_accepting_bookings` → flips
`is_accepting_bookings`; `is_verified` is untouched.
3. **Customer profile**`POST api/v1/customer_profiles/upsert` with an emergency contact → row created;
`GET …/me` returns it; verify the stored emergency-contact phone is **encrypted at rest** (inspect the
column — it is ciphertext, not the plaintext number).
4. **Patient CRUD**`POST api/v1/patients/create` (with required `gender`) → patient created under the
signed-in customer; `GET api/v1/patients/list` shows it; `update`/`archive` work; `initial_medical_notes`
is ciphertext at rest.
5. **Tenancy rejection** — as customer **A**, create a patient; as customer **B**, call
`GET api/v1/patients/get/{A's id}` and `POST api/v1/patients/update/{A's id}` → both return **404/not-found**
(B can never see or mutate A's patient). This is the load-bearing tenancy test.
6. **Add bank account + ownership inquiry**`POST api/v1/nurse_bank_accounts/add` with a normal test IBAN →
account created; the mock `IBankAccountOwnershipVerifier` runs and sets `matched_national_id = true`,
`account_holder_from_bank`, and `ownership_vendor_ref`; `GET …/list` shows the IBAN **masked** (last 4).
7. **Ownership mismatch** — add an account with the designated **mismatch** test IBAN → `matched_national_id =
false` and the mismatch holder name is recorded (the payout-gating path can later reject it).
8. **Duplicate IBAN** — add the **same** IBAN again (same nurse or a second nurse) → rejected with a clean
`409`/failure via the `iban_hash` UNIQUE, **not** an unhandled exception.
9. **Primary flip** — add a second account and `POST api/v1/nurse_bank_accounts/set_primary/{id2}` → account 2
becomes primary and account 1 is no longer primary; at no point do two `is_primary = 1` rows exist (the
filtered unique holds). Attempting to force a second primary without clearing the first is blocked.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Identity/**` profile/patient/bank areas + the `IBankAccountOwnershipVerifier` seam and where it's
registered). If you discover/confirm a rule the product docs don't capture (e.g. the masked-IBAN-on-list
behaviour, or the default-first-account-becomes-primary behaviour), record it in
[`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) —
don't invent rules.
- **Contract to write:** **`dev/contracts/domains/identity-profiles.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse-profile,
customer-profile, patient, and nurse-bank-account endpoints; the `gender` (`male`/`female`) and
`blood_type` enums; the profile/patient/bank-account DTO shapes (read-only `is_verified` + aggregates;
**masked** IBAN; encrypted fields stated as masked vs full); auth/role/rate-limit notes; and the tenancy,
guarded-`is_verified`, `iban_hash`-uniqueness, single-primary, and `matched_national_id`-gates-first-payout
side-effects. Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f2-b3** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (profiles,
patients, and nurse bank accounts are live; what **f2-b3** can now build — "who is care for"/patient
add-list-edit, customer profile, nurse profile bootstrap, nurse bank-account settings; which
endpoints/contracts are live; that addresses/service-areas are **DEFERRED to b4**; that the IBAN-ownership
rail is mocked behind `IBankAccountOwnershipVerifier`), append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-3-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: addresses (b4),
the `is_verified` flip (b6), payout gating on `matched_national_id` (b13), aggregate recompute (b9/b14)),
and update `dev/shared-working-context/reports/mocks-registry.md` (the `IBankAccountOwnershipVerifier`
row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the guarded
`is_verified` (no setter, flipped only in b6), the patient tenancy invariant + cross-customer-returns-404
rule, the `iban_hash` deterministic-hash uniqueness, the filtered single-primary index, and the
`matched_national_id`-gates-first-payout link to b13 — with a one-line pointer in `MEMORY.md`.
+332
View File
@@ -0,0 +1,332 @@
# Backend Phase 4 — Geography, addresses & nurse service areas
> **Mission:** lay the geographic spine the whole marketplace stands on. Build the
> **province → city → district** reference hierarchy (as *tables*, not hardcoded lists, so new regions
> launch without a deploy), seed it with Iran's provinces, major cities, and **Tehran's 22 municipal
> districts**; let admins curate it with `is_active`/`sort_order` toggles; serve the **cascading
> dropdowns** that every address form and search filter needs; let a **nurse declare where they will
> travel** (`nurse_service_areas`, city or city+district, `district_id=NULL` meaning *the whole city*);
> and let a **customer save service addresses** with a single enforced primary, encrypted at rest, and
> geocoded coordinates behind the **`IGeocoder`** seam. After this phase, catalog/search and booking have
> the geography they consume.
>
> **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `customer_profiles`), [b0](./backend-phase-0.md) (the `IGeocoder` host, `IFieldEncryptor`, REST surface, audit/caching seams), [b1](./backend-phase-1.md) (the seed pattern + typed config accessor) · **Unlocks:** catalog/search ([b5](./backend-phase-5.md)/[b7](./backend-phase-7.md)), booking ([b8](./backend-phase-8.md)); frontend **f3-b4**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b4**, a near-root reference domain that almost everything downstream consumes.
Balinyaar matches families to nurses **by named place**, not by GPS radius: a customer's saved address
lives in a `city` (and optionally a `district`), a nurse declares the `cities`/`districts` they will
travel to, and search later intersects the two. The geo hierarchy is stored in tables (not a static code
list) precisely so a new city or district can be launched by an admin insert — and so `is_active` /
`sort_order` give ordered, toggleable dropdowns without ever deleting a region that has historical rows
pointing at it. This phase builds that hierarchy, the two membership tables that hang off it
(`nurse_service_areas`, `customer_addresses`), and the **`IGeocoder`** seam that turns a typed address
into the lat/lng EVV will later check against.
**What already exists (do not rebuild) — built by prior phases:**
- **`nurse_profiles` & `customer_profiles`** — [b3](./backend-phase-3.md) built the role profile
extensions off `users` (`nurse_profiles.id` 1:1 with a nurse `user`; `customer_profiles.id` 1:1 with a
customer `user`), plus `patients` and `nurse_bank_accounts` and their tenancy rules. `nurse_service_areas`
hangs off `nurse_profiles`; `customer_addresses` hangs off `customer_profiles`. **Reuse those FKs — do
not re-create the profiles.**
- **The `IGeocoder` host** — [b0](./backend-phase-0.md) reserved the registry row for the geocoding seam
(it is *introduced* here, see §4). b0 also built **`IFieldEncryptor`** (the encrypt/decrypt + deterministic
`Hash` used by every encrypted PII column — addresses use it), **`ICacheService`** (the in-memory cache
the geo-lookup queries read through), **`IDateTimeProvider`**, **`ICurrentUser`** + the audit-field
SaveChanges interceptor, the REST controller surface (`sealed : BaseController`, `ISender`,
`base.OperationResult(...)`, snake_case routes), rate limiting, and `LoggingBehavior`.
- **The seed + typed-config pattern** — [b1](./backend-phase-1.md) established how reference/seed data is
loaded into the marketplace baseline (the first migration baseline, the seeding mechanism, the typed
cached `platform_configs` accessor). **Mirror b1's seeding mechanism** for the province/city/district
seed — do not invent a parallel one.
- The b0 foundation generally: Clean-Arch projects, CQRS via **`martinothamar/Mediator`** (`ISender`,
`ICommand`/`IQuery`, `internal sealed` handlers), `OperationResult<T>`, Mapster, FluentValidation,
`IUnitOfWork`, soft-delete query filters, the `ServiceConfiguration/` registration convention.
**What this phase introduces:** the five tables below, the admin/public/nurse/customer capabilities over
them, and **one new seam — `IGeocoder`** (the mocked maps/geocoding provider). No GPS-radius model, no
map-tile rendering, no availability — those are out of scope (see DEFERRED tags throughout).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially *Persistence* (projection + pagination, one `IEntityTypeConfiguration<T>` per entity,
soft-delete filters, **encrypted PII columns go through the field encryptor seam**) and *Performance,
caching* (read-heavy reference data is cached behind the cache seam with sensible invalidation).
- [`product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) — **the canonical
geo schema**: `provinces` 1:N `cities` 1:N `districts` with `sort_order`/`is_active`; `districts` are
*optional* (Tehran's 22 مناطق / neighborhoods elsewhere); `nurse_service_areas` with `district_id=NULL`
= whole city and `UNIQUE(nurse_id, city_id, district_id)`; **named districts, not a GPS radius**.
- [`product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) —
the **`customer_addresses`** definition (encrypted address + coordinates for EVV, filtered
`UNIQUE(customer_id) WHERE is_primary=1`) and how `nurse_service_areas` relates to `nurse_profiles`.
- [`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) —
**why this exists**: geography is driven by nurse-declared service areas; a city-level row (no district)
means the whole city; a city search must later match both city-only rows *and* any district row in that
city; districts are optional and vary (Tehran's 22 official مناطق vs neighborhoods elsewhere). This is
the consumer that makes `district_id=NULL` semantics load-bearing — get them right here.
- **The digests** (authoritative per-domain detail): the *Geography* and *Identity & Access →
`customer_addresses`/`nurse_service_areas`* sections of `dm_identity_geo_services_verif.md`, and the
*Search & Matching → geo* section of `biz_catalog_search.md` (in the run's `digests/`).
- **Code to mirror:** b3's `nurse_profiles`/`customer_profiles` configs and the `customer`/`nurse` feature
command structure; b1's seeding mechanism + the typed cached config accessor + the first migration
baseline it created (you add one migration on top); b0's `IFieldEncryptor` usage on PII columns, the
`ICacheService` `GetOrCreateAsync` pattern, and any `ServiceConfiguration/` seam registration.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, pagination shape) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (not money here, but the id/type
conventions — ids are `BIGINT`, coordinates are `decimal`, never `float`).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`,
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IGeocoder` row you flip from reserved → 🟡).
## 3. Scope — build this
Features live under `Baya.Application/Features/Geography/{Commands|Queries}/<Name>/`,
`Baya.Application/Features/ServiceAreas/{Commands|Queries}/<Name>/`, and
`Baya.Application/Features/Addresses/{Commands|Queries}/<Name>/`. Entities go in
`Baya.Domain/Entities/Geography/` (provinces/cities/districts/nurse_service_areas) and
`Baya.Domain/Entities/Identity/` (customer_addresses, next to b3's profiles — it is an identity-domain
table per the data model). One `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/GeographyConfig/` and `…/IdentityConfig/`. **One EF migration** for the five
tables. Ids are `BIGINT`; coordinates are `decimal(9,6)` (never `float`/`double`).
### 3.1 Entities + migration
**`provinces`** [CORE] — top of the geo hierarchy.
- Fields: `id` (BIGINT PK), `name_fa`, `name_en` (NVARCHAR — Persian primary, both required),
`sort_order` (int, default 0), `is_active` (BIT, default 1), audit fields, `deleted_at` (soft-delete).
- Relations: 1:N → `cities`.
**`cities`** [CORE] — the main address/search granularity.
- Fields: `id` (BIGINT PK), `province_id` (FK → `provinces`), `name_fa`, `name_en`, `sort_order`,
`is_active`, audit fields, `deleted_at`.
- Index: `(province_id, sort_order)` for the ordered cascading lookup.
- Relations: N:1 → `provinces`; 1:N → `districts`, `nurse_service_areas`, `customer_addresses`.
**`districts`** [CORE]/[MVP] — Tehran's 22 municipal مناطق / major neighborhoods elsewhere; **optional**.
- Fields: `id` (BIGINT PK), `city_id` (FK → `cities`), `name_fa`, `name_en`, `sort_order`, `is_active`,
audit fields, `deleted_at`.
- Index: `(city_id, sort_order)`.
- Relations: N:1 → `cities`; 1:N → `nurse_service_areas`, `customer_addresses` (both nullable refs).
**`nurse_service_areas`** [CORE] — where a nurse will travel; the membership row search later intersects.
- Fields: `id` (BIGINT PK), `nurse_id` (FK → `nurse_profiles`), `city_id` (FK → `cities`),
`district_id` (FK → `districts`, **NULLABLE**), `is_active` (BIT, default 1), audit fields, `deleted_at`.
- **Constraint:** `UNIQUE(nurse_id, city_id, district_id)` — and it **must include the NULL `district_id`
rows**. On SQL Server a plain unique index treats NULLs as distinct, which would *wrongly* allow two
"whole city" rows for the same nurse+city. Enforce the whole-city uniqueness deliberately: either a
**filtered unique index** `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` *plus*
`UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, or a computed/sentinel column
so the single index covers both. Whichever you pick, a duplicate "whole city" and a duplicate
"city+district" are **both** rejected. (See §5 — this is the rule most easily gotten wrong.)
- Relations: N:1 → `nurse_profiles`, `cities`, `districts`.
**`customer_addresses`** [CORE] — saved service locations; encrypted address + coordinates for EVV.
- Fields: `id` (BIGINT PK), `customer_id` (FK → `customer_profiles`), `city_id` (FK → `cities`),
`district_id` (FK → `districts`, NULLABLE), `title` (NVARCHAR — "خانه"/"محل کار", a label, not PII),
`address_line` (**encrypted** via `IFieldEncryptor`), `postal_code` (**encrypted**, nullable),
`latitude` / `longitude` (`decimal(9,6)`, nullable until geocoded), `is_primary` (BIT, default 0),
`recipient_name` / `recipient_phone` (**encrypted**, nullable), audit fields, `deleted_at`.
- **Constraint:** **filtered `UNIQUE(customer_id) WHERE is_primary=1`** — exactly one primary per
customer, enforced at the DB level (the authoritative backstop), not only in the handler.
- Relations: N:1 → `customer_profiles`, `cities`, `districts`; later referenced by
`booking_requests`/`bookings` (b8/b9 — **DEFERRED here**, just don't preclude it).
> **Soft-delete & audit:** every table declares the global `deleted_at IS NULL` query filter and inherits
> audit-field stamping from b0's interceptor — handlers never set `CreatedAt`/`CreatedById`. Geo rows are
> **deactivated (`is_active=0`) far more often than deleted**, so toggled-off regions vanish from
> dropdowns without orphaning the historical addresses/areas that point at them.
### 3.2 Seed data (mirror b1's seeding mechanism)
Add a province/city/district seed that runs as part of the b1-style seeding path (idempotent — safe to
re-run; key on a stable natural identifier such as `name_en` within parent, not on auto id):
- **All 31 Iranian provinces** (`name_fa`/`name_en`, `sort_order` by Iranian convention with Tehran first
or alphabetical Persian — keep it deterministic).
- **Major cities** at minimum the white-space targets the product calls out:
**Tehran, Karaj, Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom** (plus each province's capital).
- **Tehran's 22 municipal districts** (منطقه ۱ … منطقه ۲۲) under the Tehran city row. Other cities get
no districts at seed time (whole-city coverage is the default and is meaningful — see §5). Adding
neighborhoods elsewhere is an **admin insert later**, no deploy.
### 3.3 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`ListProvincesQuery`** | Query | `GET api/v1/geo/provinces` | Active provinces ordered by `sort_order`. `AsNoTracking` + `.Select` to a `ProvinceDto`; **cached** via `ICacheService.GetOrCreateAsync` (reference data; invalidate on admin write). Public. |
| **`ListCitiesQuery`** | Query | `GET api/v1/geo/cities?province_id=` | Active cities for a province, ordered by `sort_order`. Projected + cached + paginated. Public. |
| **`ListDistrictsQuery`** | Query | `GET api/v1/geo/districts?city_id=` | Active districts for a city, ordered. **Empty list is a valid result** (a city with no districts → caller selects whole-city). Projected + cached. Public. |
| **`GetGeoTreeQuery`** *(optional convenience)* | Query | `GET api/v1/geo/tree` | The full active province→city→district tree in one cached payload for the cascading dropdown (use if the client prefers one round-trip; otherwise the three lazy queries above suffice). Cached aggressively; invalidated on any admin geo write. Public. |
| **`CreateProvinceCommand` / `UpdateProvinceCommand`** | Command | `POST/PUT api/v1/admin_geo/provinces[/{id}]` | Admin create/edit `name_fa`/`name_en`/`sort_order`. Admin policy. **Invalidates the geo cache.** |
| **`SetProvinceActiveCommand`** | Command | `POST api/v1/admin_geo/provinces/{id}/set_active` | Toggle `is_active` (no delete). Cascade meaning: deactivating a province hides its cities/districts from public dropdowns (filter on the join, don't bulk-rewrite children). Invalidates cache. |
| **`CreateCityCommand` / `UpdateCityCommand` / `SetCityActiveCommand`** | Command | `POST/PUT api/v1/admin_geo/cities[/{id}]`, `…/{id}/set_active` | Same pattern under a `province_id`. |
| **`CreateDistrictCommand` / `UpdateDistrictCommand` / `SetDistrictActiveCommand`** | Command | `POST/PUT api/v1/admin_geo/districts[/{id}]`, `…/{id}/set_active` | Same pattern under a `city_id`. |
| **`AddNurseServiceAreaCommand`** | Command | `POST api/v1/nurse_service_areas` | The signed-in nurse declares coverage: `{ city_id, district_id? }`. `district_id` omitted/NULL = **whole city**. Validates the city/district exist and are active and that the district belongs to the city; enforces `UNIQUE(nurse_id, city_id, district_id)`**a duplicate (including a duplicate whole-city row) returns `409`, never a 500**. Tenancy: `nurse_id` from `ICurrentUser`, never the body. *(DEFERRED hook: this is the write that later fans out `nurse_search_index` rows in [b7](./backend-phase-7.md) — leave a clean extension point, do not build the index here.)* |
| **`RemoveNurseServiceAreaCommand`** | Command | `DELETE api/v1/nurse_service_areas/{id}` | Soft-removes the nurse's own area (tenancy-checked). *(DEFERRED hook: triggers index row removal in b7.)* |
| **`ListMyServiceAreasQuery`** | Query | `GET api/v1/nurse_service_areas` | The nurse's own areas (city + optional district names, "whole city" flag). Tenancy-scoped, projected, paginated. |
| **`CreateAddressCommand`** | Command | `POST api/v1/customer_addresses` | Creates an address for the signed-in customer: `{ title, city_id, district_id?, address_line, postal_code?, recipient_name?, recipient_phone?, is_primary? }`. **Encrypts** `address_line`/`postal_code`/recipient fields via `IFieldEncryptor`. **Geocodes** via `IGeocoder.GeocodeAsync(...)` to set `latitude`/`longitude`. If `is_primary=true` (or it is the customer's first address), enforce **single-primary** (clear the prior primary in the same unit of work; the filtered unique index is the backstop). Tenancy from `ICurrentUser`. |
| **`UpdateAddressCommand`** | Command | `PUT api/v1/customer_addresses/{id}` | Edit fields; **re-geocode** when the address line/city/district changed; re-encrypt PII. Tenancy-checked. |
| **`SetPrimaryAddressCommand`** | Command | `POST api/v1/customer_addresses/{id}/set_primary` | Atomically makes one address primary and clears the previous (single-primary invariant). |
| **`DeleteAddressCommand`** | Command | `DELETE api/v1/customer_addresses/{id}` | Soft-delete the customer's own address (tenancy-checked). |
| **`ListMyAddressesQuery`** | Query | `GET api/v1/customer_addresses` | The customer's own addresses, **primary first**. Projected (decrypt the address for display *only in the owner's own read*), paginated, tenancy-scoped. |
- **Controllers:** `GeoController` (public read), `AdminGeoController` (admin policy; create/update/set_active),
`NurseServiceAreasController` (nurse policy, tenancy-scoped), `CustomerAddressesController` (customer
policy, tenancy-scoped). All `sealed : BaseController`, inject `ISender`, return `base.OperationResult(...)`,
snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
- **Validators (FluentValidation):** non-empty `name_fa`/`name_en` on geo create/update; valid `province_id`/
`city_id` parents; `AddNurseServiceArea` (city active, district-belongs-to-city when provided);
`CreateAddress`/`UpdateAddress` (city active, non-empty `address_line`, district-belongs-to-city when
provided, postal-code format if present).
- **Mapping:** Mapster in the handler after the projected query (never hydrate entities to map them).
### 3.4 DEFERRED (build the seam/flag, not the feature)
- **`nurse_search_index` fan-out** on service-area add/remove — **DEFERRED to [b7](./backend-phase-7.md)**.
Leave the add/remove handlers as the clean trigger point; do not build the index table or its
maintenance here.
- **GPS-radius / map-tile / "nurses near me" map discovery** — **DEFERRED** ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
§(c)). Geography is **named districts**, full stop. Do not add a radius/distance model.
- **Region bulk-import feed** (`IGeoDataImporter` against an official statistics dataset) — **DEFERRED**;
the idempotent one-time seed (§3.2) plus admin CRUD is sufficient for MVP. Note it in the report.
- **EVV distance check** that *consumes* `customer_addresses.latitude/longitude` — **DEFERRED to
[b9](./backend-phase-9.md)**. This phase only *produces* the coordinates.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IGeocoder`** | **introduced here** | `GeocodeAsync(addressText, cityName, districtName?, ct)` returns a deterministic `(latitude, longitude)` for the input (e.g. a stable hash-derived offset around the city centroid, or an echo of a configured static coordinate per city) plus a `formatted_address` and a `confidence`. **No real network call.** A config switch can force a `null`/`low-confidence` result so the "address saved without coordinates / map-pin-missing" UI states are testable. Coordinates are `decimal`, never `float`. | **flip reserved → 🟡** |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `address_line`/`postal_code`/recipient fields; **never logs plaintext PII**; deterministic `Hash` available if a lookup is ever needed. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; the geo-lookup queries read through `GetOrCreateAsync` and admin writes invalidate the geo keys. | reuse row |
The mock lives behind a **DI-registered interface** in Infrastructure (real impl is a drop-in later);
selection is **config-driven, never an `if (mock)` branch in a handler**. Flip the reserved `IGeocoder`
row in [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
to 🟡 with: the seam (interface + file), what's faked, the config keys it reads, and **step-by-step how to
make it real** — register a **Neshan** (or Google) geocoding client implementing `IGeocoder`, the API-key
config, the request/response mapping to `(lat, lng, formatted_address, confidence)`, rate-limit/retry, and
what to test (a known Tehran address resolves within the expected bounds).
## 5. Critical rules you must not get wrong
- **`district_id = NULL` is a MEANINGFUL value ("entire city"), not missing data.** It must participate
correctly in the `UNIQUE(nurse_id, city_id, district_id)` constraint (a nurse cannot declare the same
whole-city coverage twice) **and** it must be a real coverage choice that later search treats as
"matches every district in that city". SQL Server's default NULL-distinct behaviour will silently let
duplicate whole-city rows through — defeat that with the filtered-index pair (or sentinel) in §3.1.
**Never** treat a NULL district as "the nurse forgot to pick one".
- **Geography is named districts, NOT GPS radii.** Do not implement, or leave room to implement, a
radius/haversine coverage model. Coverage is the set of `(city, district?)` rows a nurse declared;
search intersects rows, not circles. (The address lat/lng exists only for the later EVV *distance check*
against the booking site, not for coverage matching.)
- **Respect `is_active` — deactivation hides, it never deletes.** A toggled-off province/city/district
must disappear from the public cascading dropdowns (the queries filter `is_active=1` at every level and
honour the parent's active state), **without** deleting the region or orphaning the addresses/service
areas that already reference it. Deletion is reserved for genuinely erroneous rows with no children.
- **Exactly one primary address per customer.** Enforce it both in the handler (clearing the prior primary
in the same unit of work when a new primary is set) **and** with the filtered
`UNIQUE(customer_id) WHERE is_primary=1` index as the authoritative DB backstop — a race that tries to
set a second primary fails on the constraint, not silently. The customer's first address is primary by
default.
- **Addresses are encrypted PII.** `address_line`, `postal_code`, and recipient name/phone go through
`IFieldEncryptor` at rest — never stored or logged in plaintext, never returned in a list projection to
anyone but the owning customer. Decrypt only in the owner's own read path. (Coordinates and the `title`
label are not PII and may stay plaintext.)
- **Tenancy.** `nurse_id` on a service area and `customer_id` on an address come from `ICurrentUser`, never
from the request body; a nurse/customer can only read and mutate **their own** rows; cross-tenant access
returns `404`, not `403` revealing existence. (Booking-time tenancy — an address used in a booking must
belong to that booking's customer — is enforced in b8/b9; don't pre-build it, but don't break it.)
- **Reference reads are cached, writes invalidate.** The geo-lookup queries are read-heavy and near-static;
serve them through `ICacheService` and invalidate the relevant keys on every admin geo write so a
newly-activated city appears and a deactivated one disappears promptly. Don't hardcode the list in code.
- **Projection + pagination always.** Every read uses `AsNoTracking()` + `.Select(...)` to a DTO and every
list is paginated; no unbounded `ToListAsync()`, no entity hydration just to map.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The five tables (`provinces`, `cities`, `districts`, `nurse_service_areas`, `customer_addresses`)
exist via **one migration**, each with its `IEntityTypeConfiguration<T>`, soft-delete query filter,
and audit wiring; the `nurse_service_areas` whole-city-aware uniqueness and the
`customer_addresses` filtered `UNIQUE(customer_id) WHERE is_primary=1` are real DB constraints;
coordinates are `decimal(9,6)`; PII columns are encrypted.
- [ ] The seed (§3.2) loads provinces + major cities + Tehran's 22 districts **idempotently** and runs via
the b1 seeding path.
- [ ] All §3.3 commands/queries implemented (CQRS, `OperationResult`, projected + paginated + cached reads,
validators), with `GeoController`, `AdminGeoController`, `NurseServiceAreasController`,
`CustomerAddressesController`.
- [ ] **`IGeocoder`** introduced (Application interface, Infrastructure mock, DI registration via a
`ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers. Address create/update
sets coordinates from it.
- [ ] Handler unit tests (NSubstitute) for: the cascading lookups, the **duplicate service-area (incl.
duplicate whole-city) rejection**, single-primary enforcement (setting a second primary clears the
first / is blocked by the filtered index), geocode wiring, and `is_active` filtering. ≥1
`WebApplicationFactory` integration test per controller (happy path, 401, validation 400).
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The **Project map** in `server/CLAUDE.md` reflects `Features/Geography/**`,
`Features/ServiceAreas/**`, `Features/Addresses/**` and the new `Geography` domain folder + the
`IGeocoder` seam; the contract `dev/contracts/domains/geography-addresses.md` is written and the
`swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
1. **Seed ran** — start the API → `GET api/v1/geo/provinces` returns the 31 provinces ordered by
`sort_order`; `GET api/v1/geo/cities?province_id={Tehran}` includes Tehran; `GET api/v1/geo/districts?city_id={Tehran}`
returns the **22 districts**; `GET api/v1/geo/districts?city_id={Mashhad}` returns an **empty list**
(valid — whole-city only).
2. **Cascading dropdown**`provinces` → pick one → `cities?province_id=` → pick one → `districts?city_id=`
each returns only `is_active=1` rows in `sort_order`; (or `GET api/v1/geo/tree` returns the whole active
tree in one payload).
3. **Admin toggle**`POST api/v1/admin_geo/cities/{id}/set_active` (deactivate) → that city **disappears**
from `GET api/v1/geo/cities` for its province (and its districts disappear) **without** being deleted;
re-activate → it returns. Confirms `is_active` hides, not deletes.
4. **Nurse adds a service area** — as a nurse, `POST api/v1/nurse_service_areas { city_id }` (no district)
`201`, a **whole-city** area; `GET api/v1/nurse_service_areas` shows it flagged "whole city".
5. **Duplicate rejected** — repeat the same `POST` (same city, no district) → **`409`** (the whole-city
uniqueness fires, not a 500); add `{ city_id, district_id }` for a district in that city → `201`; repeat
that → **`409`**.
6. **Create a geocoded address** — as a customer, `POST api/v1/customer_addresses { title, city_id,
address_line, is_primary:true }` → `201` with **`latitude`/`longitude` populated** (from the `IGeocoder`
mock); `GET api/v1/customer_addresses` shows it primary-first with the address decrypted for the owner.
7. **Single-primary enforced** — create a second address with `is_primary:true` (or
`POST …/{id}/set_primary`) → it becomes primary and the **previous primary is cleared**; an attempt to
force two primaries is rejected by the filtered unique index. Confirm only one `is_primary=1` row exists.
8. **PII not leaked** — confirm `address_line` is stored encrypted (inspect the row / a non-owner read does
not return the plaintext) and never appears in logs.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the new
`Geography` domain + the `Features/Geography|ServiceAreas|Addresses/**` areas and the `IGeocoder` seam);
if you confirm/decide a rule the product docs don't capture — e.g. the **filtered-index pair** chosen to
make whole-city (`district_id=NULL`) uniqueness real, or the address-is-primary-by-default-on-first rule —
record it in [`product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) or
[`01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md) (regenerate the HTML
view per `product/CLAUDE.md`). **Don't invent rules.**
- **Contract to write:** **`dev/contracts/domains/geography-addresses.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public geo lookups
(provinces/cities/districts/tree), the admin geo CRUD + set_active endpoints, the nurse service-area
add/remove/list, and the customer address CRUD + set_primary; the DTO shapes (`ProvinceDto`, `CityDto`,
`DistrictDto`, `NurseServiceAreaDto` with the **"whole city"** flag, `CustomerAddressDto` with **masked**
PII and `decimal` coordinates); the `district_id=NULL` ⇒ whole-city semantics spelled out;
auth/rate-limit/tenancy notes; the `409` duplicate-area and single-primary side-effects. Republish the
`swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This
is what **f3-b4** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-4.md` (geo
hierarchy + addresses + service areas are live; what f3 can now build — address book + map-pin picker +
cascading province/city/district dropdowns, nurse coverage-area editor; which endpoints/contracts are
live; that geocoding is mocked behind `IGeocoder`; the `district_id=NULL` semantics the frontend must
honour). Append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-4-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contract produced, follow-ups: the b7 search
fan-out hook, the DEFERRED EVV distance check, the region bulk-import feed), and update
`dev/shared-working-context/reports/mocks-registry.md` (flip the `IGeocoder` row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
**whole-city (`district_id=NULL`) uniqueness solution** (the filtered-index pair / sentinel), the
named-districts-not-GPS-radii rule, the single-primary filtered unique + handler pattern, the address-PII
encryption columns, and the `IGeocoder` seam — with a one-line pointer in `MEMORY.md`.
+436
View File
@@ -0,0 +1,436 @@
# Backend Phase 5 — Service catalog & nurse pricing variants
> **Mission:** stand up the two-tier service model that the entire marketplace is priced and searched on.
> First the **admin catalog skeleton** — top-level care **categories** plus EAV-style configurable
> **option groups** and **option values** so new pricing dimensions ship as *data*, not migrations. Then the
> **nurse pricing layer** — the `nurse_service_variants` that are the **atomic bookable unit** of the whole
> platform: a nurse + a category + one chosen value per required dimension, at the nurse's own price and
> price unit. Transparent, upfront, nurse-set pricing is a deliberate differentiator versus the opaque
> "توافقی / negotiable" incumbents — and after this phase, search (b7), booking (b8), and every downstream
> money calculation operate on a variant, never on a nurse.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`nurse_profiles`, identity/roles), [backend-phase-1](backend-phase-1.md) (marketplace migration baseline, seed/config, admin auth) · **Unlocks:** search & matching ([backend-phase-7](backend-phase-7.md)), booking requests & lifecycle ([backend-phase-8](backend-phase-8.md)), and the catalog browse + service-builder UI ([frontend-phase-4-b5](../frontend/frontend-phase-4-b5.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **catalog & pricing** phase. Identity exists (b2/b3): there are `users` with a `role`
(`admin`/`nurse`/`customer`) and a `gender`, and every nurse has a `nurse_profiles` row. Geography exists
(b4): `provinces`/`cities`/`districts` are seeded and `nurse_service_areas` declares where a nurse will
travel. Config & reference (b1) gave us the **first marketplace migration baseline**, the typed cached
config accessor, admin auth, and the `support_alerts`/`notifications` plumbing. None of that defines *what a
nurse sells or for how much* — that is this phase.
The product models catalog in **three admin layers** (category → option group → option value) and **two
nurse layers** (variant → variant option). The admin layers are an intentionally **EAV-style configurable
structure**: an admin can introduce a new pricing dimension ("live-in", "number of patients") as rows, with
**no schema migration**. The nurse layers turn that skeleton into priced, bookable offerings. The output of
this phase is the thing the customer actually pays for: a **variant**.
**What already exists (do not rebuild):**
- **Identity, roles & nurse profiles** — [backend-phase-3](backend-phase-3.md) built `nurse_profiles`
(1:1 → `users`, carrying `is_verified`, `is_accepting_bookings`, and the denormalized
`average_rating`/`total_reviews`/`total_completed_bookings` aggregates), `customer_profiles`, `patients`,
and `nurse_bank_accounts`. [backend-phase-2](backend-phase-2.md) established phone-OTP auth, sessions, and
`users.gender` (`male`/`female`). **Variants FK to `nurse_profiles`** — read that entity; do not re-model
it. Tenancy ("only the owning nurse edits their variants") keys off `ICurrentUser``nurse_profiles`.
- **Config, admin auth & the migration baseline** — [backend-phase-1](backend-phase-1.md) created the **first
marketplace EF Core migration** (the baseline every later phase extends additively), `platform_configs`
(typed cached accessor), `audit_logs` (written by the SaveChanges interceptor), the in-app `notifications`
write, and the `support_alerts` raise API. Your migration is **additive** on top of b1's baseline. Admin
endpoints use the admin policy established by b1/b2 — reuse it.
- **Geography** — [backend-phase-4](backend-phase-4.md) built `provinces`/`cities`/`districts` (+ seed) and
`nurse_service_areas` (`district_id = NULL` ⇒ whole city; `UNIQUE(nurse_id, city_id, district_id)`).
Catalog itself does **not** touch geography — but b7 fans a variant out across its nurse's service areas
into the search index, so keep the variant shape clean for that projection.
- **Cross-cutting seams** — [backend-phase-0](backend-phase-0.md) introduced `ICacheService`,
`IDateTimeProvider`, `IFieldEncryptor`, `IObjectStorage`, and `INotificationDispatcher`, plus the REST
surface (`BaseController`, snake_case routing, rate limiting), the CQRS pipeline
(`ISender`/`ICommand`/`IQuery`, `ValidateCommandBehavior`, `OperationResult<T>`), and the audit-field
interceptor. **Reuse `ICacheService`** for the read-heavy public catalog reads; do not introduce new seams.
> The **denormalized `nurse_search_index`**, the `INurseSearch` search seam, and the index-maintenance
> hooks that fan a variant out per covered area are owned by **[backend-phase-7](backend-phase-7.md)**, *not*
> this phase. You build the variant as a clean, projectable source; b7 reads it. Do **not** create the search
> index or a search query here. **(DEFERRED → b7.)**
>
> The **`variant_snapshot_json`** that freezes a variant onto a booking at booking time is owned by the
> **Booking area ([backend-phase-8](backend-phase-8.md))**. This phase ships a **variant-snapshot serializer**
> (a pure library function — §3.4) that b8 *consumes*; it is not an endpoint here. **(snapshot persistence
> DEFERRED → b8.)**
>
> **`nurse_availability_slots` / `nurse_availability_exceptions`** are **soft scheduling guidance only** — not
> on the money or safety path. They are **(DEFERRED)** for MVP; see
> [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md).
> Note them in your report; **do not build** them.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
— admin defines the catalog skeleton (categories + configurable option groups/values, addable without a
schema change); each nurse defines their own variants (category + chosen option combination + own price +
price unit); the **five price units** (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`); the
auto-generated-but-editable `display_name`; **deactivate, never delete**; and that catalog is snapshotted
onto the booking. Read **(b) Iran-specific** — why `per_24h`/`per_day` are first-class and why upfront
pricing is the differentiator.
- **Product — data model (source of truth):**
[`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
— the three admin layers + two nurse layers, **why the EAV configurability is load-bearing**, why the
**variant is the bookable unit (not the nurse)**, the `UNIQUE(variant_id, option_group_id)` "one value per
dimension" rule, the **NULL `service_category_id` = cross-category** rule, and the "consider a uniqueness
strategy on `(nurse_id, category, option-set)`" guidance you will implement as the duplicate-listing guard.
- **Type & money rules on the wire:**
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**price is IRR Rials as an integer** (`BIGINT`), no floats, and money crosses the wire as a string of
digits; the `price_unit` enum is a stable string code; `name_fa`/`name_en` reference data returns both.
- **Code to mirror (existing patterns):** an existing feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`), an
`IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`, a `sealed` controller under
`Baya.Web.Api/Controllers/V1/` (`BaseController`, inject `ISender`, `[controller]`/`[action]` snake_case
tokens, `base.OperationResult(...)`), the b1 reference-data **seed** pattern (how seeded `name_fa`/`name_en`
rows were inserted in the baseline migration), and how reads use `AsNoTracking()` + `.Select()` projection
+ pagination + `ICacheService`.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, status codes, mandatory list pagination, localisation of `name_fa`/`name_en`).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md` (the
`nurse_profiles` shape + the admin/nurse policies) and `.../after-backend-phase-1.md` (the migration
baseline + config accessor + admin policy).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + migration → command/query handler(s) → controller
endpoint → contract. Everything async with `CancellationToken`; reads are `AsNoTracking()` + `.Select()`
projection + pagination; writes go through `IUnitOfWork` with a single `CommitAsync`. Money is IRR `BIGINT`.
### 3.1 Entities, configs & migration
Add these five tables as **one additive EF Core migration** on top of b1's baseline. One
`IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/CatalogConfig/`. All catalog rows
carry a **`name_fa` (primary) + `name_en`** pair — never persist a category/group/value without the Persian
label.
- **`service_categories`** — admin-managed top-level care types; the primary search dimension.
- Columns: `id` (BIGINT PK), `name_fa` (NVARCHAR, required), `name_en` (NVARCHAR, required),
`description_fa`/`description_en` (NVARCHAR, nullable), `icon_key` (NVARCHAR, nullable — UI glyph),
`sort_order` (INT, for ordered display), `is_active` (BIT, default 1), plus the audit fields stamped by
the interceptor (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) and soft-delete (`deleted_at`).
- **Seed** the five MVP categories with both labels: **Elderly Care** (مراقبت از سالمند), **Post-Surgery
Recovery** (مراقبت پس از جراحی), **Infant Care** (مراقبت از نوزاد), **Chronic Illness Management**
(مدیریت بیماری مزمن), **Companionship** (همراهی / مراقبت روزمره). Seed in the migration (b1 baseline
pattern) so a nurse can build a variant immediately.
- Index: `(is_active, sort_order)` for the public ordered list; soft-delete query filter (`!IsDeleted`).
- **`service_option_groups`** — admin-managed configurable **dimensions** (e.g. نوع شیفت / shift type,
تعداد بیمار / patient count).
- Columns: `id` (BIGINT PK), `service_category_id` (BIGINT FK → `service_categories`, **NULLABLE**
**NULL = cross-category**, applies everywhere), `name_fa`/`name_en` (required), `is_required` (BIT —
whether a variant must answer this group), `sort_order` (INT), `is_active` (BIT), + audit + soft-delete.
- Index: `(service_category_id, sort_order)`; soft-delete query filter. The nullable FK is deliberate —
enforce nothing that breaks the cross-category (NULL) case.
- **`service_option_values`** — the concrete choices within a group (e.g. شبانه‌روزی, ۲ نفر).
- Columns: `id` (BIGINT PK), `option_group_id` (BIGINT FK → `service_option_groups`), `name_fa`/`name_en`
(required), `sort_order` (INT), `is_active` (BIT), + audit + soft-delete.
- Index: `(option_group_id, sort_order)`; soft-delete query filter.
- **`nurse_service_variants`** — **the atomic bookable unit.** A nurse + category + chosen option combination
at a price.
- Columns: `id` (BIGINT PK), `nurse_id` (BIGINT FK → `nurse_profiles`), `service_category_id` (BIGINT FK →
`service_categories`), `price` (**BIGINT — IRR Rials, integer, no float, ever**), `price_unit` (NVARCHAR
stable code — closed set `per_hour` | `per_session` | `per_half_day` | `per_day` | `per_24h`),
`session_count` (INT, nullable — number of sessions/units the engagement spans; relevant for
`per_session` and packages), `display_name` (NVARCHAR — **auto-generated from the option labels, but
nurse-editable**), `is_active` (BIT, default 1 — deactivation, never hard-delete), + audit + soft-delete.
- **Indexes / constraints:** index on `(nurse_id, is_active)` for the nurse's offerings list and the b7
index projection; index on `service_category_id`; the **duplicate-listing guard** (§3.3) on
`(nurse_id, service_category_id, option-set)`; soft-delete query filter (`!IsDeleted`).
- `price_unit` is the **only** value in this area allowed to be a closed code enum — categories, groups,
and values are **data**, never code constants (EAV is load-bearing).
- **`nurse_service_variant_options`** — the option values that define one variant's configuration.
- Columns: `id` (BIGINT PK), `variant_id` (BIGINT FK → `nurse_service_variants`), `option_group_id` (BIGINT
FK → `service_option_groups`), `option_value_id` (BIGINT FK → `service_option_values`), + audit.
- **`UNIQUE(variant_id, option_group_id)`** — **one value per dimension per variant** (a variant cannot
answer the same group twice). Index `(variant_id)` for loading a variant's full option set.
> **Do not add `nurse_search_index` here.** It is b7's denormalized read model. **Do not add
> `variant_snapshot_json`** — it lives on `booking_requests` and is owned by b8. **Do not add
> `nurse_availability_slots`/`_exceptions`** — DEFERRED.
### 3.2 Admin catalog — commands & queries
Feature folder `Baya.Application/Features/Catalog/`. **Admin-only** (narrowest admin policy from b1/b2);
these mutate the platform skeleton.
- **`CreateServiceCategoryCommand`** / **`UpdateServiceCategoryCommand`** (`Commands/CreateServiceCategory/`,
`.../UpdateServiceCategory/`) — set `name_fa`/`name_en` (both required), descriptions, `icon_key`,
`sort_order`. FluentValidation: both labels non-empty.
- **`SetServiceCategoryActiveCommand`** (`Commands/SetServiceCategoryActive/`) — activate/deactivate
(`is_active`). **Soft state only — never hard-delete.** Deactivating a category hides it from public browse
and from new variant creation; existing variants in that category are left intact (their bookings/history
must survive — see §5).
- **`CreateServiceOptionGroupCommand`** / **`UpdateServiceOptionGroupCommand`**
(`Commands/CreateServiceOptionGroup/`, `.../UpdateServiceOptionGroup/`) — set `service_category_id`
(**nullable — NULL marks the group cross-category**), `name_fa`/`name_en`, `is_required`, `sort_order`.
- **`CreateServiceOptionValueCommand`** / **`UpdateServiceOptionValueCommand`**
(`Commands/CreateServiceOptionValue/`, `.../UpdateServiceOptionValue/`) — set `option_group_id`,
`name_fa`/`name_en`, `sort_order`, `is_active`.
- **`GetCatalogCategoriesQuery`** (`Queries/GetCatalogCategories/`) — **public**, paginated, `is_active`
categories ordered by `sort_order`, returning `name_fa`/`name_en`. `AsNoTracking()` + `.Select()`;
**cache through `ICacheService`** with invalidation on any category mutation (read-heavy reference data).
- **`GetCategoryOptionGroupsQuery`** (`Queries/GetCategoryOptionGroups/`) — **public**, for a category id,
returns its **applicable** option groups (the category's own groups **plus** all cross-category
(NULL-category) groups), each with its active option values, `is_required`, ordered by `sort_order`. This
is the skeleton the nurse builder fills in and the customer browses. Cache + invalidate on group/value
mutation.
### 3.3 Nurse variants — commands & queries
Feature folder `Baya.Application/Features/Variants/` (or a `Catalog/Variants/` sub-area, matching the
surrounding convention). **Nurse-owner-only** for writes; tenancy via `ICurrentUser``nurse_profiles`.
- **`CreateVariantCommand`** (`Commands/CreateVariant/`) — the nurse picks a `service_category_id`, supplies
the chosen `option_value_id` for each required group (and any optional groups they answer), sets `price`
(IRR BIGINT) + `price_unit` (one of the five) + optional `session_count`, and an optional `display_name`
override. The handler, in one transaction:
1. **Validate the category** exists and is active.
2. **Resolve applicable groups** (the category's groups + cross-category groups) and assert **every
`is_required` group is answered exactly once** and **no group is answered twice** (one value per
dimension); reject unknown groups/values, or a value that does not belong to its claimed group.
3. **Duplicate-listing guard:** reject if this nurse already has a non-deleted variant in the same category
with the **identical answered option-set** (same set of `(option_group_id, option_value_id)` pairs).
Enforce with a DB backstop (see below) **and** an explicit pre-check returning a clean conflict
`OperationResult`, never a raw DB exception.
4. **Auto-generate `display_name`** from the chosen option labels (e.g. category + " · " + value labels)
when the nurse did not override it.
5. Insert the variant + its `nurse_service_variant_options` rows; `CommitAsync` once.
- FluentValidation: `price > 0`, `price_unit` in the closed set, `session_count` null or `> 0`, at least
the required groups present.
- **DB backstop for the duplicate guard:** because the option-set is multi-row, a plain composite unique
index is insufficient. Persist a deterministic **`option_set_hash`** column on `nurse_service_variants`
(a stable hash of the sorted `(option_group_id, option_value_id)` pairs, computed in the handler) and put
a **filtered `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL`** on it.
This makes the guard race-safe; the pre-check gives the friendly message. (If b1's conventions already
established a canonical hashing helper, reuse it.)
- **`UpdateVariantCommand`** (`Commands/UpdateVariant/`) — owner edits `price`, `price_unit`,
`session_count`, and `display_name`. Re-validate price/unit; if the edit would collide with another of the
nurse's variants' option-set, reject. (Changing the **option-set** itself is treated as create-new +
deactivate-old to keep historical meaning stable — do not silently mutate a variant's dimensions; if you
do allow option edits, recompute `option_set_hash` and re-run the duplicate guard.)
- **`SetVariantActiveCommand`** (`Commands/SetVariantActive/`) — owner activates/deactivates (`is_active`).
**Deactivate, never hard-delete.** A deactivated variant cannot be booked and (via b7) must drop out of the
search index. Editing `display_name` may piggyback as `EditVariantDisplayNameCommand` or be folded into
`UpdateVariantCommand` — match the surrounding granularity.
- **`ListMyVariantsQuery`** (`Queries/ListMyVariants/`) — the signed-in nurse's own offerings, **active and
inactive**, paginated, each with category label, resolved option labels, price, unit, `session_count`,
`display_name`, `is_active`. `AsNoTracking()` + `.Select()` projection.
- **`GetVariantQuery`** (`Queries/GetVariant/`) — a single variant with its full resolved option-set and
labels, for the edit screen and for b8's booking-request capture to read the canonical offering. Owner or
admin for the full view; a public-safe projection (price/unit/labels, no internal fields) backs the
customer-facing nurse profile.
### 3.4 Variant-snapshot serializer (library function for b8 — not an endpoint)
- **`IVariantSnapshotSerializer`** (interface in `Application/Contracts/`, implementation in Application or
Infrastructure) — a pure function `string Serialize(variant + resolved options)` that emits the canonical
**`variant_snapshot_json`**: category id + `name_fa`/`name_en`, each `(option_group label, option_value
label)`, `price`, `price_unit`, `session_count`, `display_name`, and the variant id, **as they are at
serialize time**. Booking ([backend-phase-8](backend-phase-8.md)) calls this to freeze the offering onto
`booking_requests.variant_snapshot_json` so later variant edits/deactivation never mutate past bookings,
disputes, or invoices. **This phase ships and unit-tests the serializer; b8 persists its output.** Do not
add the snapshot column here.
### 3.5 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy, lists
paginated). Routes shown logically; the snake_case transformer produces the real segments.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/admin/catalog/categories` | `CreateServiceCategoryCommand` | admin |
| `PUT /v1/admin/catalog/categories/{id}` | `UpdateServiceCategoryCommand` | admin |
| `PATCH /v1/admin/catalog/categories/{id}/active` | `SetServiceCategoryActiveCommand` | admin |
| `POST /v1/admin/catalog/option-groups` | `CreateServiceOptionGroupCommand` | admin |
| `PUT /v1/admin/catalog/option-groups/{id}` | `UpdateServiceOptionGroupCommand` | admin |
| `POST /v1/admin/catalog/option-values` | `CreateServiceOptionValueCommand` | admin |
| `PUT /v1/admin/catalog/option-values/{id}` | `UpdateServiceOptionValueCommand` | admin |
| `GET /v1/catalog/categories` | `GetCatalogCategoriesQuery` | public |
| `GET /v1/catalog/categories/{id}/option-groups` | `GetCategoryOptionGroupsQuery` | public |
| `POST /v1/nurse/variants` | `CreateVariantCommand` | nurse (owner) |
| `PUT /v1/nurse/variants/{id}` | `UpdateVariantCommand` | nurse (owner) |
| `PATCH /v1/nurse/variants/{id}/active` | `SetVariantActiveCommand` | nurse (owner) |
| `GET /v1/nurse/variants` | `ListMyVariantsQuery` | nurse (owner) |
| `GET /v1/nurse/variants/{id}` | `GetVariantQuery` | owner / admin (full) · public (safe projection) |
### 3.6 Out of scope (DEFERRED — build the seam/hook/serializer, not the feature)
- **`nurse_search_index`, the `INurseSearch` seam, the search query, and index fan-out/maintenance** —
**(DEFERRED → [backend-phase-7](backend-phase-7.md))**. Keep the variant shape projection-friendly; b7
reads category/price/unit/`is_active` from it.
- **`variant_snapshot_json` persistence** — **(DEFERRED → [backend-phase-8](backend-phase-8.md))**. You ship
the serializer (§3.4); b8 writes the column.
- **`nurse_availability_slots` / `nurse_availability_exceptions`** — soft guidance, not money/safety path —
**(DEFERRED)**; note them, don't build.
- **Holiday/surge pricing rules engine; the lighter Companionship/daily-living *tier* as a pricing model;
dynamic/tiered commission per category** — **(DEFERRED)**, see
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
(c). Companionship ships only as a seeded **category** (data), not as a special pricing path.
## 4. Mocks & seams in this phase
**None.** Catalog and variant data are fully owned by Balinyaar's database — there is no third-party service
to mock here. This phase **introduces no cross-cutting seam.**
- **Reuse `ICacheService`** (from [backend-phase-0](backend-phase-0.md)) for the read-heavy public catalog
reads (`GetCatalogCategories`, `GetCategoryOptionGroups`) with invalidation on the matching admin mutation.
Do **not** redefine it.
- The **search-index writer** seam (`INurseSearch` / `ISearchIndexWriter`) that variant writes will fan out
through is **introduced in [backend-phase-7](backend-phase-7.md)**, not here. Do not pre-build it; leave the
registry row to b7. (Listed in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md).)
- `IVariantSnapshotSerializer` (§3.4) is an **internal application contract**, not an external-service seam —
it does not go in the mock registry. It has a single real implementation.
Because this phase mocks nothing, there is no `mocks-registry.md` row to add — but you **must** still write
the phase report (§8).
## 5. Critical rules you must not get wrong
- **The bookable unit is the variant, NOT the nurse.** Search (b7), booking (b8), and every pricing
calculation operate on `nurse_service_variants` — a nurse who has *no* active variant is not bookable. Do
not anywhere treat "a nurse" as the priced/bookable entity; the customer pays for a specific variant.
- **`price` is IRR Rials as an integer (`BIGINT`) — no floats, anywhere.** Never store, compute, or serialize
price as a float/decimal-with-fraction; there is no Toman in the DB or the contract. The engagement total
is **`price` combined with `price_unit` and `session_count`** — **do NOT compute a total from `price`
alone**; the unit and session count are load-bearing and a downstream consumer (booking) derives the total
from all three. Money crosses the wire as a string of digits per
[`money-and-types.md`](../../contracts/conventions/money-and-types.md).
- **One value per dimension per variant.** Enforce **`UNIQUE(variant_id, option_group_id)`** on
`nurse_service_variant_options` — a variant must never answer the same option group twice. Validate this in
the handler *and* let the unique index be the authoritative backstop.
- **All required option groups must be answered.** A `CreateVariant`/`UpdateVariant` that omits any
applicable `is_required` group is a validation failure (clean `OperationResult`, not an exception). The
applicable set = the category's own groups **plus** every cross-category (NULL `service_category_id`) group.
- **A NULL-category option group applies cross-category.** `service_option_groups.service_category_id = NULL`
means the dimension applies to *every* category. `GetCategoryOptionGroups`, the required-group check, and
the duplicate guard must all include cross-category groups — silently dropping them is a defect.
- **Catalog must be seeded before any nurse can create a variant.** The five seed categories ship in this
phase's migration; a variant create against a missing/inactive category fails cleanly. Do not let variant
creation succeed against a category that does not exist or is deactivated.
- **Duplicate identical listings are rejected.** Two non-deleted variants for the same nurse, same category,
and the **identical answered option-set** are not allowed — guarded by the filtered
`UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS NULL` backstop plus a friendly
pre-check conflict message. (A nurse may, of course, have many variants per category — just not two
*identical* ones.)
- **Deactivate, never hard-delete.** Variants (and categories/groups/values) soft-deactivate. A deactivated
variant is unbookable and (via b7) drops out of search. **Past bookings, snapshots, disputes, and invoices
must never be mutated by a later catalog edit or deactivation** — historical records survive via the
snapshot (§3.4), which is the entire reason snapshotting exists.
- **EAV is load-bearing — categories/groups/values are DATA, not code.** Do not hardcode categories, option
groups, or option values as C# enums/constants. The **only** closed code enum in this area is the
`price_unit` set (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`). Admins must be able to add a
new dimension as rows with **no migration**.
- **Every catalog row carries `name_fa` (primary) + `name_en`.** Never persist or return a category/group/
value without both labels; the client picks by locale (it never derives a label from a code).
- **Tenancy & authority.** Only the **owning nurse** may create/edit/deactivate their variants (check
`ICurrentUser``nurse_profiles`, not just a role); only **admins** may touch the catalog skeleton
(categories/groups/values). A nurse editing another nurse's variant is a `403`/`NotFound`, never a success.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `service_categories`, `service_option_groups`, `service_option_values`, `nurse_service_variants`,
`nurse_service_variant_options` exist via **one additive migration** with the §3.1 constraints:
`UNIQUE(variant_id, option_group_id)`; the nullable `service_option_groups.service_category_id`; the
filtered duplicate-listing `UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS
NULL`; `price` as `BIGINT`; soft-delete query filters. The **five seed categories** (`name_fa`+`name_en`)
are present.
- [ ] Admin CRUD (`Create/Update/SetActive` for categories; `Create/Update` for option groups & values) and
the public `GetCatalogCategories` / `GetCategoryOptionGroups` queries are implemented as CQRS features
with validators and the §3.5 endpoints, returning the standard `OperationResult` envelope. The public
reads are cached via `ICacheService` and invalidated on the matching mutation.
- [ ] Nurse `CreateVariant` / `UpdateVariant` / `SetVariantActive` / `ListMyVariants` / `GetVariant` are
implemented with: required-group enforcement (incl. cross-category groups), one-value-per-dimension,
the duplicate-listing guard (pre-check + DB backstop), auto-generated-but-editable `display_name`, and
owner-tenancy. Variants deactivate, never hard-delete.
- [ ] `IVariantSnapshotSerializer` is implemented and **unit-tested** (its JSON contains category labels,
each option label, price, unit, session count) — ready for b8 to persist.
- [ ] Tests prove: admin creates category + required group + values; a nurse builds a valid variant; a
**duplicate identical listing is rejected**; **missing a required group fails validation**; `ListMyVariants`
returns the nurse's active + inactive variants. (See §7.)
- [ ] `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/catalog.md` is written and the `swagger.json` snapshot is refreshed;
the `server/CLAUDE.md` *Project map* notes the new `Features/Catalog` (+ variants) area and the
`CatalogConfig` configuration folder.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger or
curl. The expected results below become the "what can be tested" section of your report.
1. **Catalog is seeded.** `GET /v1/catalog/categories``200`, lists the five seed categories with
`name_fa`+`name_en`, ordered by `sort_order`, active only.
2. **Admin builds a dimension.** As admin, `POST /v1/admin/catalog/option-groups` with
`service_category_id` = Elderly Care, `name_fa: "نوع شیفت"`, `name_en: "Shift type"`, `is_required: true`
`200`. Then `POST /v1/admin/catalog/option-values` twice (e.g. `روزانه / Daytime`, `شبانه‌روزی /
Live-in`) → both `200`. `GET /v1/catalog/categories/{elderly_id}/option-groups` → returns the new required
group **plus any cross-category groups**, each with its values.
3. **Nurse builds a valid variant.** As a nurse, `POST /v1/nurse/variants` with the Elderly category, the
required shift-type value = شبانه‌روزی, `price: "8000000"` (IRR string), `price_unit: per_24h``200`,
variant created `is_active: true`, with an auto-generated `display_name` containing the category + option
labels.
4. **Duplicate identical listing is rejected.** Repeat the exact same `POST /v1/nurse/variants` (same
category + same option-set) → a clean **conflict** `OperationResult` failure (not a 500, not a raw DB
exception).
5. **Missing a required group fails validation.** `POST /v1/nurse/variants` for Elderly **without** the
required shift-type value → a `400`/validation `OperationResult` failure naming the missing required group.
6. **One value per dimension.** Attempt to send two values for the same option group in one create → rejected
(handler + the `UNIQUE(variant_id, option_group_id)` backstop).
7. **List my variants (active + inactive).** `GET /v1/nurse/variants` → both the active variant and (after
`PATCH /v1/nurse/variants/{id}/active` → inactive) the deactivated one appear, visually/dataly distinct;
the deactivated one is flagged unbookable. The variant row is **never** hard-deleted.
8. **Tenancy.** As a *different* nurse, `PUT /v1/nurse/variants/{id}` on the first nurse's variant →
`403`/`NotFound`, never a success.
9. **Snapshot serializer (unit test).** A unit test serializes a variant + its options and asserts the JSON
carries the category labels, each option label, price, `price_unit`, and `session_count`.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Catalog`
(categories/option-groups/option-values) and the nurse `Variants` feature area, the five new tables + the
`Persistence/Configuration/CatalogConfig/` folder, and the `IVariantSnapshotSerializer` contract (and that
it is consumed by b8). If you established a reusable option-set hashing helper, note it in
`server/CONVENTIONS.md`. If you discovered/decided any business rule not already in the product docs,
reflect it in
[`product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
or [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you touched
Markdown).
- **Contract to write:** publish **`dev/contracts/domains/catalog.md`** (the §3.5 routes; request/response
shapes for categories, option groups, option values, and variants; the `price_unit` enum; the `name_fa`/
`name_en` pairing; the IRR-string money format; the required-group/duplicate-listing failure cases and
status codes — `409` on a duplicate listing, `400` on a missing required group; examples) per
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md), starting from the
[domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
[frontend-phase-4-b5](../frontend/frontend-phase-4-b5.md) can derive its types (it does not guess shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-5.md` (the catalog
is seeded and admin-CRUDable; the public catalog browse endpoints are live; nurses can build/edit/deactivate
variants; the `IVariantSnapshotSerializer` is ready for b8; **what b7 must read** from the variant for the
search index; **what f4-b5 can now build** — category grid + service builder; the duplicate-listing,
required-group, and one-value-per-dimension rules the frontend must respect). Append your phase summary to
`shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-5-report.md` (what was built,
what is now testable and exactly how — the §7 steps — that **nothing is mocked** in this phase, the contract
produced, and follow-ups for b7 (index projection) and b8 (snapshot persistence)). This phase adds **no**
`mocks-registry.md` row (it mocks nothing) — state that explicitly in the report so the next agent doesn't
go looking.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **variant is the
bookable unit** principle, the **EAV / NULL-category cross-category** rule, the **`option_set_hash` +
filtered-unique** duplicate-listing strategy, and the **price + price_unit + session_count (never price
alone)** total rule — with a one-line `MEMORY.md` pointer.
+353
View File
@@ -0,0 +1,353 @@
# Backend Phase 6 — Nurse verification & credentials (mocked vendors)
> **Mission:** build the **trust engine** — the platform's entire brand. A **data-driven**, platform-owned
> verification pipeline (steps are *rows*, not an enum) that mixes automated KYC-vendor checks with manual
> admin document review, rolls every per-step outcome into one authoritative `nurse_verifications.status`,
> maintains a structured, queryable **credential registry** (license numbers, authority, holder name,
> issue/expiry), and **transactionally flips `nurse_profiles.is_verified`** the moment every required step
> passes (and reverses it on suspension). Documents live in object storage behind signed URLs — only
> metadata + an integrity hash touch the DB. Every external vendor (Shahkar, identity KYC/liveness, the
> MoH/INO/criminal-record portals, IBAN ownership) is **mocked behind a DI seam** so the real swap is
> implementation-only. After this phase a nurse can finally become bookable.
>
> **Track:** backend · **Depends on:** [b3](./backend-phase-3.md) (`nurse_profiles`, `nurse_bank_accounts`, `IBankAccountOwnershipVerifier`), [b1](./backend-phase-1.md) (`support_alerts`, the typed config accessor), [b0](./backend-phase-0.md) (`IObjectStorage`, `IFieldEncryptor`, `ICurrentUser`, audit interceptor) · **Unlocks:** search visibility ([b7](./backend-phase-7.md)); frontend **f5-b6**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b6**, the verification leg of the build. Balinyaar is a *trust-first* home-nursing
marketplace; the product docs are blunt that "verified trust is your **entire brand**." Vetting is
**platform-owned, non-optional, and performed at the authoritative source** — a nurse's service variants
become bookable **only after every required verification step passes**. This phase builds that gate: the
6-step pipeline, the admin review queue, the structured credential registry that powers the public trust
badge and renewal alerts, and the **one transaction** that flips `nurse_profiles.is_verified`. It sits
between catalog ([b5](./backend-phase-5.md), which built the bookable service variants) and search
([b7](./backend-phase-7.md), which surfaces only verified nurses) — and it is the hard prerequisite that
makes the `nurse_search_index.is_searchable` projection meaningful.
**What already exists (do not rebuild) — built by prior phases:**
- **`nurse_profiles`** — [b3](./backend-phase-3.md) built the nurse extension off `users` (1:1 UNIQUE
`user_id`), carrying the **guarded `is_verified` BIT DEFAULT 0** (no public setter — this phase owns the
only write), `is_accepting_bookings`, the denormalized rating aggregates, and soft-delete. **The legacy
`verification_status` column was deliberately CUT** — `nurse_verifications.status` is the sole source of
verification truth. Do **not** reintroduce a second copy.
- **`nurse_bank_accounts`** — [b3](./backend-phase-3.md) built the payout-IBAN table (`iban` enc,
`iban_hash` UNIQUE, `is_primary` filtered-UNIQUE per nurse, `is_verified`, `matched_national_id`,
`account_holder_from_bank`, `ownership_vendor_ref`, `verified_by_admin_id`) and the
**`IBankAccountOwnershipVerifier`** seam (استعلام شبا / Sheba inquiry). This phase's
`bank_account_verification` step **couples to that table and reuses that seam** — it does not re-register
or re-verify bank accounts.
- **`users` / identity** — [b2](./backend-phase-2.md)/[b3](./backend-phase-3.md): `users.national_id` (enc,
**NULL until KYC passes**), `users.gender`, `shahkar_verified_at`/`national_id_verified_at` (reset to NULL
on phone change), and admin RBAC roles for the review queue. The identity-KYC step **populates
`users.national_id`** on pass; Shahkar and IBAN ownership both compare against that verified national-ID.
- **`support_alerts`** — [b1](./backend-phase-1.md) built the alert table + the **raise API** (built early
precisely because verification raises expiry/renewal/shared-SIM alerts). This phase **raises** alerts; it
does not build the table.
- **The typed, cached config accessor + `platform_configs`** — [b1](./backend-phase-1.md). Read review-SLA
hints, expiry-scan cadence, and any verification thresholds through it; never hardcode.
- **`IObjectStorage`** — [b0](./backend-phase-0.md): presigned/streamed put/get/delete keyed by an opaque
storage key, returning a retrievable URL. Verification documents store bytes here. **Reuse it.**
- **`IFieldEncryptor`** — [b0](./backend-phase-0.md): `Encrypt`/`Decrypt`/`Hash`. `credential_number` is
encrypted PII through this seam. **Reuse it.**
- The b0 foundation: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`** (not MediatR), Mapster, FluentValidation + `ValidateCommandBehavior`,
`ICurrentUser` + the audit-field SaveChanges interceptor, rate limiting, `IDateTimeProvider`,
`INotificationDispatcher` (in-app write landed via b1).
**What this phase introduces:** the five verification tables + seed, the nurse/admin capabilities, the
guarded `is_verified` flip transaction, the credential-expiry scanner, the public trust badge — and **three
new seams** (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). The **scheduled cron** that
drives the expiry scan is DEFERRED — the scan is admin/manually triggered now (see §3.3).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) — especially
the *Persistence* (encrypted PII through the field encryptor; one config per entity; projected/paginated
reads) and *Architecture* (seams in Application, mocks in Infrastructure) blocks.
- [`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) — **the
business rules**: the six steps and what each proves, the data-driven pipeline, the structured credential
registry, continuous monitoring (phone-change re-run, expiry re-verify), and the MVP-vs-DEFERRED line
(automated MoH/INO lookup, ML fraud scoring, and the liability-insurance step are all DEFERRED).
- [`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
**the canonical schema** for the five tables. Mirror these field names exactly (especially the
`nurse_credentials` column list and the `UNIQUE` constraints).
- [`product/research/verification.md`](../../../product/research/verification.md) — *why* the design is what
it is: MoH پروانه صلاحیت حرفه‌ای is the single most important credential and **already bundles the
criminal-record screen**; identity is the *easy* layer (buy a KYC vendor, don't build); license check is
**manual** today because no public B2B API exists; the criminal record is **consent-gated to the person**
(nurse-uploaded + re-requested). This is the source of the "manual today, API later" seam shape.
- The **identity digest** for the cross-domain invariants:
`dm_identity_geo_services_verif.md` (`is_verified` is write-guarded; `national_id` NULL until KYC;
`shahkar_verified_at` resets on phone change; `nurse_search_index.is_searchable` depends on `is_verified`).
- **Code to mirror:** [b3](./backend-phase-3.md)'s `nurse_bank_accounts` config + the
`IBankAccountOwnershipVerifier` usage; [b1](./backend-phase-1.md)'s `support_alerts` raise API + the typed
config accessor + the seed-migration pattern (for `verification_step_types`); [b0](./backend-phase-0.md)'s
`IObjectStorage`/`IFieldEncryptor` usage and a `ServiceConfiguration/` seam registration; any existing
`Features/<Area>/{Commands|Queries}/**` for handler structure and `OperationResult` returns.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, routing) — money is not central here but follow the format.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-3.md`,
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the `IObjectStorage` /
`IBankAccountOwnershipVerifier` / `IFieldEncryptor` rows you reuse, and where to add the three new ones).
## 3. Scope — build this
Features live under `Baya.Application/Features/Verification/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Verification/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/VerificationConfig/`; one EF migration for the five tables + one **seed
migration** for `verification_step_types`.
### 3.1 Entities + migration
**`nurse_verifications`** [CORE] — the master per-nurse record; **the SOLE source of verification truth**.
- Fields: `id`, `nurse_id` (FK `nurse_profiles`, **UNIQUE** → 1:1), `status` (enum, see below),
`submitted_at`, `approved_at`, `rejected_at`, `suspended_at` (all nullable), `rejection_reason` (nullable),
`reviewed_by_admin_id` (FK `users` nullable), `internal_notes` (nullable), audit fields, soft-delete.
- Relations: 1:1 → `nurse_profiles`; 1:N → `verification_steps`.
**`verification_step_types`** [CORE] — the admin catalog of pipeline steps (data-driven, not an enum).
- Fields: `id`, `code` (NVARCHAR, **UNIQUE**, stable machine code), `display_name`, `description`,
`is_required` (BIT), `is_automated` (BIT), `automation_provider` (NVARCHAR nullable — e.g. `shahkar`,
`identity_kyc_vendor`), `sort_order` (int), `is_active` (BIT), audit fields.
- **SEED** (via the seed migration) exactly these six stable codes:
`identity_kyc` (automated), `shahkar_match` (automated), `moh_competency_license` (manual today),
`ino_membership` (manual), `criminal_record` (manual, time-limited), `bank_account_verification`
(automated, couples to b3). A new regulatory step (e.g. professional-liability insurance) is one INSERT —
**never** hardcode the six in a C# enum.
- Relations: 1:N → `verification_steps`.
**`verification_steps`** [CORE] — one row per step per nurse.
- Fields: `id`, `nurse_verification_id` (FK), `step_type_id` (FK), `status` (enum, see below),
`external_response_json` (NVARCHAR(MAX) nullable — raw KYC-vendor response for audit),
`expires_at` (DATETIME2 nullable — for time-limited steps), **`is_automated` (BIT — *snapshotted* from the
step type at seed time, read this, never the live step-type)**, `started_at`/`completed_at` (nullable),
`failure_reason` (nullable), audit fields. **`UNIQUE(nurse_verification_id, step_type_id)`**.
- **On expiry → revert to `pending` + `RaiseSupportAlert`** (handled by the scanner, §3.2).
- Relations: N:1 → `nurse_verifications`, `verification_step_types`; 1:N → `verification_documents`.
**`verification_documents`** [CORE] — **metadata only; bytes never in the DB**.
- Fields: `id`, `step_id` (FK `verification_steps`), `object_storage_key` (NVARCHAR — the opaque
`IObjectStorage` key), `integrity_hash` (NVARCHAR(64) — content hash to detect tampering/swap),
`content_type`, `file_size_bytes` (BIGINT), `original_file_name` (nullable), `uploaded_by_user_id`
(FK `users`), audit fields, soft-delete.
- **No PII bytes, ever** — the file lives in object storage behind a **signed URL**; the DB row is metadata.
- Relations: N:1 → `verification_steps`.
**`nurse_credentials`** [MVP] — the structured, queryable credential registry (powers badge + renewal).
- Fields (mirror [`04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
exactly):
`id`, `nurse_id` (FK `nurse_profiles`), `credential_type` (NVARCHAR — `moh_competency_license` /
`ino_membership` / `criminal_record`), `credential_number` (**NVARCHAR(100) encrypted** via
`IFieldEncryptor`), `holder_name_snapshot` (NVARCHAR(200) — name as printed, for ID cross-check),
`issuing_authority` (NVARCHAR(200)), `issued_at` (DATE nullable), `expires_at` (DATE nullable — drives
renewal alerts), `verification_source` (NVARCHAR(300) nullable — portal URL/method),
`verification_method` (NVARCHAR(20) — `manual` / `portal` / `api`), `verified_by_admin_id` (FK `users`
nullable), audit fields, soft-delete.
- Relations: N:1 → `nurse_profiles`; cross-referenced by the relevant `verification_steps`.
**Status enums** (define as proper enums; persist per project convention):
- `VerificationStatus`: `not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`.
- `VerificationStepStatus`: `not_started` | `pending` | `in_review` | `passed` | `failed` | `expired`.
(`pending` = awaiting submission/automation; `in_review` = awaiting manual admin decision.)
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`AdminUpsertStepTypeCommand`** / **`AdminListStepTypesQuery`** / **`AdminDeactivateStepTypeCommand`** | Cmd/Query | `POST`/`GET`/`DELETE api/v1/admin_verification_step_types[/{id}]` | Admin CRUD over the step-type catalog (`code` immutable once used, `is_required`/`is_automated`/`automation_provider`/`sort_order`/`is_active`). Adding a step = one row. Cached (read-heavy reference data) with invalidation on write. |
| **`SubmitNurseVerificationCommand`** | Command | `POST api/v1/nurse_verification/submit` | For the signed-in nurse (tenancy via `ICurrentUser`): upsert a `nurse_verifications` row, set `status='pending'` + `submitted_at`, and **seed one `verification_steps` row per *active required* `verification_step_types`** (status `pending`/`not_started`, **snapshotting `is_automated`** onto each step). Idempotent: re-submitting does not duplicate steps. |
| **`GetNurseVerificationStatusQuery`** | Query | `GET api/v1/nurse_verification` | The nurse's aggregate `status` + per-step list (code, display, status, `expires_at`, failure reason, whether automated) + a "what's blocking bookability" summary. Projected (`AsNoTracking` + `.Select`), tenancy-scoped. Feeds f5's checklist. |
| **`RequestDocumentUploadUrlCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/upload_url` | Returns a **signed PUT URL** from `IObjectStorage` for a manual-evidence step (MoH license PDF, INO card, عدم سوء پیشینه). Validates the step belongs to the nurse and accepts uploads. Returns the `object_storage_key` the client echoes back on confirm. |
| **`ConfirmDocumentUploadCommand`** | Command | `POST api/v1/nurse_verification/steps/{stepId}/documents` | After the client uploads to the signed URL: persist a `verification_documents` **metadata row** (key, `integrity_hash` computed/echoed, content type, size). **Bytes never enter the DB.** Moves the step to `in_review` if it's a manual step. |
| **`RunIdentityKycCommand`** | Command | `POST api/v1/nurse_verification/steps/identity_kyc/run` | Calls **`IIdentityKycProvider`** (national-ID validity + name match + liveness). Persists `external_response_json`; on pass **populates `users.national_id`** + `national_id_verified_at` and sets the step `passed`; on fail sets `failed` + `failure_reason`. Re-aggregates (§ `AggregateAndFinalize`). |
| **`RunShahkarMatchCommand`** | Command | `POST api/v1/nurse_verification/steps/shahkar_match/run` | Calls **`IShahkarVerifier`** to bind the login SIM ↔ the **verified** `users.national_id`. Persists `external_response_json` + sets `shahkar_verified_at` on pass. **Shared-SIM is an explicit handled failure state** → step `failed` + `RaiseSupportAlert(shared_sim)`. Requires identity KYC passed first (national-ID present). |
| **`RunBankAccountVerificationCommand`** | Command | `POST api/v1/nurse_verification/steps/bank_account_verification/run` | Reuses **`IBankAccountOwnershipVerifier`** (b3) on the nurse's primary `nurse_bank_accounts`: holder national-ID must equal the verified nurse national-ID. On match sets the bank account's `matched_national_id=1` + the step `passed`; on mismatch `failed` (money-mule guard). |
| **`AdminListPendingStepsQuery`** | Query | `GET api/v1/admin_verifications?status=in_review&page=&page_size=` | The manual-review worklist (MoH/INO/criminal steps in `in_review`): nurse, step code, submitted docs (with **signed GET URLs** via `IObjectStorage`). Projected + paginated. |
| **`AdminGetVerificationDetailQuery`** | Query | `GET api/v1/admin_verifications/{nurseVerificationId}` | Full per-nurse detail for the doc-viewer: all steps, documents (signed URLs), existing credentials, identity name (for cross-check). Projected. |
| **`AdminReviewStepCommand`** | Command | `POST api/v1/admin_verifications/steps/{stepId}/decide` | Admin **passes or rejects** a manual step with a required reason on reject. **On pass of a credential-bearing step (MoH/INO/criminal): record a `nurse_credentials` row** (encrypted `credential_number`, `holder_name_snapshot` **cross-checked against the identity name**, authority, issued/expires, `verification_method='manual'`, `verified_by_admin_id`). Writes an audit-log entry (b1 audit interceptor + an explicit decision record). Re-aggregates. |
| **`AggregateAndFinalize`** | Command (internal step, called after every outcome write) | — | Re-reads all *required* steps. Sets `nurse_verifications.status` (`pending``in_review` when any step is `in_review`; `rejected` if any required step `failed`; **`approved` only when every required step is `passed`**). **On all-passed: flip `nurse_profiles.is_verified = 1` in the SAME transaction**, set `approved_at`. Idempotent — re-running an already-approved verification is a no-op. |
| **`AdminSuspendVerificationCommand`** | Command | `POST api/v1/admin_verifications/{nurseVerificationId}/suspend` | Sets `status='suspended'` + `suspended_at` and **reverses the flip — `nurse_profiles.is_verified = 0` in the same transaction** (un-publishing the nurse from search via b7's projection hook). Records reason + admin. |
| **`ScanExpiringCredentialsCommand`** | Command | `POST api/v1/admin_verifications/scan_expiring` (admin-triggered; cron DEFERRED) | Scans `nurse_credentials.expires_at` and the matching time-limited `verification_steps.expires_at` (criminal-record especially). For each lapsed credential: **revert the step to `pending`/`expired`, raise a `support_alert` + a renewal-prompt notification**, and if a *required* credential lapsed, re-aggregate (which can drop `is_verified` via suspension semantics). Paginated/batched. |
| **`GetVerifiedTrustBadgeQuery`** | Query | `GET api/v1/nurses/{nurseId}/trust_badge` (public) | The public "verified" badge sourced from approved `nurse_verifications` + non-expired `nurse_credentials` (credential **types** held, e.g. "MoH license · INO member", **never the encrypted numbers**). Projected, cached. Feeds the public nurse profile (f6). |
- **Controllers:** `NurseVerificationController` (nurse policy, tenancy-scoped),
`AdminVerificationController` (admin policy, sensitive — **rate-limited**), and the public
trust-badge action (anonymous, read-only). All `sealed : BaseController`, inject `ISender`, return
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
- **Validators:** FluentValidation on the submit/run/decide/upload commands (national-ID format on identity
run; required rejection reason on a reject decision; required `expires_at` on the criminal-record
credential; step ownership/tenancy on the nurse-side commands).
### 3.3 DEFERRED (build the seam/flag, not the feature)
- **The scheduled expiry-scan cron** (`CredentialExpiryScannerJob`, emulating Nursys e-Notify cadence) —
DEFERRED. The scan logic ships now as `ScanExpiringCredentialsCommand`, admin-triggered; leave a clean
entry point + a `verification_expiry_scan_cadence` config key. (Roadmap: a hosted scheduler.)
- **Automated MoH/INO license lookup** — DEFERRED behind `ICredentialVerifier` (`verification_method='api'`);
the manual review path is the default impl today. Build the seam, not the API client.
- **`fraud_flags` / ML fraud scoring** and the **professional-liability-insurance step** — DEFERRED
(`fraud_flags` modeled-but-inactive; the insurance step is "addable as a row when required"). Do not build.
- **Customer national-ID KYC** — out of scope here (the column stays unused per b3). Do not gate on it.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IShahkarVerifier`** | **introduced here** | `MatchAsync(phone, nationalId, ct)` returns a deterministic result + a fake `vendor_ref` + an `external_response_json` blob: **pass** when phone+national-ID match a seed map; **force shared-SIM failure** for a known test number (the explicit handled state). No real Shahkar call. | **add a new row** (🟡) |
| **`IIdentityKycProvider`** | **introduced here** | `VerifyAsync(nationalId, livenessPayload, ct)` returns deterministic **pass/fail by test national-ID** + a fake vendor ref + `external_response_json`; pass implies a name-match string used for the credential cross-check. No real liveness/OCR. | **add a new row** (🟡) |
| **`ICredentialVerifier`** | **introduced here** | The **manual-admin default impl** *is* the mock: `Verify(credentialType, …)` returns `RequiresManualReview` today (`verification_method='manual'`), shaped so an `api`/`portal` impl drops in later for MoH/INO. No portal call. | **add a new row** (🟡) |
| `IBankAccountOwnershipVerifier` | reuse from **b3** | Sheba inquiry mock; returns holder national-ID = entered one (or mismatch for a test IBAN). Used by `bank_account_verification`. | reuse row |
| `IObjectStorage` | reuse from **b0** | local-disk/in-memory blob store; signed PUT/GET URLs for `verification_documents`. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `credential_number`, never logs plaintext. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor + the step-type/badge caches. | reuse row |
Each new seam is an **Application-layer interface** with an **Infrastructure mock** and **DI registration**
via a `ServiceConfiguration/` extension (real impl is config-selected later — **never** an `if (mock)` branch
in a handler). Persist the raw vendor response in `external_response_json` so the audit trail survives the
swap. Append the three new rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — which Iranian KYC vendor
[Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak], which methods to implement, what to test; and
for `ICredentialVerifier`, the note that MoH/INO have **no public B2B API** so it stays manual until one
appears).
## 5. Critical rules you must not get wrong
- **`nurse_verifications.status` is the SINGLE source of truth.** `nurse_profiles.verification_status` no
longer exists — **do not reintroduce it** or any second copy of verification state. Every read of "is this
nurse verified for *state* purposes" goes through `nurse_verifications.status`; the only derived boolean is
`nurse_profiles.is_verified`, written solely by the flip below.
- **Flip `nurse_profiles.is_verified = 1` ONLY inside the all-passed transaction** that confirms every
*required* step is `passed` (`AggregateAndFinalize`). Never set it from a controller, a partial pass, or an
out-of-band update. **Reverse it (`is_verified = 0`) in the same transaction on suspension** — a suspended
or lapsed nurse must immediately stop being bookable/searchable.
- **A nurse is bookable only after all required steps pass.** Any failing/pending/expired required step keeps
every service variant unbookable. This is the gate b7's `nurse_search_index.is_searchable` reads.
- **The pipeline is data-driven — read required steps from `verification_step_types`, never a code enum.** A
new step is an INSERT. Seed the six codes; don't branch on them in a `switch` that would break when a row
is added.
- **`step_type.is_automated` is SNAPSHOTTED onto `verification_steps` at seed time — read the snapshot, not
the live step type.** Historical records must survive later catalog edits (an admin toggling a step's
automation must not rewrite the meaning of past verifications).
- **Expiring credentials auto-revert the step.** On `expires_at` lapse (criminal-record especially), revert
the step to `pending`/`expired`, **raise a `support_alert`**, send a renewal prompt, and if a *required*
credential lapsed, re-gate bookability. A lapsed certificate must **never** silently keep a nurse verified.
- **`users.national_id` is populated only after the identity step passes**, and every downstream comparison
(Shahkar match, IBAN ownership, credential holder-name cross-check) compares against **that** verified
national-ID. An unverified registration must never look KYC-complete.
- **IBAN ownership gates the first payout, not admin eyeballing.** `bank_account_verification` reuses b3's
`IBankAccountOwnershipVerifier`; the holder national-ID must equal the verified nurse national-ID
(money-mule prevention). Never pass the step on a mismatch.
- **`verification_documents` store metadata only — bytes live in object storage behind signed URLs, with an
integrity hash; files are never public.** No document byte stream is ever written to or returned from the
DB; access is always a short-lived signed URL.
- **`credential_number` is encrypted PII** (through `IFieldEncryptor`) and **`holder_name_snapshot` must be
cross-checked against the nurse's identity name** before a credential is recorded as verified. Never trust
an uploaded file alone — forgery (the "imposter nurse") is the documented attack.
- **Shared-SIM is an explicit handled state**, not an undefined edge: fail Shahkar gracefully with a clear,
non-accusatory reason and raise a `support_alert`. Re-run Shahkar on phone change (`shahkar_verified_at`
resets to NULL upstream).
- **Every manual admin decision is auditable** (the b1 audit interceptor + an explicit decision record) for
defensibility — verification is platform-owned and must never be marketed as a check not actually performed.
- **All vendor steps are mocked now — but behind real DI seams.** No mock behaviour baked into call sites;
the swap is implementation-only and config-selected.
- **Tenancy:** every nurse-side command/query is scoped to the authenticated nurse via `ICurrentUser`; a
nurse can never read or act on another nurse's verification. Admin endpoints sit behind the admin policy and
are rate-limited. The public trust badge exposes **credential types held, never numbers**.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The five tables (`nurse_verifications`, `verification_step_types`, `verification_steps`,
`verification_documents`, `nurse_credentials`) exist via one migration, each with its
`IEntityTypeConfiguration<T>`, the `nurse_id` UNIQUE (1:1) on `nurse_verifications`, the
`UNIQUE(nurse_verification_id, step_type_id)` on `verification_steps`, the **snapshot `is_automated`**
column, encrypted `credential_number`, and soft-delete/audit wiring.
- [ ] The seed migration loads the **six** `verification_step_types` with the exact stable codes and the
correct `is_required`/`is_automated` flags.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `NurseVerificationController`, `AdminVerificationController`, and the public badge.
- [ ] **`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`** introduced (Application
interfaces, Infrastructure mocks, DI via a `ServiceConfiguration/` extension, config-selected); the b3
`IBankAccountOwnershipVerifier` and b0 `IObjectStorage`/`IFieldEncryptor` seams reused (not redefined).
No `if (mock)` in handlers.
- [ ] The **flip transaction** is correct: `is_verified=1` only on all-required-passed, in one transaction;
`is_verified=0` on suspension, in one transaction; the expiry scan reverts steps + raises alerts.
- [ ] Handler unit tests (NSubstitute) for: step seeding from required step-types, the snapshot of
`is_automated`, the automated-step pass/fail (Shahkar + identity KYC mocks), the manual-review →
credential-record path with holder-name cross-check, the all-passed flip, the suspension reverse, and
the expiry revert + alert. ≥1 `WebApplicationFactory` integration test per controller (happy path, 401,
validation 400). `dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Verification/**` area is added to the **Project map** in
`server/CLAUDE.md`; the three new seams noted where seams are documented.
- [ ] The contract `dev/contracts/domains/verification.md` is written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Prereqs: a nurse user with a `nurse_profiles` row (b3) and (for the bank step) a primary
`nurse_bank_accounts` row; an admin user. The seam mocks are deterministic — use the seed/test national-IDs.
1. **Seed step-types**`GET api/v1/admin_verification_step_types` → the **six** seeded codes appear with
correct `is_required`/`is_automated` flags. Add a 7th via `POST` → it persists (proves data-driven).
2. **Submit** — as the nurse, `POST api/v1/nurse_verification/submit` → a `nurse_verifications` row in
`pending` and **one `verification_steps` row per required step**, each with `is_automated` snapshotted.
`GET api/v1/nurse_verification` shows the checklist + "what's blocking bookability".
3. **Upload a document**`POST …/steps/{stepId}/upload_url` returns a signed URL; PUT a file to it (mock
storage); `POST …/steps/{stepId}/documents` → a `verification_documents` **metadata row** with the
`object_storage_key` + `integrity_hash` stored; **no bytes in the DB**; the manual step moves to `in_review`.
4. **Run automated Shahkar / identity KYC**`POST …/steps/identity_kyc/run` (pass test national-ID) →
step `passed`, `users.national_id` populated, `external_response_json` stored; then
`POST …/steps/shahkar_match/run``passed` (and `shahkar_verified_at` set). Run Shahkar with the
**shared-SIM** test number → step `failed` + a `support_alert` raised.
5. **Run bank-account verification**`POST …/steps/bank_account_verification/run` (matching test IBAN) →
`passed` + the account's `matched_national_id=1`; with the **mismatch** test IBAN → `failed`.
6. **Admin approves manual steps**`GET api/v1/admin_verifications?status=in_review` shows the queue;
`POST api/v1/admin_verifications/steps/{stepId}/decide` (pass) on MoH + INO + criminal-record →
each records a `nurse_credentials` row (encrypted number, holder-name cross-check, expiry on the
criminal record). Reject one with a reason → step `failed` and the nurse sees the reason.
7. **All steps passed flips `is_verified` in one transaction** — once every required step is `passed`,
`nurse_verifications.status='approved'` **and** `nurse_profiles.is_verified=1` — confirm both changed
together (verify there is no in-between state where the verification is approved but `is_verified` is still 0).
8. **Suspend reverses it**`POST api/v1/admin_verifications/{id}/suspend``status='suspended'` **and**
`is_verified=0` together.
9. **Expire a credential** — set a `nurse_credentials.expires_at` (criminal record) in the past, run
`POST api/v1/admin_verifications/scan_expiring` → the matching step reverts to `pending`/`expired`, a
`support_alert` is raised, and (since it's required) the nurse is re-gated.
10. **Public trust badge**`GET api/v1/nurses/{nurseId}/trust_badge` → shows the verified status +
credential **types** held; **no credential numbers** are ever returned.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Verification/**` area + the three new seams). If you discover/confirm a rule the product docs
don't capture (e.g. the exact `in_review` vs `pending` step semantics, the renewal-prompt notification, or
the trust-badge "types not numbers" rule), record it in
[`product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) /
[`product/data-model/04-verification-and-credentials.md`](../../../product/data-model/04-verification-and-credentials.md)
— don't invent rules. Note the new seam family in `server/CONVENTIONS.md` if it establishes a reusable pattern.
- **Contract to write:** **`dev/contracts/domains/verification.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the nurse endpoints
(submit, status, upload-url, confirm-document, run identity/Shahkar/bank steps), the admin endpoints
(step-type CRUD, pending-steps queue, detail, decide, suspend, scan-expiring), and the public trust-badge
endpoint; the `VerificationStatus` / `VerificationStepStatus` enums and the six step-type `code`s; the
step / step-type / document (signed-URL) / credential DTO shapes (**`credential_number` never serialized**;
badge shows **types not numbers**); auth/tenancy/rate-limit notes; the side effects (`is_verified` flip,
`support_alerts` raised, `users.national_id` populated). Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f5-b6** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-6.md` (the
verification pipeline is live, what f5 can now build — the nurse verification checklist, identity submit,
document upload, credentials form, under-review state, trust badge; which endpoints/contracts are live;
that all vendors are mocked behind `IShahkarVerifier`/`IIdentityKycProvider`/`ICredentialVerifier` and the
reused b3 bank seam). Append to `backend/STATUS.md`, write
`dev/shared-working-context/reports/backend-phase-6-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contracts produced, follow-ups: the expiry-scan
cron, automated MoH/INO lookup, `fraud_flags`), and update
`dev/shared-working-context/reports/mocks-registry.md` (the three new seam rows → 🟡; the reused rows noted).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — `status` is the
single source of truth (no `verification_status` copy), the guarded `is_verified` flip/reverse transaction,
the `is_automated` snapshot rule, the data-driven step catalog, the expiry-revert + alert flow, documents-as-
metadata-only, and the three new vendor seams — with a one-line pointer in `MEMORY.md`.
+357
View File
@@ -0,0 +1,357 @@
# Backend Phase 7 — Search & matching (nurse search index)
> **Mission:** make verified nurses discoverable. Build the **denormalized `nurse_search_index`** — one
> flat row per bookable variant **per covered area** (fan-out) — and the write-side **maintenance hooks**
> that keep it consistent on every change to a nurse's profile, variants, service areas, verification, or
> reviews. Then build the single family-facing **search query** (filter by category, city/district with
> NULL-district = whole-city, gender, price; sort by rating; paginate) behind a **search-service seam**
> so an Elasticsearch backend can drop in later without touching callers. A row is searchable **only**
> when the nurse is verified, not suspended, accepting bookings, and the variant is active — an
> unverified or paused nurse must **never** surface. This is the discovery layer the whole booking funnel
> stands on.
>
> **Track:** backend · **Depends on:** [b5](./backend-phase-5.md) (catalog & variants), [b6](./backend-phase-6.md) (verification → `is_verified`), [b4](./backend-phase-4.md) (geography & service areas), [b3](./backend-phase-3.md) (nurse profiles, gender, rating aggregates) · **Unlocks:** booking discovery (b8); frontend **f6-b7**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b7**, the discovery layer. Catalog (b5) gave nurses priced **variants** — the
atomic bookable unit; geography (b4) gave them **service areas** (which cities/districts they travel to);
verification (b6) gave them the guarded **`is_verified`** flag; profiles (b3) gave them **gender** and the
denormalized **rating aggregates**. All those facts live in normalized tables across four domains. A naive
"find me a verified female elder-care nurse in Tehran district 3 under X rials, best-rated first" query
joins `nurse_profiles → nurse_service_variants → nurse_service_areas` plus a rating sort across 4+ tables —
slow at modest scale. This phase **flattens all of it into one maintained-on-write read model** so search
is a single indexed, paginated table scan, and **introduces the `INurseSearch` seam** so MVP runs on SQL
today and Elasticsearch can replace the backend later by config alone. No Elasticsearch at MVP — the index
table *is* the search backend (and stays the projection/fallback even after Elastic lands).
**What already exists (do not rebuild) — built by prior phases:**
- **Catalog & bookable variants** — [b5](./backend-phase-5.md) built `service_categories`,
`service_option_groups`/`service_option_values`, and the nurse-side **`nurse_service_variants`**
(`nurse_id`, `service_category_id`, `price`, `price_unit``per_hour`/`per_session`/`per_half_day`/
`per_day`/`per_24h`, `session_count`, `is_active`, `display_name`) + `nurse_service_variant_options`.
**The variant is the bookable unit — search projects *variants*, not nurses.** b5 already stubbed a
write-side hook on variant create/edit/activate/deactivate (the catalog digest's `ISearchIndexWriter`
note); **this phase owns the real implementation** behind that hook — do not re-create the variant CRUD.
- **Geography & service areas** — [b4](./backend-phase-4.md) built `provinces`/`cities`/`districts` (each
with `sort_order`, `is_active`) and **`nurse_service_areas`** (`nurse_id`, `city_id`, `district_id` NULL,
`UNIQUE(nurse_id, city_id, district_id)`; **`district_id = NULL` means the whole city**). The geo lookup
queries (ListProvinces/ListCities/ListDistricts) and the nurse Add/Remove-ServiceArea commands already
exist — **this phase hooks the index fan-out onto the service-area writes**, it does not rebuild the geo
domain or the area editor.
- **Nurse profiles, gender & rating aggregates** — [b3](./backend-phase-3.md) built `nurse_profiles`
(`user_id` UNIQUE, the guarded `is_verified` BIT, `is_accepting_bookings` BIT, the denormalized
`average_rating`/`total_reviews`/`total_completed_bookings`) and `users.gender` (`male`/`female`). The
**aggregate-recompute on review/booking transitions** is wired in b3/b14 — this phase **reads** those
fields into the index, it does not own the recompute math.
- **Verification & the `is_verified` flip** — [b6](./backend-phase-6.md) built `nurse_verifications`
(the sole source of verification truth; `status``not_started`/`pending`/`in_review`/`approved`/
`rejected`/`suspended`) and the **guarded flip** that sets `nurse_profiles.is_verified=1` only inside
the confirm transaction (and reverses it on suspension). **This phase hooks index maintenance onto that
flip** so flipping `is_verified` flips the rows' `is_searchable` — it does not touch the verification
pipeline.
- The b0 foundation + b1 plumbing: REST surface, `BaseController`, `OperationResult<T>`, CQRS via
**`martinothamar/Mediator`**, `IDateTimeProvider`, the typed cached `platform_configs` accessor, and the
**`ICacheService`** seam (optional result/geo-lookup caching). Reuse all of these.
**What this phase introduces:** the `nurse_search_index` table + its EF config + migration, the
**index-maintenance handlers** (upsert/fan-out/remove + `is_searchable` recomputation + a full backfill/
rebuild job), the **`SearchNurses` query** behind the new **`INurseSearch` seam** (SQL impl
`SqlNurseSearch` now), and the public `GET /search/nurses` endpoint. The same-gender filter is first-class
here. Booking-side capture of `booking_requests.required_caregiver_gender` is owned by **b8 (DEFERRED**
here — see §3).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially *Persistence* (AsNoTracking + `.Select` projection, pagination on every list, one
`IEntityTypeConfiguration<T>` per entity) and *Performance/caching* (cache read-heavy data behind the
cache seam).
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) —
**the business rules**: search by category + city/district + price + rating; geography driven by
nurse-declared service areas (city-level row = whole city); **the denormalized index exists only when the
nurse is verified + not suspended + the variant active**; **same-gender matching as a first-class,
near-hard filter** surfaced *before* booking; MVP (this) vs DEFERRED (map discovery, hard availability
filter, algorithmic ranking).
- [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md) —
**the canonical `nurse_search_index` schema** (the field table: `variant_id`, `nurse_id`,
`service_category_id`, copied `price`/`price_unit`, `city_id`/`district_id` one-row-per-area, `nurse_gender`,
copied `average_rating`/`total_reviews`/`total_completed_bookings`, `is_searchable`, `updated_at`) and the
**"maintained on writes to `nurse_profiles`, `nurse_service_variants`, `nurse_service_areas`, `reviews`"**
+ **"`is_searchable=1` only when the source nurse/variant are bookable"** invariants. Mirror these names
exactly.
- **Code to mirror:** b5's `Features/Catalog/**` (the variant create/edit/activate/deactivate commands and
the write-side hook stub you'll implement); b4's `nurse_service_areas` config + the Add/Remove-ServiceArea
commands; b3's `nurse_profiles` config (gender + rating aggregate columns) and the aggregate-recompute
path; b6's `is_verified` flip transaction. Mirror their `Features/<Area>/{Commands|Queries}/<Name>/`
layout, `IEntityTypeConfiguration<T>`, and the `IUnitOfWork`/`CommitAsync` pattern.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope,
pagination shape). `price` in the index and in search responses is **IRR `long`/`BIGINT`** — no floats.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md`, `…-6.md`,
`…-4.md`, `…-3.md`, and `reports/mocks-registry.md` (the `INurseSearch`/`ICacheService` seam rows).
## 3. Scope — build this
Money (`price`) is IRR `long` / `BIGINT`. The search read model + its maintenance live under
`Baya.Application/Features/Search/{Commands|Queries}/<Name>/`; the entity in
`Baya.Domain/Entities/Search/`; one `IEntityTypeConfiguration<T>` in
`Persistence/Configuration/SearchConfig/`; the `INurseSearch` seam in `Application/Contracts/`, its SQL
implementation in Infrastructure; one EF migration for the table.
### 3.1 Entity + migration
**`nurse_search_index`** [CORE] — denormalized read model; **one row per (variant × covered area)** (fan-out).
- Fields (mirror [`product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)):
- `id` BIGINT PK.
- `variant_id` BIGINT FK → `nurse_service_variants`.
- `nurse_id` BIGINT FK → `nurse_profiles`.
- `service_category_id` BIGINT FK → `service_categories` (copied from the variant — the primary search
dimension).
- `price` BIGINT, `price_unit` NVARCHAR (copied from the variant; closed `price_unit` set).
- `city_id` BIGINT, `district_id` BIGINT **NULL** — the covered area this row represents.
**`district_id = NULL` is a meaningful value: "whole city".** One row per area the nurse covers
(fan-out); a nurse covering 3 areas with 2 active variants yields up to 6 rows.
- `nurse_gender` NVARCHAR(10) — copied from `users.gender` via the nurse, for the same-gender filter.
- `average_rating`, `total_reviews`, `total_completed_bookings` — copied from `nurse_profiles`.
- `is_searchable` BIT — **true only when** nurse `is_verified=1` AND `nurse_verifications.status` not
`suspended` AND `is_accepting_bookings=1` AND variant `is_active=1` (see §5). The single visibility gate.
- `updated_at` DATETIME2 — stamped from `IDateTimeProvider` on every upsert.
- **Indexes (this is the whole point — get them right):**
- A **covering composite index** for the hot search path:
`(is_searchable, service_category_id, city_id, district_id) INCLUDE (price, nurse_gender, average_rating, nurse_id, variant_id)`
— so the filtered, rating-sorted page is served from the index without key lookups.
- A **filtered unique index** `UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`
(district_id NULL participating) so a given variant×area appears **exactly once** — this is the upsert
target and the anti-duplication guard.
- A secondary index on `nurse_id` (so a nurse-scoped rebuild/remove is cheap).
- **No business writes here.** This table is a **read-only projection** — only the maintenance handlers in
§3.2 write it, and only by re-deriving from source tables. Soft-delete + audit/`updated_at` wiring per
conventions; the table is fully **re-derivable** from source by the backfill job (§3.2).
### 3.2 Index-maintenance handlers (the write side — owned by the source's code path)
The index is maintained **inline, inside the same transaction** as the source write that changes a
projected fact. The projection is written **only by the code path that owns the source row** — a variant
write reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans
out/removes that nurse's rows. These are small internal commands the existing b3/b4/b5/b6 handlers call (or
that the SaveChanges pipeline dispatches) — **not** public endpoints.
| Command | Trigger (source write) | What it does |
| --- | --- | --- |
| **`ReindexVariantCommand(variantId)`** | `nurse_service_variants` create / edit (price, category, options) / activate / deactivate (b5) | Recomputes the index rows for **this variant across all the nurse's service areas**: upserts one row per area (copying price/unit/category, the nurse's gender + rating + searchability), and sets `is_searchable` per the §5 predicate. On **deactivate** the variant's rows go `is_searchable=0` (kept, not deleted — soft-delete the rows on hard variant deletion only, which b5 forbids). |
| **`ReindexNurseCommand(nurseId)`** | `nurse_profiles` change: the **`is_verified` flip** (b6), **suspend/un-suspend**, **`is_accepting_bookings` toggle** (b3), and the **rating-aggregate recompute** on review/booking transitions (b3/b14) | Re-derives **every row for the nurse** = (each active variant) × (each service area), recomputing `is_searchable`, and refreshing the copied `nurse_gender`/`average_rating`/`total_reviews`/`total_completed_bookings`. Flipping `is_verified` from 1→0 (or suspend) sets **all** the nurse's rows `is_searchable=0` in one go; flipping 0→1 (with accepting + active variants) makes them searchable. |
| **`FanOutServiceAreaCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **add** (b4) | Inserts one index row **per active variant** for the newly-covered area (respecting the `UNIQUE(variant_id, city_id, district_id)` upsert guard), with `is_searchable` per §5. |
| **`RemoveServiceAreaRowsCommand(nurseId, cityId, districtId)`** | `nurse_service_areas` **remove** (b4) | Deletes (soft-deletes) the index rows for that nurse×area across all variants. Removing an area must drop exactly those rows — **don't collapse areas or you break geo filtering** (§5). |
| **`RebuildSearchIndexCommand`** [job] | manual admin trigger / first-launch / nightly reconciliation | **Idempotent full rebuild**: truncates+repopulates (or upserts+prunes) the entire index from `nurse_profiles` × `nurse_service_variants` × `nurse_service_areas`, applying §5. This is the convergence/reconciliation path — the index must be **re-derivable from source** at any time. Batched/paginated so it scales. Admin-only endpoint `POST api/v1/admin_search/rebuild_index` (rate-limited, admin policy). |
- **Transactionality:** the reindex step runs **inside the same `IUnitOfWork` transaction** as its source
write (single `CommitAsync`) so the projection can never diverge from the source on a successful commit; a
source write that rolls back rolls back its index change too. (The seam is shaped so a later Elastic
feeder can instead consume these as outbox events — see §4 — but the **SQL path applies them inline
today**.)
- **No `ISearchIndexWriter` controller surface.** These commands are internal; they are invoked from the
owning domain's handlers, never exposed as their own REST routes (except the admin rebuild job).
### 3.3 The search query + seam (the read side)
**`INurseSearch`** (Application contract) — the search-service seam. SQL implementation `SqlNurseSearch`
(Infrastructure) reads **only** `nurse_search_index WHERE is_searchable = 1`. **All callers depend on the
interface**, never on raw SQL or an Elastic client, so the MVP→Elastic swap is config-only.
**`SearchNursesQuery`** [CORE] — the single family-facing discovery query, delegating to `INurseSearch`.
- Route: **`GET api/v1/search/nurses`** (public — discovery is pre-auth; **rate-limited** as an unauthenticated
public endpoint).
- Filters (all optional except category + city per the product doc's "city required, district optional"):
- `service_category_id` (required) — the primary dimension.
- `city_id` (required), `district_id` (optional) — **geography matching:** a city search matches **both**
the city-only rows (`district_id IS NULL`, "whole city") **and** any row for a district in that city; a
district search matches that district's rows **plus** the whole-city rows (NULL district covers it). Get
this exactly right (§5).
- `nurse_gender` (optional, `male`/`female`) — the **first-class same-gender filter**.
- `min_price` / `max_price` (optional, IRR `BIGINT`) — price range over the copied `price`.
- (Optional, surfaced for UI) `price_unit` filter so "per_hour" and "per_day" listings can be compared
like-for-like; not required.
- Sort: by `average_rating` **descending** (stable tiebreak on `total_reviews` desc then `nurse_id` so
paging is deterministic). Rating sort is the only MVP sort.
- **Always paginated** (`page`/`page_size`, default/max per conventions) — `AsNoTracking()` + `.Select(...)`
projection to a `NurseSearchResultDto` (nurse_id, variant_id, category, price + unit, nurse_gender,
average_rating, total_reviews, total_completed_bookings, city/district) — never hydrate entities to map
them.
- **Caching:** optionally cache hot (category, city, gender) result pages behind **`ICacheService`** (reuse
the b0/b1 seam) with a short TTL, invalidated on index writes for the affected city/category — or ship
no-cache at MVP and add the decorator later. Geo-lookup dropdowns (provinces/cities/districts) are already
cacheable via b4; don't duplicate them here.
- **Controller:** `SearchController` (`sealed : BaseController`, inject `ISender`, snake_case
`[controller]`/`[action]` routes, `base.OperationResult(...)`, `CancellationToken` threaded). Plus
`AdminSearchController` for the rebuild job (admin policy).
- **Validators:** FluentValidation on `SearchNursesQuery` (category + city required; `min_price ≤ max_price`;
`nurse_gender ∈ {male,female}` when present; `page_size` ≤ max).
### 3.4 DEFERRED (do not build; leave the seam/pointer)
- **Booking-side same-gender capture** — `booking_requests.required_caregiver_gender` (`male`/`female`/`any`)
and the "surface the chosen gender *into* the booking flow before booking" guarantee are owned by
**[b8](./backend-phase-8.md)**. This phase makes `nurse_gender` a **first-class search facet** (so families
can narrow up front) and stops there. (DEFERRED → b8.)
- **Elasticsearch backend** (`ElasticNurseSearch` behind `INurseSearch`) and the **feeder/outbox daemon**
that streams source changes into Elastic. DEFERRED — the SQL index is the MVP backend and remains the
projection/fallback. Build the **seam**, not the Elastic impl. (See §4.)
- **Availability as a hard filter** — `nurse_availability_slots`/`nurse_availability_exceptions` are **soft
guidance only**; never block a search result on availability ([`product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md) §(c)). DEFERRED.
- **Map-based discovery / geocoding** (`IGeocoder` for radius search), **algorithmic ranking beyond rating**,
**"preferred nurse" continuity-of-carer** suggestions. DEFERRED.
## 4. Mocks & seams in this phase
| Seam | Owner | Behaviour | Registry |
| --- | --- | --- | --- |
| **`INurseSearch`** | **introduced here** | The search-service seam. **MVP impl `SqlNurseSearch` is the real backend, not a mock** — keep it production-grade: it reads `nurse_search_index WHERE is_searchable=1`, applies the category/city/district/gender/price filters, rating sort, and pagination. The DEFERRED `ElasticNurseSearch` is a config-selected drop-in later; callers depend only on `INurseSearch`. | **add a new row** (🟢 SQL is real; Elastic 🟡 deferred) |
| **`ISearchIndexWriter`** (events) | **introduced here** (shape only) | The index-maintenance seam. The SQL path applies the reindex/fan-out/remove **inline** today (§3.2). Shape it so the same change events can later be routed to an **outbox/queue** for the Elastic feeder instead of an inline upsert — record the swap path, but **do not** build the queue now. | **add a new row** (🟡 outbox deferred) |
| `ICacheService` | reuse from **b0/b1** | in-memory; optional decorator over hot search result pages + the typed config accessor. | reuse row |
| `IDateTimeProvider` | reuse from **b0** | stamps `updated_at` on every index upsert (deterministic in tests). | reuse row |
The Elastic implementation is a **DI-registered drop-in** behind `INurseSearch` (selection by config,
**never** an `if (mock)` branch in a handler). Append the `INurseSearch` + `ISearchIndexWriter` rows to
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's real/faked, config keys, **step-by-step how to make it real** — the Elastic client
package, the index mapping, the feeder daemon that consumes the writer's events via outbox/CDC, the
config switch that points `INurseSearch` at `ElasticNurseSearch`, and the fact that the SQL index stays
the fallback/reconciliation source).
## 5. Critical rules you must not get wrong
- **The visibility invariant is the safety rule.** A row is `is_searchable = 1` **only** when the nurse is
`is_verified = 1` AND **not suspended** (`nurse_verifications.status != 'suspended'`) AND
`is_accepting_bookings = 1` AND the variant `is_active = 1`. **An unverified, suspended, paused, or
deactivated nurse/variant must never appear in results.** Get this wrong and you leak unvetted or
unbookable nurses to families — the single highest-stakes bug in this phase. Recompute `is_searchable` on
**every** relevant source write; never trust a stale value.
- **The bookable unit is the variant, not the nurse.** Search, results, and (later) booking operate on
`nurse_service_variants`. The index is keyed per-variant-per-area; never collapse a nurse's variants into
one row.
- **The index is a read-only projection — never let search mutate source.** All writes re-derive from
`nurse_profiles` / `nurse_service_variants` / `nurse_service_areas` / `reviews`. The `RebuildSearchIndexCommand`
must reconstruct the entire index from source with the same result as the incremental hooks — incremental
maintenance and full rebuild must **converge**. If they can diverge, the maintenance logic is wrong.
- **Maintain inside the source's transaction.** The reindex runs in the **same unit of work** as the write
that changed the projected fact (one `CommitAsync`), so a committed source change always carries its index
change and a rolled-back one carries neither. The projection is written **only by the code path owning the
source row** — don't reindex a variant from the profile handler, or vice-versa.
- **`district_id = NULL` means the whole city — a real coverage value, not missing data.** A **city search
matches both city-only rows (NULL district) and every district row in that city**; a **district search
matches that district's rows *and* the whole-city (NULL) rows.** NULL participates correctly in the
`UNIQUE(variant_id, city_id, district_id)` upsert guard. Reimplementing geography as GPS radii is wrong —
think named districts.
- **Fan-out cardinality is exact.** One index row per (variant × covered area). Adding a service area
**inserts** one row per active variant; removing it **deletes** exactly those rows; deactivating a variant
flips its rows to `is_searchable=0`. Don't collapse, dedupe-away, or orphan rows.
- **Same-gender matching is near-hard and first-class.** `nurse_gender` is an **exposed, up-front search
filter** — for bodily-care, a gender mismatch is culturally unacceptable. Make same-gender easy to select
and the facet prominent; never silently default or drop it. (Carrying the chosen gender *into* the booking
request is b8.)
- **Money is IRR `BIGINT`, no floats.** The copied `price` and the `min_price`/`max_price` filters are
`long`/`BIGINT`; price-unit display + `session_count` totals are not recomputed here. No float path,
anywhere.
- **Rating-sort consistency.** The copied `average_rating`/`total_reviews`/`total_completed_bookings` must
track the source recompute (every review status transition, booking completion/dispute reversal, nightly
reconciliation). A hidden 1★ review must lower the rating **in the index**, not leave it inflated — which
is why the rating-recompute path (b3/b14) calls `ReindexNurseCommand`.
- **Seam discipline.** Controllers/handlers depend on **`INurseSearch`**, never on raw SQL or an Elastic
client directly, so the MVP→Elastic swap is config-only. The SQL impl is real and production-grade, not a
throwaway mock.
- **Availability is soft.** Availability slots/exceptions never hard-filter search results at MVP; the nurse
still accepts/rejects each request.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `nurse_search_index` exists via one migration with its `IEntityTypeConfiguration<T>`, the covering
search index, the **`UNIQUE(variant_id, city_id, district_id)` filtered** upsert guard (NULL district
participating), the `nurse_id` secondary index, and soft-delete/audit/`updated_at` wiring.
- [ ] The §3.2 maintenance commands (`ReindexVariantCommand`, `ReindexNurseCommand`,
`FanOutServiceAreaCommand`, `RemoveServiceAreaRowsCommand`, `RebuildSearchIndexCommand`) are
implemented and **wired into the b3/b4/b5/b6 source handlers** so every relevant write maintains the
index **in the same transaction**. `is_searchable` is recomputed correctly per §5 on each.
- [ ] `SearchNursesQuery` + `GET api/v1/search/nurses` implemented behind **`INurseSearch`** (`SqlNurseSearch`
impl), reading **only** `is_searchable=1` rows, with correct NULL-district geography, the same-gender
filter, price range, rating sort, projected + paginated reads, and the FluentValidation validator.
- [ ] **`INurseSearch`** (+ the `ISearchIndexWriter` event shape) introduced as Application interfaces with
Infrastructure impls, **DI-registered via a `ServiceConfiguration/` extension** (config-selected; no
`if (mock)` in handlers).
- [ ] Handler/unit tests (NSubstitute): the `is_searchable` predicate (verified+accepting+not-suspended+
active true; each missing condition false), the geography NULL-district matching, the gender/price
filters, the rating sort, the fan-out on area add/remove, and the **`is_verified` flip → index
`is_searchable` update**; ≥1 `WebApplicationFactory` integration test for `/search/nurses` (happy path,
validation 400). The convergence test: incremental maintenance == full rebuild for a seeded fixture.
`dotnet build Baya.sln` zero new warnings; `dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Search/**` area + the `INurseSearch` seam are reflected in the **Project
map** in `server/CLAUDE.md`.
- [ ] The contract `dev/contracts/domains/search.md` is written and the `swagger.json` snapshot republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from prior phases) a small fixture: a province/city with ≥2 districts; **Nurse A** = verified
(`is_verified=1`, `nurse_verifications.status=approved`), `is_accepting_bookings=1`, female, with one
**active** elder-care variant priced in IRR and a service area = (city, district 3); **Nurse B** = **not
verified** (or paused) with an otherwise identical variant + area; **Nurse C** = verified, accepting, male,
active variant, service area = **whole city** (`district_id=NULL`). Give the nurses different
`average_rating`s.
1. **Verified+accepting+active appears**`GET api/v1/search/nurses?service_category_id=…&city_id=…`
**Nurse A** and **Nurse C** appear; **Nurse B does not** (unverified/paused never surfaces).
2. **Whole-city vs district geography** — search with `district_id=` district 3 → Nurse A (district 3) **and**
Nurse C (whole-city NULL row) both match; search the same city with **no district** → both still match;
search a *different* district in that city → only Nurse C (the whole-city row) matches.
3. **Same-gender filter** — add `nurse_gender=female` → only Nurse A; `nurse_gender=male` → only Nurse C.
4. **Price range** — set `min_price`/`max_price` straddling the variants → only the in-range variant(s) appear
(IRR `BIGINT`, exact).
5. **Rating sort** — give Nurse A a higher `average_rating` than Nurse C → results come back A-before-C;
paging is deterministic.
6. **Flip verification updates searchability** — flip **Nurse A** to suspended/`is_verified=0` (via the b6
path) → re-run the search → **Nurse A disappears** (its rows' `is_searchable` went 0 in the same
transaction). Flip back → it reappears.
7. **Service-area fan-out** — add a second service area to Nurse C → new index rows appear and Nurse C now
matches that area too; remove the area → those rows (and only those) drop out.
8. **Variant deactivate** — deactivate Nurse A's variant → it stops appearing (rows `is_searchable=0`),
without deleting the index rows.
9. **Rebuild convergence**`POST api/v1/admin_search/rebuild_index` → the index is identical to its
incrementally-maintained state (counts and `is_searchable` flags match); no duplicate (variant×area) rows.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Search/**` area, the `nurse_search_index` projection, and the `INurseSearch` seam + where it's
registered). If you discover/confirm a rule the product docs don't capture (e.g. the exact city↔district
NULL-matching semantics in the query, or the incremental-vs-rebuild convergence guarantee), record it in
[`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
or [`../../../product/data-model/03-services-and-pricing.md`](../../../product/data-model/03-services-and-pricing.md)
— don't invent rules.
- **Contract to write:** **`dev/contracts/domains/search.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the public
`GET api/v1/search/nurses` endpoint (filters: `service_category_id`, `city_id`, optional `district_id`,
optional `nurse_gender`, optional `min_price`/`max_price`, pagination; rating sort; the
**NULL-district = whole-city** matching rule documented explicitly), the `NurseSearchResultDto` shape
(IRR `BIGINT` `price` + `price_unit` enum, `nurse_gender`, rating fields), the admin
`POST api/v1/admin_search/rebuild_index`, auth/rate-limit notes, and that **only `is_searchable=1` rows are
ever returned**. Republish the `swagger.json` snapshot per
[`../../contracts/openapi/README.md`](../../contracts/openapi/README.md). This is what **f6-b7** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-7.md` (search
is live, what **f6** can now build — search + filters (C1), results (C2), nurse profile (C3) — which
endpoint/contract is live, that the backend is the SQL index with `INurseSearch` allowing a later Elastic
swap, and that booking-side `required_caregiver_gender` capture lands in b8), append to `backend/STATUS.md`,
write `dev/shared-working-context/reports/backend-phase-7-report.md` (what was built, **what is now
testable and exactly how** per §7, what is deferred + how to make it real — Elastic + feeder, contracts
produced, follow-ups: the booking-side gender capture, the optional result cache, the Elastic backend),
and update `dev/shared-working-context/reports/mocks-registry.md` (the `INurseSearch` row → 🟢 SQL real,
Elastic 🟡; the `ISearchIndexWriter` outbox row → 🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
`is_searchable` four-condition predicate, the **NULL-district = whole-city** matching (both directions),
the (variant × area) fan-out cardinality + the `UNIQUE(variant_id, city_id, district_id)` upsert guard,
the "maintain-in-the-source-transaction, projection owned by the source's code path" rule, the
incremental↔rebuild convergence requirement, and the `INurseSearch` (SQL-now/Elastic-later) seam — with a
one-line pointer in `MEMORY.md`.
+480
View File
@@ -0,0 +1,480 @@
# Backend Phase 8 — Booking requests & lifecycle (pre-payment intent)
> **Mission:** build the **pre-payment intent** layer of the engagement lifecycle — the money-free
> `booking_requests` table and its full state machine (request → accept → pay-window → expire/reject).
> A customer requests a specific nurse, patient, service variant, address, date, and **required caregiver
> gender**; the nurse sees only the limited unencrypted `customer_notes` (never the full clinical
> instructions) and accepts or rejects before a config-driven **response deadline**; on accept a
> config-driven **30-minute payment window** opens. **No `bookings` row and no money exist yet** — that
> conversion happens in b9/b10. This phase owns the two-stage clinical-disclosure boundary's *first*
> stage, the tenancy invariant, the same-gender filter, and the deadlines that the whole booking flow
> hinges on.
>
> **Track:** backend · **Depends on:** [backend-phase-3](backend-phase-3.md) (`customer_profiles`, `nurse_profiles`, `patients`, tenancy via `ICurrentUser`), [backend-phase-4](backend-phase-4.md) (`customer_addresses`), [backend-phase-5](backend-phase-5.md) (`nurse_service_variants` + the `IVariantSnapshotSerializer`), [backend-phase-1](backend-phase-1.md) (typed cached config accessor `IPlatformConfig`, the `IJobScheduler`/`BackgroundService` pattern, `notifications`), [backend-phase-7](backend-phase-7.md) (search & matching — the gender/match data customers discover nurses through) · **Unlocks:** bookings · sessions · care · EVV ([backend-phase-9](backend-phase-9.md)), and the booking-request UI ([frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **booking-request** phase — the first half of the request→accept→pay→confirm engagement
model. The product deliberately splits the lifecycle into **two tables** so each keeps clean invariants:
a **money-free request phase** (`booking_requests`, this phase) and a **payment-backed booking phase**
(`bookings`, [backend-phase-9](backend-phase-9.md)). A `booking_requests` row can be rejected, time out,
or have its payment window lapse **without a booking ever existing** — merging the two would mean a swamp
of nullable money fields and tangled status logic. This phase ends in a complete, testable request
state machine; b9 picks it up at "payment captured" and converts an `accepted_awaiting_payment` request
into a confirmed `bookings` row.
Everything this phase needs already exists. A customer (with a `customer_profiles` row) has `patients`
and `customer_addresses`; a nurse (with a `nurse_profiles` row and a `gender`) has priced, bookable
`nurse_service_variants`; config exposes the deadline durations; search lets the customer find the nurse
in the first place. This phase wires those into a request a nurse can accept or reject.
**What already exists (do not rebuild) — confirmed from the prior phases:**
- **Customers, nurses, patients & tenancy** — [backend-phase-3](backend-phase-3.md) built
`customer_profiles`, `nurse_profiles` (carrying `is_verified`, `is_accepting_bookings`, and the
`average_rating`/`total_reviews` aggregates), and `patients` (the care recipient, separate from the
payer, carrying a load-bearing `gender`). **Tenancy is resolved off `ICurrentUser.UserId`
`customer_profiles`/`nurse_profiles`** — a patient belongs to exactly one `customer_id`; b3 started the
ownership scoping and **b8 enforces the full booking-request tenancy invariant** (§5). Read those
entities; do not re-model them.
- **Addresses** — [backend-phase-4](backend-phase-4.md) built `customer_addresses` (encrypted address
line + `decimal(9,6)` coordinates, `customer_id` FK → `customer_profiles`, filtered
`UNIQUE(customer_id) WHERE is_primary=1`). The request points at one via `customer_address_id`. Do not
geocode here — b4 already did; b9/EVV consumes the coordinates.
- **Service variants** — [backend-phase-5](backend-phase-5.md) built `nurse_service_variants` (the
**atomic bookable unit**: a nurse + category + chosen option-set at a `price` (IRR `BIGINT`) +
`price_unit`), and shipped **`IVariantSnapshotSerializer`** (a pure serializer for the canonical
`variant_snapshot_json`). The request FKs a `variant_id`; **a variant belongs to exactly one nurse**,
which the tenancy invariant verifies. **Do not compute any price/total here** — no money lives on a
request; the serializer/snapshot/total are b9's concern.
- **Config, the job runner & notifications** — [backend-phase-1](backend-phase-1.md) built
`platform_configs` + the typed cached accessor **`IPlatformConfig`** (`GetConfig<T>(key)`), seeded the
deadline keys **`nurse_response_deadline_hours`** and **`booking_payment_deadline_minutes`** (= **30**),
introduced the in-process **`IJobScheduler`/`BackgroundService`** interval-runner pattern (used for
`PurgeOldReadNotifications`), and shipped the real in-app **`INotificationDispatcher`** that writes a
`notifications` row. **Reuse all three** — read deadlines through `IPlatformConfig` (never hardcode),
schedule the expiry sweep through the same job pattern, and emit request/accept/expiry notifications
through `INotificationDispatcher`.
- **Search & matching** — [backend-phase-7](backend-phase-7.md) built the `nurse_search_index` and the
`INurseSearch` query (filterable by category/city/district/**gender**/price). The customer arrives at
this phase *from* a search result, having already filtered on `required_caregiver_gender` against nurse
gender; this phase **re-validates** that same-gender match server-side at request time (search is a
discovery aid, not the authority).
- **Cross-cutting plumbing** — [backend-phase-0](backend-phase-0.md): the REST surface (`BaseController`,
snake_case routing, rate limiting), the CQRS pipeline (`ISender`/`ICommand`/`IQuery`,
`ValidateCommandBehavior`, `OperationResult<T>`), `IDateTimeProvider` (use it — **never `DateTime.Now`**
— deadlines and expiry are time-sensitive and must be testable), `ICurrentUser`, the audit-field
interceptor, and `ICacheService`. **Reuse these; introduce no new seam.**
> **`bookings`, `booking_sessions`, `booking_care_instructions`, the three-amount money split, the
> conversion command, and `dispute_window_ends_at`** are owned by **[backend-phase-9](backend-phase-9.md)**
> (with payment capture from [backend-phase-10](backend-phase-10.md)). This phase **ends at
> `accepted_awaiting_payment`**; it must **not** create a booking, post a ledger entry, compute a price,
> or persist `variant_snapshot_json`/`address_snapshot_json`. **(DEFERRED → b9/b10.)** The request's
> `converted` terminal status is *set by b9* when it consumes the accepted request — this phase models the
> status value and the 1:1 link but does not perform the conversion.
>
> **`cancellation_policies`, `CancelBooking`, refunds, and `recurring_booking_schedules`** are also b9+
> / DEFERRED — do not build them here. A customer cancelling a *request* (before any booking) is in scope
> as the `cancelled_by_customer` transition (§3.3); a *booking* cancellation is not.
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md).
- **Product — business rules (source of truth):**
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
— the **request → accept → pay → confirm lifecycle**, the two-table phase split (*no money on a
request*), the **response deadline** computed-and-frozen-from-config, the **30-minute payment window**,
the request statuses, and (b) the Iran-specific note that the platform **deliberately keeps the nurse's
per-request accept/reject autonomy** (availability is soft guidance, never a hard auto-accept). Read
(c) MVP vs DEFERRED so you don't pull booking/session/cancellation scope forward.
- **Product — data model (source of truth):**
[`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
— the **canonical `booking_requests` schema**: the FK set + the **tenancy invariant** (patient & address
belong to `customer_id`; variant belongs to `nurse_id`), `required_caregiver_gender`
(`male`/`female`/`any`), `requested_date`/`requested_time_start`/`requested_time_end`,
**unencrypted request-stage `customer_notes`**, the exact `status` set, `nurse_response_deadline_at`
(frozen from config), `payment_deadline_at` (set on accept), and `nurse_rejection_reason`. Note the
**1:1 → `bookings` on conversion** relation (which b9 owns).
- **Type, money & gender rules on the wire:**
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**gender is load-bearing** (`required_caregiver_gender` = `male`/`female`/`any`, matched against nurse
gender; never default or drop it silently); timestamps are **UTC ISO-8601**; enums cross the wire as
stable string codes; ids are `BIGINT`. (There is **no money** on a request — but read this so the
request's wire shape is consistent with the rest of the contract.)
- **Contract conventions:**
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, status codes — `400` validation/business, `403`/`404` tenancy, `409`
state-machine conflict — mandatory list pagination, locale handling).
- **Code to mirror (existing patterns):** a b3/b4/b5 feature folder under
`Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/` (request `record` + `internal sealed`
handler + `OperationResult`, validator picked up by `ValidateCommandBehavior`); an
`IEntityTypeConfiguration<T>` under `Persistence/Configuration/<Area>Config/`; a `sealed` controller
under `Baya.Web.Api/Controllers/V1/`; b1's **`IPlatformConfig.GetConfig<T>`** usage and its
**`IJobScheduler`/`BackgroundService`** retention job (mirror it for the expiry sweep); how b3 resolves
tenancy off `ICurrentUser`; how reads use `AsNoTracking()` + `.Select()` projection + pagination.
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-5.md` (the variant
shape + `IVariantSnapshotSerializer`), `.../after-backend-phase-4.md` (the `customer_addresses` shape),
`.../after-backend-phase-3.md` (profiles/patients + the tenancy + role policies), and
`.../after-backend-phase-1.md` (the config accessor keys + the job-runner pattern + notifications).
## 3. Scope — build this
A vertical slice per capability: entity + EF config + **one additive migration** → command/query
handler(s) → controller endpoint → contract. Everything is `async` with `CancellationToken` threaded
through; reads are `AsNoTracking()` + `.Select()` projection + pagination; writes go through
`IUnitOfWork` with a single `CommitAsync`. **There is no money and no `bookings` row anywhere in this
phase.**
### 3.1 Entity, config & migration
Add **`booking_requests`** as **one additive EF Core migration** on top of the existing baseline, with one
`IEntityTypeConfiguration<BookingRequest>` in `Persistence/Configuration/BookingConfig/`. The entity lives
in the Booking domain area (`Baya.Domain/Entities/Booking/`); ids are `BIGINT`.
- **`booking_requests`** — the pre-payment intent. **No money columns, ever.**
- **FKs:** `id` (BIGINT PK), `customer_id` (BIGINT FK → `customer_profiles`), `nurse_id` (BIGINT FK →
`nurse_profiles`), `patient_id` (BIGINT FK → `patients`), `variant_id` (BIGINT FK →
`nurse_service_variants`), `customer_address_id` (BIGINT FK → `customer_addresses`).
- `required_caregiver_gender` (NVARCHAR(10), **nullable**) — closed code set `male` | `female` | `any`.
Same-gender care is decisive for bodily care; this is a **first-class filter**, matched against the
nurse's `gender` at request time (§5), not a soft preference.
- `requested_date` (DATE), `requested_time_start` (TIME), `requested_time_end` (TIME) — for multi-day
engagements these are the **engagement start**; per-visit scheduling is b9's `booking_sessions`.
- `customer_notes` (NVARCHAR(1000), **nullable, UNENCRYPTED**) — the **only** clinical context the nurse
sees pre-accept (Principle 6 / two-stage disclosure, stage 1). **Do not** route this through
`IFieldEncryptor`; it is deliberately limited and plaintext. The full encrypted care instructions are
b9's `booking_care_instructions`.
- `status` (NVARCHAR(50)) — closed code set:
`pending_nurse_response``accepted_awaiting_payment``converted` / `rejected_by_nurse` /
`expired_no_response` / `payment_deadline_expired` / `cancelled_by_customer`. Guarded by a forward-only
transition check (§3.4).
- `nurse_response_deadline_at` (DATETIME2(7), **not null**) — **computed once from config at creation
and frozen** (`now + nurse_response_deadline_hours`); immune to later config changes.
- `payment_deadline_at` (DATETIME2(7), **nullable**) — null until accept; set to
`now + booking_payment_deadline_minutes` (= 30) **on accept**, then frozen.
- `nurse_rejection_reason` (NVARCHAR(500), nullable) — set on reject.
- Audit fields (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`, stamped by the b0 interceptor) +
soft-delete (`deleted_at`) with the `!IsDeleted` global query filter.
- **Indexes:** `(nurse_id, status)` for the **nurse inbox** ordered list; `(customer_id, status)` for
the **customer inbox**; `(status, nurse_response_deadline_at)` and
`(status, payment_deadline_at)` so the **expiry sweep** (§3.4) selects stale rows by a covering index
instead of scanning. Do **not** add a `variant_snapshot_json` / `address_snapshot_json` column here —
those land on `bookings` in b9.
> **Do not add `bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`, or
> `cancellation_policies` in this migration.** They are b9's. This migration adds exactly one table.
### 3.2 Customer-side — create & cancel a request
Feature folder `Baya.Application/Features/Booking/` (Commands/Queries sub-folders per the surrounding
convention). **Customer-owner-only** for these writes; tenancy resolved via `ICurrentUser.UserId`
`customer_profiles`.
- **`CreateBookingRequestCommand`** (`Commands/CreateBookingRequest/`) — the customer requests a nurse.
Input: `nurse_id`, `variant_id`, `patient_id`, `customer_address_id`, `requested_date`,
`requested_time_start`, `requested_time_end`, `required_caregiver_gender`, optional `customer_notes`.
The handler, in one transaction:
1. **Resolve the caller's `customer_id`** from `ICurrentUser``customer_profiles` (never trust a
`customer_id` from the body).
2. **Tenancy invariant (§5):** load the `patients` row and assert `patient.customer_id == customer_id`;
load the `customer_addresses` row and assert `address.customer_id == customer_id`; load the
`nurse_service_variants` row and assert `variant.nurse_id == nurse_id`. Any mismatch ⇒ a clean
`NotFoundResult`/`FailureResult` (never a raw 500, never leaking another customer's data).
3. **Bookability checks:** the variant is active (`is_active`), the nurse `is_verified` and
`is_accepting_bookings`. A request against an inactive variant or an unbookable nurse fails cleanly.
4. **Same-gender match (§5):** if `required_caregiver_gender` is `male`/`female`, assert the nurse's
`gender` equals it; `any` matches either. A mismatch is a validation failure naming the conflict.
5. **Compute & freeze `nurse_response_deadline_at`** = `IDateTimeProvider.UtcNow +
IPlatformConfig.GetConfig<int>("nurse_response_deadline_hours")`. **Read from config — never
hardcode.** Store the absolute timestamp on the row so a later config change cannot move it.
6. Insert the `booking_requests` row with `status = pending_nurse_response`, `payment_deadline_at =
null`; `CommitAsync` once.
7. **Notify the nurse** of the new request via `INotificationDispatcher` (a `booking_request_received`
notification type, `data_json` carrying the request id + patient display name + requested date — no
PII beyond what stage-1 disclosure allows).
- FluentValidation: all FKs present/positive; `requested_time_end > requested_time_start`;
`requested_date` not in the past; `required_caregiver_gender` in the closed set when supplied;
`customer_notes` ≤ 1000 chars.
- **`CancelBookingRequestCommand`** (`Commands/CancelBookingRequest/`) — the customer withdraws a request
that is still `pending_nurse_response` **or** `accepted_awaiting_payment` (before they pay).
Owner-tenancy; transition to `cancelled_by_customer` through the guard (§3.4). A request already
`converted`/`rejected_by_nurse`/`expired_*` cannot be cancelled ⇒ `409` conflict. (This is a *request*
cancellation only — a *booking* cancellation with refund tiers is b9+/DEFERRED.)
### 3.3 Nurse-side — accept & reject
**Nurse-owner-only**; tenancy resolved via `ICurrentUser.UserId``nurse_profiles`; the request's
`nurse_id` must equal the caller's nurse id (else `403`/`NotFound`).
- **`AcceptBookingRequestCommand`** (`Commands/AcceptBookingRequest/`) — the assigned nurse accepts a
`pending_nurse_response` request. The handler:
1. Load the request scoped to the caller's `nurse_id`; assert `status == pending_nurse_response` (else
`409`). Assert `nurse_response_deadline_at` has **not** already passed (`IDateTimeProvider.UtcNow`);
an accept after the deadline is rejected with a clear message (the sweep may not have run yet — the
command must self-guard, not rely on the job).
2. **Set & freeze `payment_deadline_at`** = `IDateTimeProvider.UtcNow +
IPlatformConfig.GetConfig<int>("booking_payment_deadline_minutes")` (= 30). **From config, never
hardcode 30.**
3. Transition `status → accepted_awaiting_payment` through the guard; `CommitAsync`.
4. **Notify the customer** (`booking_request_accepted`, `data_json` with the request id + the
`payment_deadline_at` so the client can show the 30-minute countdown).
- **Two-stage disclosure (§5):** the accept handler — and the nurse inbox/detail queries — expose
**only `customer_notes`**, never any encrypted care instructions (those don't exist until b9 and are
gated to post-confirmation). There is nothing encrypted to leak here *because there is nothing
encrypted on a request* — but the contract and queries must make the stage-1-only boundary explicit so
b9 inherits it correctly.
- **`RejectBookingRequestCommand`** (`Commands/RejectBookingRequest/`) — the assigned nurse declines a
`pending_nurse_response` request with a required `nurse_rejection_reason`. Transition →
`rejected_by_nurse`; notify the customer (`booking_request_rejected`). Reject is only valid from
`pending_nurse_response` (else `409`). FluentValidation: reason non-empty, ≤ 500 chars.
### 3.4 Status guard & the expiry sweep (this phase owns the scheduled job)
- **Forward-only transition guard.** Add an allowed-transition table/helper for `booking_requests.status`
and route **every** write through it; an illegal transition returns a `409` `OperationResult` conflict,
never a silent overwrite. Allowed edges:
- `pending_nurse_response``accepted_awaiting_payment` | `rejected_by_nurse` | `expired_no_response`
| `cancelled_by_customer`
- `accepted_awaiting_payment``converted` (b9 only) | `payment_deadline_expired` |
`cancelled_by_customer`
- all of `converted` / `rejected_by_nurse` / `expired_no_response` / `payment_deadline_expired` /
`cancelled_by_customer` are **terminal** (no outgoing edges).
- **`ExpireBookingRequestsCommand`** (`Commands/ExpireBookingRequests/`) + a hosted **`BackgroundService`**
registered through the b1 `IJobScheduler` pattern, running on a short interval (e.g. every minute —
config-driven if b1 exposes an interval key, else a sensible constant documented in the report). On each
tick, in a single bounded, paginated pass (do **not** load the whole table):
- select `pending_nurse_response` rows where `nurse_response_deadline_at <= UtcNow` → transition →
`expired_no_response`; notify the customer (`booking_request_expired_no_response`).
- select `accepted_awaiting_payment` rows where `payment_deadline_at <= UtcNow` → transition →
`payment_deadline_expired`; notify the customer (`booking_request_payment_window_expired`).
- The sweep uses the covering indexes from §3.1, batches its updates, threads `CancellationToken`, and is
**idempotent/re-entrant** (a row already moved by a concurrent accept/reject is simply skipped — the
`status` predicate in the `WHERE` is the guard; never assume a row is still pending just because the
previous tick saw it). Expiry is computed against `IDateTimeProvider.UtcNow` so tests can drive it
deterministically.
- The command is also exposed as an **admin/manual trigger** endpoint (§3.6) so a human/test can fire the
sweep on demand without waiting for the interval.
### 3.5 Queries — customer inbox, nurse inbox, single request
- **`ListBookingRequestsQuery`** (`Queries/ListBookingRequests/`) — paginated, **role-aware**: a customer
sees their own requests (scoped to `customer_id`); a nurse sees requests addressed to them (scoped to
`nurse_id`). Optional `status` filter. `AsNoTracking()` + `.Select()` projection returning: request id,
status, the **counterparty** (nurse display name + rating for the customer view; patient display name +
requested date for the nurse view), `requested_date`/times, `required_caregiver_gender`,
`nurse_response_deadline_at`, `payment_deadline_at`, and — for the **nurse view**
`customer_notes` (stage-1 only). Order: actionable first (e.g. `pending_nurse_response` /
`accepted_awaiting_payment` before terminal states), then by deadline. **Never** project encrypted/care
fields (there are none, and there must never be any added to this query).
- **`GetBookingRequestQuery`** (`Queries/GetBookingRequest/`) — a single request, visible to its
**customer or its nurse** (admin too). Returns the full request projection: the resolved variant label
(via the b5 variant projection — read the canonical offering, do not duplicate price math), patient +
address summary (the **customer** sees their own full address; the **nurse** sees only what stage-1
disclosure allows — a coarse location/the request fields, **not** the encrypted full address line),
both deadlines, status, and `customer_notes`. A caller who is neither the request's customer nor its
nurse ⇒ `403`/`NotFound`.
### 3.6 REST endpoints
Controllers under `Baya.Web.Api/Controllers/V1/` (`sealed`, `BaseController`, inject `ISender`,
`[controller]`/`[action]` snake_case tokens, `base.OperationResult(...)`, narrowest authorize policy,
lists paginated). Routes shown logically; the snake_case transformer produces the real segments.
| Verb & route | Maps to | Auth |
| --- | --- | --- |
| `POST /v1/booking_requests` | `CreateBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/cancel` | `CancelBookingRequestCommand` | customer (owner) |
| `POST /v1/booking_requests/{id}/accept` | `AcceptBookingRequestCommand` | nurse (assigned) |
| `POST /v1/booking_requests/{id}/reject` | `RejectBookingRequestCommand` | nurse (assigned) |
| `GET /v1/booking_requests` | `ListBookingRequestsQuery` | customer or nurse (role-scoped) |
| `GET /v1/booking_requests/{id}` | `GetBookingRequestQuery` | customer / nurse (party) / admin |
| `POST /v1/admin/booking_requests/expire` | `ExpireBookingRequestsCommand` (manual trigger) | admin |
### 3.7 Out of scope (DEFERRED — do not build)
- **`bookings`, the three-amount money split (`gross_price_irr`/`balinyaar_commission_irr`/
`nurse_payout_amount`), `psp_fee_amount`, the conversion command, `booking_sessions`,
`booking_care_instructions`, `visit_verifications`, `dispute_window_ends_at`,
`variant_snapshot_json`/`address_snapshot_json`** — **(DEFERRED → [backend-phase-9](backend-phase-9.md)
+ [backend-phase-10](backend-phase-10.md)).** This phase stops at `accepted_awaiting_payment`; b9
consumes the accepted request, captures payment (b10), and sets the request to `converted`.
- **`cancellation_policies`, `CancelBooking`/`CancelSession`, refund tiers, `recurring_booking_schedules`,
`nurse_availability_slots`/`_exceptions` hard-blocking** — **(DEFERRED)**; availability stays *soft
guidance* (search-only). The nurse always individually accepts/rejects — never auto-accept from
availability.
- **SMS/push notification channels**`INotificationDispatcher` writes **in-app only** (the channel
abstraction exists from b0/b1; SMS/push are DEFERRED). Emit the in-app notification; do not add a real
SMS path.
## 4. Mocks & seams in this phase
**None introduced.** This phase owns no third-party integration — booking-request data is fully Balinyaar's.
It **reuses** existing seams; it must **not** redefine them:
| Reused seam | From | Used for |
| --- | --- | --- |
| `IPlatformConfig` | [b1](backend-phase-1.md) | reading `nurse_response_deadline_hours` & `booking_payment_deadline_minutes` (cached) |
| `INotificationDispatcher` | [b1](backend-phase-1.md) (real in-app) | request-received / accepted / rejected / expired in-app notifications |
| `IJobScheduler` / `BackgroundService` | [b1](backend-phase-1.md) | the recurring expiry sweep |
| `IDateTimeProvider` | [b0](backend-phase-0.md) | deadline computation + expiry, testably |
| `ICurrentUser` | [b0](backend-phase-0.md) | tenancy resolution (customer/nurse) |
Because this phase **mocks nothing**, there is **no `mocks-registry.md` row to add** — state that
explicitly in your report (§8) so the next agent doesn't go looking. The expiry job is an *internal*
hosted service, not an external seam; it does not go in the mock registry.
## 5. Critical rules you must not get wrong
- **NO money exists on a `booking_requests` row — and no `bookings` row exists yet.** The request phase is
deliberately money-free; a `bookings` row exists **only** when the nurse accepted **AND** payment was
captured (b9/b10). Never add a money column to `booking_requests`, never compute a price/total here,
never create a booking on accept alone. Accept only opens the payment window.
- **Two-stage clinical disclosure (Principle 6), stage 1.** Pre-accept (and pre-payment), the nurse sees
**only the unencrypted, limited `customer_notes`** — never any full clinical/care instructions. Those
are b9's encrypted `booking_care_instructions`, readable **only post-confirmation** by the assigned
nurse + admin. Do not add an encrypted clinical field to this table; do not surface anything beyond
`customer_notes` in the nurse inbox/detail queries. This boundary is the *entire reason* the request and
booking phases are split — preserve it exactly so b9 inherits a clean seam.
- **Tenancy invariant — enforced before the request is created.** The `patient` **and** the
`customer_address` must belong to the caller's `customer_id`; the `variant` must belong to the
requested `nurse_id`. Resolve `customer_id`/`nurse_id` from `ICurrentUser`, never from the request body.
A cross-customer patient/address or a variant that isn't the nurse's is a clean failure, never a
success and never a leak of the other party's data.
- **`required_caregiver_gender` is a first-class same-gender filter, not a soft preference.** When it is
`male`/`female`, it **must** be matched against the nurse's `gender` at request time and rejected on
mismatch; `any` matches either. Same-gender bodily care is decisive in the Iranian context. Never
default it silently or treat it as advisory.
- **Deadlines come from config and are frozen on the row — never hardcoded, never recomputed.** Compute
`nurse_response_deadline_at` from `nurse_response_deadline_hours` at **create** time and
`payment_deadline_at` from `booking_payment_deadline_minutes` (= 30) at **accept** time, both via
`IPlatformConfig` + `IDateTimeProvider`, and **store the absolute timestamp**. A later config change must
**not** move an existing request's deadlines. Do not read "30 minutes" as a literal anywhere.
- **The nurse always individually accepts or rejects.** Availability slots are soft guidance only (b5-area,
DEFERRED) — never auto-accept a request from availability. This deliberate per-request autonomy also
underpins the worker-misclassification posture; do not erode it.
- **Status transitions go through the forward-only guard.** Every accept/reject/cancel/expire/convert
edge is validated; illegal transitions return `409`, never a silent overwrite. Terminal states have no
outgoing edges. The expiry sweep's `WHERE status = …` predicate is the concurrency guard — a row moved
by a racing accept is simply skipped.
- **Time is injected, expiry is idempotent.** Use `IDateTimeProvider.UtcNow` everywhere (no `DateTime.Now`);
accept/reject **self-guard** against an already-passed deadline rather than trusting that the sweep ran;
the sweep is bounded, paginated, re-entrant, and safe to run concurrently with user actions.
- **The request → booking link is 1:1 and owned by b9.** Model the `converted` status and let b9 create
the `bookings` row that points back at the request; this phase never writes that link itself.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `booking_requests` exists via **one additive migration** with the §3.1 columns, the closed `status`
set, the **unencrypted** `customer_notes`, `nurse_response_deadline_at` (not null) + nullable
`payment_deadline_at`, the FK set, the inbox/expiry indexes, soft-delete filter, and **no money
column**.
- [ ] `CreateBookingRequestCommand` enforces the **tenancy invariant** (patient + address ∈ `customer_id`,
variant ∈ `nurse_id`), the **same-gender** match, bookability (active variant, verified/accepting
nurse), and **freezes `nurse_response_deadline_at` from `nurse_response_deadline_hours`**; it
notifies the nurse.
- [ ] `AcceptBookingRequestCommand` sets `payment_deadline_at = now + booking_payment_deadline_minutes`
(**30, from config**), transitions to `accepted_awaiting_payment`, notifies the customer, and
self-guards against an expired response deadline. `RejectBookingRequestCommand` requires a reason and
transitions to `rejected_by_nurse`. `CancelBookingRequestCommand` handles `cancelled_by_customer`.
- [ ] The forward-only **status guard** rejects illegal transitions with `409`; `ExpireBookingRequestsCommand`
+ its `BackgroundService` transition `pending_nurse_response → expired_no_response` and
`accepted_awaiting_payment → payment_deadline_expired`, paginated, idempotent, time-injected, and is
also reachable via the admin manual-trigger endpoint.
- [ ] `ListBookingRequestsQuery` (role-scoped customer/nurse inbox, paginated, projected) and
`GetBookingRequestQuery` (party/admin only) are implemented; the **nurse inbox exposes only
`customer_notes`** and never any care/clinical field.
- [ ] Tests prove the §7 scenarios (incl. **cross-customer patient rejected**, **same-gender mismatch
rejected**, accept sets the 30-min window, the **expiry job transitions stale rows**); `dotnet build
Baya.sln` zero new warnings; `dotnet test Baya.sln` green including this phase's tests.
- [ ] The contract `dev/contracts/domains/booking-requests.md` is written, the `swagger.json` snapshot is
refreshed, and the `server/CLAUDE.md` *Project map* notes the new `Features/Booking` area, the
`BookingConfig` configuration folder, and the request-expiry `BackgroundService`.
## 7. How to test (what a human can verify after this phase)
Run the API (`dotnet run --project src/API/Baya.Web.Api/...`) against a reachable SQL Server; use Swagger
or curl, signing in as the relevant role. These expected results become the "what can be tested" section
of your report.
1. **Create a request (happy path).** As a customer, `POST /v1/booking_requests` with your own
`patient_id` + `customer_address_id`, a nurse's `variant_id` (that nurse `is_verified` &
`is_accepting_bookings`), a future `requested_date`, `required_caregiver_gender: any`, and a short
`customer_notes``200`, status `pending_nurse_response`, `nurse_response_deadline_at` set to
`now + nurse_response_deadline_hours`, `payment_deadline_at` null. The nurse receives an in-app
notification.
2. **Cross-customer patient is rejected.** Repeat the create but with a `patient_id` belonging to a
**different** customer → a clean tenancy failure (`403`/`404` `OperationResult`), **not** a 500 and
**no** request row created. Same for an `customer_address_id` you don't own and a `variant_id` that
isn't the requested nurse's.
3. **Same-gender mismatch is rejected.** Request `required_caregiver_gender: female` against a `male`
nurse → a validation failure naming the gender conflict; `any` against any nurse succeeds.
4. **Nurse accepts → 30-minute window.** As the assigned nurse, `POST /v1/booking_requests/{id}/accept`
`200`, status `accepted_awaiting_payment`, `payment_deadline_at = now + booking_payment_deadline_minutes`
(30 min). The customer gets a `booking_request_accepted` notification carrying the deadline. No
`bookings` row exists (there is no bookings table yet).
5. **Nurse rejects.** `POST /v1/booking_requests/{id}/reject` with a reason on a fresh pending request →
`200`, status `rejected_by_nurse`, `nurse_rejection_reason` stored, customer notified. Rejecting a
non-pending request → `409`.
6. **Nurse inbox shows only `customer_notes`.** As the nurse, `GET /v1/booking_requests?status=pending_nurse_response`
→ the request appears with `customer_notes` and the per-request countdown to `nurse_response_deadline_at`,
and **no** encrypted/clinical field of any kind.
7. **Customer cancels before paying.** On an `accepted_awaiting_payment` request, the customer
`POST /v1/booking_requests/{id}/cancel``200`, status `cancelled_by_customer`. Cancelling a
`converted`/`rejected`/`expired` request → `409`.
8. **The expiry job transitions stale requests.** Create a request, then (using the injected clock in a
test, or by waiting / a short test config) advance past the response deadline and
`POST /v1/admin/booking_requests/expire` (admin) → the pending request becomes `expired_no_response`;
an accepted request past `payment_deadline_at` becomes `payment_deadline_expired`. Both notify the
customer. Running the trigger again is a no-op (idempotent).
9. **Tenancy on read.** As a third party (neither the request's customer nor nurse),
`GET /v1/booking_requests/{id}``403`/`NotFound`, never the request's data.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):** `server/CLAUDE.md` *Project map* — add the `Features/Booking`
(booking-requests) feature area, the new `booking_requests` table + the
`Persistence/Configuration/BookingConfig/` folder, and the request-expiry `BackgroundService` (note it
reuses the b1 `IJobScheduler` pattern). If you established the forward-only status-guard helper as a
reusable pattern (b9 will reuse it for the `bookings` machine), note it in `server/CONVENTIONS.md`. If
you discovered/decided any business rule not already in the product docs, reflect it in
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
or [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
(no invented rules — record decisions, and regenerate the HTML view per `product/CLAUDE.md` if you
touched Markdown).
- **Contract to write:** publish **`dev/contracts/domains/booking-requests.md`** — the §3.6 routes;
request/response shapes for create/accept/reject/cancel and the two queries; the `status` enum
(`pending_nurse_response`/`accepted_awaiting_payment`/`converted`/`rejected_by_nurse`/
`expired_no_response`/`payment_deadline_expired`/`cancelled_by_customer`) and `required_caregiver_gender`
enum (`male`/`female`/`any`); the deadline timestamps (UTC ISO-8601); the **tenancy** and
**stage-1-disclosure** notes (nurse view exposes only `customer_notes`); the failure cases and status
codes (`400` validation/same-gender, `403`/`404` tenancy, `409` illegal transition / already-expired) —
per [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (gender section), starting
from the [domain contract template](../../contracts/domains/_TEMPLATE.md). Refresh the `swagger.json`
snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md) so
[frontend-phase-7-b8](../frontend/frontend-phase-7-b8.md) can derive its types (it does not guess
shapes).
- **Handoff & report:** write `shared-working-context/backend/handoff/after-backend-phase-8.md` — the
request lifecycle is live end-to-end (create/accept/reject/cancel/expire); the deadlines are config-
driven and frozen; **what b9 must consume** (an `accepted_awaiting_payment` request → create the
`bookings` row, set the request to `converted` through the guard, and that the **full encrypted care
instructions are b9's stage-2** — this phase intentionally exposes only `customer_notes`); **what
f7-b8 can now build** (the request form C4, the awaiting-acceptance/status tracker C5 with the response
+ payment countdowns, and the nurse incoming-requests inbox with accept/reject). Append your phase
summary to `shared-working-context/backend/STATUS.md`, and write `reports/backend-phase-8-report.md`
(what was built, **what is now testable and exactly how** — the §7 steps, that **nothing is mocked**
here, the contract produced, and follow-ups for b9: conversion, sessions, two-stage stage-2 care
instructions). State explicitly in the report that this phase adds **no** `mocks-registry.md` row.
- **Memory:** save a `project`-type memory note for the non-obvious decisions here — the **two-table phase
split / no-money-on-a-request** rule, the **stage-1 (`customer_notes`-only) vs stage-2 (encrypted
care-instructions) disclosure boundary**, the **deadlines-frozen-from-config** rule (which config keys),
the **tenancy invariant** (patient+address ∈ customer, variant ∈ nurse), the **same-gender match at
request time**, and the **forward-only status guard + idempotent expiry sweep** — with a one-line
`MEMORY.md` pointer.
+452
View File
@@ -0,0 +1,452 @@
# Backend Phase 9 — Bookings, sessions, care instructions & EVV
> **Mission:** turn an accepted, paid request into a real **engagement**. On payment capture, convert a
> `booking_requests` row into a `bookings` row with the **three-amount money split**
> (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`), freeze the service/address/policy
> as **snapshots**, and fan out **N `booking_sessions`** (always ≥ 1, even for a single visit) so every
> visit has its own schedule, its own **EVV** check-in/out, and its own payout accrual. Build the
> **two-stage clinical disclosure** boundary (`booking_care_instructions`, encrypted, readable only
> post-confirmation by the assigned nurse + admin), the **EVV** records (`visit_verifications` — GPS +
> timestamps, an *advisory* address match that flags review but never blocks), and the **dispute-window**
> gate that — and only that — makes a session payout-eligible. This phase is the spine the payments
> capture (b10), refunds (b11), payouts (b13), and reviews (b14) all hang off.
>
> **Track:** backend · **Depends on:** [b8](./backend-phase-8.md) (`booking_requests` lifecycle), [b1](./backend-phase-1.md) (`platform_configs`, `support_alerts`, `INotificationDispatcher`), [b0](./backend-phase-0.md) (`IFieldEncryptor`, `ICurrentUser`, audit interceptor, REST/`OperationResult`), [b4](./backend-phase-4.md) (`IGeocoder`, `customer_addresses` coordinates) · **Unlocks:** payments capture **b10**, reviews **b14**, payouts **b13**; frontend **f8-b9**
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is **backend phase b9**, the hinge between the **request** arc (b8) and the **money** arc (b10b13).
Balinyaar splits the engagement lifecycle into two tables on purpose: a money-free **request phase**
(`booking_requests`, built in b8) and a payment-backed **booking phase** (`bookings`, built **here**). A
`bookings` row exists **only** when the nurse accepted **and** payment was captured — never on accept
alone. This phase builds the booking, its sessions, the encrypted care instructions, the EVV proof of
service, and the dispute-window gate; the actual card capture that *triggers* the conversion lands in
**b10**, so this phase ships a **mock-confirm path** (a DI seam) to make conversion testable now.
The product framing: home nursing in Iran is dominantly **multi-visit / شبانه‌روزی live-in** care, so a
booking carries a `session_count` and owns **N `booking_sessions`**, each independently scheduled,
verified (EVV), and paid out per completed session — money releases per session, not as one whole-month
escrow ([`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)).
EVV ([`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md))
is the authoritative GPS-and-timestamp proof that a visit happened, and is the gate that — together with a
closed dispute window — releases escrow. A single-visit booking still creates **exactly one** session so
the EVV/payout path is uniform.
**What already exists (do not rebuild) — built by prior phases:**
- **`booking_requests` + its lifecycle** — [b8](./backend-phase-8.md) built `booking_requests`
(`customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id`,
`required_caregiver_gender`, `requested_date`/`requested_time_start`/`requested_time_end`,
unencrypted request-stage `customer_notes`, frozen `nurse_response_deadline_at` + `payment_deadline_at`,
`nurse_rejection_reason`, and the `status` machine `pending_nurse_response → accepted_awaiting_payment →
converted | rejected_by_nurse | expired_no_response | payment_deadline_expired | cancelled_by_customer`),
the create/accept/reject commands, the expiry job, and the **same-gender + tenancy validation** at
request time. **This phase reads an `accepted_awaiting_payment` request and converts it; it does not
re-validate gender/tenancy from scratch — those were enforced at request creation and are frozen.** The
conversion flips the request to `converted`.
- **`platform_configs` typed cached accessor + `support_alerts` + notifications** — [b1](./backend-phase-1.md)
built the typed, cached config reader (read `dispute_window_hours` default `72`,
`evv_location_tolerance_meters`, and the no-show late threshold through it — **never hardcode**), the
`support_alerts` table + raise API (this phase raises `location_mismatch` and `no_show` alerts), and the
real in-app `notifications` write behind **`INotificationDispatcher`**.
- **`IGeocoder` + address coordinates** — [b4](./backend-phase-4.md) built `customer_addresses` (with
lat/lng) and the **`IGeocoder`** seam. This phase **reuses `IGeocoder`** for the EVV address-match
distance computation; it does not introduce a new geo seam.
- **`IFieldEncryptor`, `ICurrentUser` + audit interceptor, the REST surface** — [b0](./backend-phase-0.md)
built `IFieldEncryptor` (encrypts `address_snapshot_json` and the `booking_care_instructions` columns;
never logs plaintext), `ICurrentUser` + the audit-field SaveChanges interceptor, the rate limiter, the
`BaseController` + `OperationResult<T>` envelope, CQRS via **`martinothamar/Mediator`**, and
`IDateTimeProvider`.
- **`nurse_service_variants`, `patients`, `customer_addresses`** — the priced variant, the patient, and the
service address the request points at, built in catalog (b5) / identity (b3) / geo (b4). This phase reads
them only to **snapshot** them — it never mutates them.
**What this phase introduces:** the five booking-domain tables (`bookings`, `booking_sessions`,
`booking_care_instructions`, `visit_verifications`, `cancellation_policies`), the conversion / session /
EVV / dispute-window / cancellation capabilities, and **one new seam — `IPaymentCaptureSimulator`** (the
mock-confirm path that stands in for b10's real card capture so conversion is testable now). The actual
card gateway, ledger postings, and refund execution are **DEFERRED** to b10/b11 (pointers in §3.6).
## 2. Required reading (do this first)
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/backend-conventions-checklist.md`](../_shared/backend-conventions-checklist.md) —
especially the *Performance, caching, money, idempotency* block (IRR `BIGINT`, the three-amount split,
encrypted PII columns through the field-encryptor seam, projected + paginated reads).
- [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
**the business rules**: the two-phase split (no money on a request; a booking implies captured
payment), single-visit *and* multi-session engagements, the booking status machine, snapshots, and MVP
vs DEFERRED (recurring schedules modeled-but-inactive).
- [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
**the EVV rules**: per-session GPS check-in/out, the *advisory* address-match tolerance
(`evv_location_tolerance_meters`) that flags review but never auto-cancels, no-show alerting, and that
**payout is gated on EVV completion + a closed dispute window** (never on `completed` alone).
- [`product/data-model/05-booking-and-scheduling.md`](../../../product/data-model/05-booking-and-scheduling.md)
**the canonical schema** for `bookings` (the three amounts + `platform_fee_rate` + `session_count` +
`dispute_window_ends_at` + snapshots + the guarded status), `booking_sessions`,
`booking_care_instructions`, `visit_verifications` (FK now on `booking_session_id`), and
`cancellation_policies`. **Mirror these field names and the CHECK exactly.**
- [`product/overview/platform-summary.md`](../../../product/overview/platform-summary.md) — the four ground
truths (no cash custody → escrow is a ledger state, the weekly-payout / hold-then-pay model that *requires*
EVV proof) and the **IRR-Rials-always** money rule.
- **Code to mirror:** b8's `Features/Bookings/**` (or `Features/BookingRequests/**`) command/query structure,
validators, and the `booking_requests` config; b4's `customer_addresses` config + `IGeocoder` usage; b1's
typed config accessor and `support_alerts` raise API + `INotificationDispatcher`; b0's `IFieldEncryptor`
usage on encrypted columns and the `BaseController`/`OperationResult` pattern.
- **Contract conventions:** [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
and [`money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR `BIGINT`, the envelope,
enum casing).
- **Prior handoffs:** `dev/shared-working-context/backend/handoff/after-backend-phase-8.md`, `…-4.md`,
`…-1.md`, `…-0.md`, and `reports/mocks-registry.md` (the seam rows you reuse — `IGeocoder`,
`IFieldEncryptor`, `INotificationDispatcher` — plus the one you add).
## 3. Scope — build this
All money is IRR `long` / `BIGINT`. Features live under
`Baya.Application/Features/Bookings/{Commands|Queries}/<Name>/`; entities in
`Baya.Domain/Entities/Bookings/`; one `IEntityTypeConfiguration<T>` per entity in
`Persistence/Configuration/BookingsConfig/`; one EF migration for the five tables. Encrypted columns
(`address_snapshot_json`, the `booking_care_instructions` clinical fields, EVV GPS detail) go through
`IFieldEncryptor` — never stored or logged in plaintext.
### 3.1 Entities + migration
**`bookings`** [CORE] — the confirmed engagement; source of truth for the service event + its money split.
- Fields: `id` (BIGINT PK), `booking_request_id` (BIGINT FK → `booking_requests`, **UNIQUE** — 1:1),
`customer_id`, `nurse_id`, `patient_id`, `variant_id`, `customer_address_id` (denormalized FKs for query
performance), `partner_center_id` (BIGINT FK → `partner_centers`, **nullable** — the licensed center /
merchant-of-record; `partner_centers` is DEFERRED to b15, so leave the FK nullable and unset for now),
`variant_snapshot_json` (NVARCHAR(MAX) — variant + option labels at booking time),
`address_snapshot_json` (NVARCHAR(MAX), **encrypted** — full address at booking time),
`gross_price_irr` (BIGINT — total charged the customer), `balinyaar_commission_irr` (BIGINT — platform's
cut), `platform_fee_rate` (DECIMAL(5,4) — **rate snapshot for audit**, frozen at conversion),
`nurse_payout_amount` (BIGINT — `= gross_price_irr balinyaar_commission_irr`, **derived, not
free-entered**), `psp_fee_amount` (BIGINT, **nullable** — gateway cost for true margin; the mock-confirm
path may set it, real capture sets it in b10), `session_count` (SMALLINT NOT NULL DEFAULT 1),
`scheduled_date` / `scheduled_time_start` / `scheduled_time_end` (engagement-level; per-visit lives in
`booking_sessions`), `status` (NVARCHAR(30) — the guarded machine below), `confirmed_at`,
`cancelled_at`, `cancellation_reason`, `cancelled_by`, `completed_at`,
`dispute_window_ends_at` (DATETIME2, **nullable** — set on completion = `completed_at +
config(dispute_window_hours, 72)`), audit + soft-delete fields.
- **CHECK (DB constraint, not handler-only):** `gross_price_irr = balinyaar_commission_irr +
nurse_payout_amount`; **all three ≥ 0**.
- **`payout_released` was CUT — do NOT add any boolean "paid" flag.** Paid-ness is derived later from a
`nurse_payout_booking_links` row + the ledger (b13).
- Relations: 1:1 ← `booking_requests`; 1:N → `booking_sessions`; 1:1 → `booking_care_instructions`;
referenced later by `payment_transactions`/`ledger_entries`/`reviews`/`invoices`/`refunds`/`nurse_clawbacks`/
`nurse_payout_booking_links` (those tables land in later phases — do not create them here).
**`booking_sessions`** [MVP] — one row per **visit**; always ≥ 1, even for a single-visit booking.
- Fields: `id` (BIGINT PK), `booking_id` (BIGINT FK → `bookings`), `session_index` (INT — 1-based ordinal),
`scheduled_date` / `scheduled_time_start` / `scheduled_time_end` (per-visit), `visit_payout_amount`
(BIGINT — this session's portion of `nurse_payout_amount`), `status` (NVARCHAR(20) — `scheduled` |
`in_progress` | `completed` | `missed` | `cancelled`), `payout_eligible_at` (DATETIME2, **nullable**
per-session dispute-window close, set on completion), `cancellation_event_id` (BIGINT, **nullable** — set
when this session is cancelled, references the cancellation snapshot recorded on the booking/session),
audit + soft-delete fields.
- **Invariant (handler-enforced):** `Σ(booking_sessions.visit_payout_amount) = bookings.nurse_payout_amount`
for the booking — the split must reconcile exactly (distribute the remainder of integer division onto the
last session so no Rial is lost or created). All `visit_payout_amount ≥ 0`.
- Relations: N:1 → `bookings`; 1:1 → `visit_verifications`.
**`booking_care_instructions`** [CORE] — encrypted clinical/logistical context; **post-confirmation +
assigned-nurse/admin only**.
- Fields: `id` (BIGINT PK), `booking_id` (BIGINT FK → `bookings`, **UNIQUE** — 1:1), and the encrypted
fields (all NVARCHAR(MAX) **enc**): `current_conditions`, `medications`, `allergies`,
`special_instructions`, `emergency_contact_name`, `emergency_contact_phone`, audit + soft-delete fields.
- **Why separate + encrypted:** keeps the financial/scheduling table clean and enforces the two-stage
disclosure boundary with stricter access control. **Never** project these fields into a list query or log
them; decrypt only in the gated `GetCareInstructionsQuery` (§3.2).
- Relations: 1:1 → `bookings`.
**`visit_verifications`** [CORE] — the EVV record; **required for payout**.
- Fields: `id` (BIGINT PK), `booking_session_id` (BIGINT FK → `booking_sessions`, **UNIQUE** — 1:1; the FK
is on the *session*, not the booking, so each visit is verified independently),
`check_in_at` (DATETIME2, nullable), `check_in_lat` / `check_in_lng` (decimal, nullable),
`check_out_at` (DATETIME2, nullable), `check_out_lat` / `check_out_lng` (decimal, nullable),
`check_in_address_match` (BIT/bool, **nullable***advisory*: did check-in fall within
`evv_location_tolerance_meters` of the booking address?), `check_in_distance_meters` (decimal, nullable —
the computed distance, for the admin review screen), `status` (NVARCHAR(20) — `pending` | `checked_in` |
`completed`), audit + soft-delete fields.
- **GPS detail is sensitive** — treat with the same access discipline as PII; only the owning nurse + admin
read raw EVV detail. `visit_verifications.status` and the parent `bookings.status` must stay consistent
via the documented mapping (§5).
- Relations: 1:1 → `booking_sessions`.
**`cancellation_policies`** [MVP] — config-driven, snapshot-able refund/penalty tiers by lead time + actor.
- Fields: `id` (BIGINT PK), `code` (NVARCHAR(50) **UNIQUE** — e.g. `standard_24h`, `nurse_no_show`),
`applies_to` (NVARCHAR(20) — `customer` | `nurse` | `admin`), `hours_before_start_min` /
`hours_before_start_max` (INT, **nullable** — tier bounds, half-open ranges), `refund_percentage`
(DECIMAL(5,2) — 0100), `fee_amount_or_rate` (cancellation fee / nurse penalty — store as a BIGINT IRR
fee plus an optional DECIMAL rate, or a discriminator + value; pick one and document it), `is_active`
(bool), audit + soft-delete fields.
- **Seed** the baseline tiers (admin CRUD is below): e.g. `standard_24h` (customer, ≥ 24h before start →
100% refund), `standard_inside_24h` (customer, < 24h → 50% refund), `nurse_no_show` (nurse → 100% refund
+ nurse penalty). Confirm exact tiers against the product doc; if the doc leaves a number open, pick the
safe default, make it config-seeded, and flag it in the report.
- **Resolution + snapshot:** the applicable row is resolved by `(applies_to, lead-time bucket)` at cancel
time and its **`code` + `refund_percentage`** are **frozen onto the cancellation** — a later edit to the
policy row must not change a past cancellation.
**Status enums** (define as proper enums; persist as string per project convention so the contract is
readable):
- `BookingStatus`: `pending_payment` | `confirmed` | `in_progress` | `completed` | `disputed` | `closed` | `cancelled`.
- `BookingSessionStatus`: `scheduled` | `in_progress` | `completed` | `missed` | `cancelled`.
- `VisitVerificationStatus`: `pending` | `checked_in` | `completed`.
- `CancellationActor` (for `applies_to`): `customer` | `nurse` | `admin`.
**Allowed booking transitions** (encode as a transition table consulted by `TransitionBookingStatusCommand`
— a CHECK constraint can back the terminal states; the table is the authoritative guard):
`pending_payment → confirmed | cancelled`; `confirmed → in_progress | cancelled`;
`in_progress → completed | cancelled`; `completed → disputed | closed`; `disputed → closed`. `closed`,
`cancelled` are terminal. **No transition may contradict EVV** (e.g. you cannot move a booking to
`in_progress` with no session checked-in; you cannot move to `completed` while a session is still
`in_progress`).
### 3.2 Commands & queries (CQRS, `OperationResult`, never throw for expected failures)
| Capability | Type | Route | What it does |
| --- | --- | --- | --- |
| **`ConvertRequestToBookingCommand`** | Command (internal step + mock-confirm trigger) | `POST api/v1/bookings/convert` (mock/test path — see §4) | The conversion engine, **invoked by payment capture in b10**. Loads an `accepted_awaiting_payment` `booking_requests` row, verifies capture succeeded (via `IPaymentCaptureSimulator` now / real `payment_transactions.succeeded` in b10). Creates a `bookings` row 1:1 (`pending_payment → confirmed`), writes `variant_snapshot_json` + **encrypted** `address_snapshot_json` from the current variant/address, **computes the three amounts** (`gross_price_irr` from the variant price × sessions/units; `balinyaar_commission_irr = round(gross × platform_fee_rate)` from the *config rate snapshotted into* `platform_fee_rate`; `nurse_payout_amount = gross commission`, asserting `gross = commission + payout`), sets `session_count`, flips the request → `converted`, and orchestrates **`GenerateBookingSessions`** in the same unit of work. **Idempotent:** the `booking_request_id` UNIQUE means a replay can't create a second booking — detect the existing booking and return it. |
| **`GenerateBookingSessions`** | Command (internal step) | — | Creates `session_count` `booking_sessions` (`session_index` 1…N, status `scheduled`), splitting `nurse_payout_amount` into `visit_payout_amount` so **Σ exactly equals `nurse_payout_amount`** (integer split + remainder on the last session). **Always creates ≥ 1 session**, even for a single visit, so the EVV/payout path is uniform. Per-visit schedule defaults from the engagement schedule; multi-session schedules can be filled later. |
| **`GetBookingDetailQuery`** | Query | `GET api/v1/bookings/{id}` | Booking header + money summary (the three amounts, `platform_fee_rate`, `psp_fee_amount`) + sessions (schedule, status, EVV state) + status timeline. **Tenancy-scoped:** customer sees own bookings, nurse sees assigned bookings, admin sees all — never cross-tenant. Projected (AsNoTracking + `.Select`). **Never** includes care-instruction clinical fields. |
| **`ListBookingsQuery`** | Query | `GET api/v1/bookings?role=customer\|nurse&status=&page=&page_size=` | The role-scoped "My bookings" list (customer / nurse), status-filterable, **projected + paginated**. Admin variant lists all. |
| **`ListSessionsForNurseQuery`** | Query | `GET api/v1/booking_sessions/today?date=` | The nurse's sessions for a day (today's visits), with per-session check-in/out CTA state. Tenancy-scoped to the nurse via `ICurrentUser`, projected + paginated. |
| **`TransitionBookingStatusCommand`** | Command | `POST api/v1/bookings/{id}/transition` | Applies a status change **only if allowed** by the transition table (§3.1) **and** consistent with EVV/session state; otherwise `OperationResult.FailureResult` (no throw). Records `confirmed_at`/`cancelled_at`/`completed_at` as appropriate. Most transitions are driven internally (capture → `confirmed`, first check-in → `in_progress`, last check-out → `completed`); the explicit endpoint covers admin/dispute moves. |
| **`SubmitCareInstructionsCommand`** | Command | `POST api/v1/bookings/{id}/care_instructions` | Writes/updates the 1:1 `booking_care_instructions` (**encrypted**) for a **confirmed** booking. Customer-authored (or admin). Validates the booking is `confirmed`+ (not `pending_payment`/`cancelled`). |
| **`GetCareInstructionsQuery`** | Query | `GET api/v1/bookings/{id}/care_instructions` | **Decrypts and returns** the clinical fields **only** to (a) the **assigned nurse** of that booking and (b) **admin**, and **only post-confirmation**. Any other caller (the customer, an unassigned nurse, pre-confirmation) → `403`/`NotFoundResult`**the two-stage disclosure boundary; do not leak.** |
| **`CheckInVisitCommand`** | Command | `POST api/v1/booking_sessions/{id}/check_in` | The assigned nurse clocks in: captures GPS + timestamp into `visit_verifications`, computes `check_in_distance_meters` to the booking address via **`IGeocoder`** and sets `check_in_address_match` against `config(evv_location_tolerance_meters)`. **On mismatch:** raise a `support_alerts` (`location_mismatch`) for admin review and notify via `INotificationDispatcher`**never block, never cancel.** Sets the session → `in_progress` and the booking → `in_progress` (first relevant check-in). Tenancy: only the assigned nurse. |
| **`CheckOutVisitCommand`** | Command | `POST api/v1/booking_sessions/{id}/check_out` | Must follow an open check-in (else `FailureResult`). Captures GPS + timestamp, sets `visit_verifications.status = completed`, the session → `completed`, and — when **all** of the booking's sessions are `completed`/`cancelled`/`missed` — the booking → `completed`, which fires **`SetDisputeWindow`** (below). |
| **`SetDisputeWindow`** | Command (internal step on completion) | — | On booking completion sets `dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`; on **each** session completion sets that session's `payout_eligible_at` from the same/per-session window. This is the **only** thing that makes a session payout-eligible — `completed` alone never is. |
| **`GetVisitVerificationQuery`** / **`ListSessionEvvQuery`** | Query | `GET api/v1/booking_sessions/{id}/evv`, `GET api/v1/admin_evv?type=mismatch\|no_show&page=&page_size=` | Per-session EVV detail (owning nurse + admin only) and the **admin EVV-review queue** (mismatch / no-show, joined to `support_alerts`). Projected + paginated; raw GPS gated to nurse(own)+admin. |
| **`CancelBookingCommand`** / **`CancelSessionCommand`** | Command | `POST api/v1/bookings/{id}/cancel`, `POST api/v1/booking_sessions/{id}/cancel` | Resolve the applicable `cancellation_policies` row by **lead time + actor**, **snapshot its `code` + `refund_percentage`** onto the cancellation (record `cancellation_event_id` on the session/booking, `cancelled_by`, `cancellation_reason`), set the session(s) → `cancelled` and (if whole booking) the booking → `cancelled`. **Refund only un-started sessions** (those still `scheduled` with no EVV check-in); a session already `in_progress`/`completed` is not refunded. **Refund *execution* is b11** — this phase records the cancellation + computes/snapshots the refundable amount and policy; it does **not** post the refund ledger or call a refund channel. |
| **`ManageCancellationPoliciesCommand`** (CRUD) | Command | `POST/PUT api/v1/admin_cancellation_policies` | Admin CRUD + the seed for the baseline tiers. Editing a policy **never** mutates an already-snapshotted cancellation. |
- **Controllers:** `BookingsController` (customer/nurse/admin, tenancy-scoped), `BookingSessionsController`
(nurse EVV + session views), `AdminEvvController` (admin review queue), and
`AdminCancellationPoliciesController` (admin). All `sealed : BaseController`, inject `ISender`, return
`base.OperationResult(...)`, snake_case `[controller]`/`[action]` routes, `CancellationToken` threaded.
Cancellation and EVV endpoints carry the **admin/nurse** narrowest-fitting policy; the cancel/convert
endpoints are **rate-limited**.
- **Validators:** FluentValidation on the input-bearing commands (`ConvertRequestToBookingCommand`
request id present + in `accepted_awaiting_payment`; `SubmitCareInstructionsCommand` — booking confirmed,
field lengths; `CheckIn/CheckOutVisitCommand` — GPS present, session belongs to the nurse;
`CancelBooking/CancelSessionCommand` — reason present; `ManageCancellationPoliciesCommand` — percentage
0100, non-overlapping tiers per actor).
### 3.3 No-show / late detection (job)
A scheduled sweep: if a session has no check-in by `scheduled_time_start + config(no_show_threshold)`,
create a `no_show` `support_alerts` row and notify the family via `INotificationDispatcher`, and mark the
session `missed` (per the EVV doc). **The recurring scheduler itself is DEFERRED** — build the
`DetectNoShowSessions` command (the unit of work the cron will call) and a config key for the cadence;
trigger it from an admin/test endpoint now and note it in the report. (Roadmap: a hosted scheduler — same
pattern as b8's `ExpireBookingRequests` and b13's `SchedulePayoutJob`.)
### 3.4 DEFERRED (build the seam/flag, not the feature)
- **`recurring_booking_schedules`** — open-ended recurring engagements: **modeled-but-inactive** per the
product doc. Do **not** create the table or any activation logic/UI this phase; launch is all finite
engagements. Note the deferral in the report.
- **Hard availability-based booking blocks** — availability slots/exceptions remain **soft** (search
guidance only, owned by the nurse domain); the nurse always individually accepts/rejects. Do not add a
hard block here.
- **Continuous geofencing during a live-in shift, supervisory tele-check-ins, family-visible live care
logs, consented in-home cameras** — DEFERRED per [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md) §(c).
Build only the per-session check-in/out EVV.
### 3.5 What this phase does NOT do (handed to later phases)
- **Real card capture, `payment_transactions`, `payment_webhook_events`, `ledger_entries`** — b10. This
phase only **consumes a capture signal** via the `IPaymentCaptureSimulator` seam to drive conversion.
- **Refund execution / refund ledger / `refunds`** — b11. This phase records the cancellation +
snapshots the policy + computes the refundable un-started-session amount; it posts no refund.
- **Payout batching / `nurse_payout_booking_links` / `dispute_window`-gated eligibility selection** — b13.
This phase only **sets** `dispute_window_ends_at` / `payout_eligible_at`; b13 consumes them.
- **Reviews on a completed booking** — b14.
## 4. Mocks & seams in this phase
| Seam | Owner | Mock behaviour | Registry |
| --- | --- | --- | --- |
| **`IPaymentCaptureSimulator`** | **introduced here** | `ConfirmCaptureAsync(bookingRequestId, ct)` returns a deterministic *succeeded* capture result (a fake `gateway_reference`, an optional `psp_fee_amount`) so `ConvertRequestToBookingCommand` is testable now. **In b10 the real card capture replaces this** by calling `ConvertRequestToBooking` directly after a real `payment_transactions.succeeded`; this seam is the temporary trigger, not a parallel money path. A config switch can force a *failed* capture so the "capture failed → no booking" path is testable. | **add a new row** (🟡) |
| `IGeocoder` | reuse from **b4** | mock returns fixed/deterministic coordinates + a haversine distance; used for the EVV `check_in_address_match` advisory and `check_in_distance_meters`. | reuse row |
| `IFieldEncryptor` | reuse from **b0** | local symmetric key; encrypts `address_snapshot_json` + the `booking_care_instructions` clinical fields; **never logs plaintext**. | reuse row |
| `INotificationDispatcher` | reuse from **b1** | real in-app `notifications` write; used for no-show/late + location-mismatch alerts to family/admin. | reuse row |
| `ICacheService` | reuse from **b0/b1** | in-memory; behind the typed config accessor (`dispute_window_hours`, `evv_location_tolerance_meters`, no-show threshold). | reuse row |
The `IPaymentCaptureSimulator` mock lives behind a **DI-registered interface** in Infrastructure (selected
by config; **no `if (mock)` branch in a handler**), so b10 swaps in the real capture trigger cleanly.
Append its row to [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
(seam, file, what's faked, config keys, **step-by-step how to make it real** — in b10 this seam is removed
and `ConfirmPaymentAndPostLedger` calls `ConvertRequestToBooking` directly on a real `succeeded`
transaction).
## 5. Critical rules you must not get wrong
**Money correctness is sacred — the following must hold verbatim:**
- **Money is IRR `BIGINT`, no floats, ever.** `gross_price_irr`, `balinyaar_commission_irr`,
`nurse_payout_amount`, `psp_fee_amount`, `visit_payout_amount` are all `long`/`BIGINT`. Commission is
computed by integer-rounding `gross × platform_fee_rate` at conversion and the rate is **snapshotted**
into `platform_fee_rate`; no float survives into storage.
- **`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`, all amounts ≥ 0** — a DB CHECK on
`bookings`, and `nurse_payout_amount` is **derived** (`gross commission`), never free-entered. Never
store a split that doesn't sum.
- **A booking exists ONLY when the nurse accepted AND payment was captured.** Never create a `bookings`
row from an unpaid or unaccepted request; conversion runs **only** from an `accepted_awaiting_payment`
request with a successful capture (the `IPaymentCaptureSimulator` now / real `succeeded` transaction in
b10). On capture, flip the request → `converted`.
- **Append-only / snapshot discipline — snapshots freeze history.** `variant_snapshot_json`,
`address_snapshot_json`, `platform_fee_rate`, and the resolved cancellation `code` +
`refund_percentage` are frozen at their moment; **later edits to the variant/address/policy rows must
not mutate an existing booking.** Read snapshots from the booking, never re-resolve from live source rows.
- **The `payout_released` boolean was CUT — never reintroduce it.** Do not add any boolean "paid"/
"payout done" flag to `bookings` or `booking_sessions`. Paid-ness is derived later from a
`nurse_payout_booking_links` row + the ledger (b13).
- **Payout is eligible ONLY after `dispute_window_ends_at` passes with no open dispute — never on
`completed` alone.** `SetDisputeWindow` sets `dispute_window_ends_at = completed_at +
config(dispute_window_hours, 72)` (and per-session `payout_eligible_at`); b13 gates the payout on that,
not on the `completed` status. EVV check-out is necessary but not sufficient.
- **`Σ(visit_payout_amount) = nurse_payout_amount`** across the booking's sessions — reconcile exactly,
remainder on the last session; no Rial created or lost in the split.
**Domain invariants you must not get wrong:**
- **Two-stage clinical disclosure.** Pre-accept, the nurse sees **only** the unencrypted request-stage
`customer_notes` (b8). The full **encrypted** `booking_care_instructions` are readable **only
post-confirmation** and **only** by the **assigned nurse** + **admin** — never the customer, never an
unassigned nurse, never pre-confirmation. `GetCareInstructionsQuery` enforces this; the fields are never
projected into a list or logged.
- **A single-visit booking still creates exactly one `booking_session`** so EVV and payout follow one
uniform path. `GenerateBookingSessions` always produces ≥ 1.
- **EVV address mismatch is *advisory only*.** On a check-in outside `evv_location_tolerance_meters`, raise
a `location_mismatch` `support_alerts` + notify for admin review — **never auto-cancel, never block the
visit, never withhold based on mismatch alone.** GPS-permission-denied/unavailable still allows check-in
(flagged). Tolerance radius + no-show threshold come from `platform_configs`, not hardcoded constants.
- **EVV is per session, not per booking.** The FK is `booking_session_id`; a multi-day engagement accrues
payout per completed session; one EVV cannot represent a multi-day engagement.
- **Booking and EVV state machines must not diverge.** Transitions go through
`TransitionBookingStatusCommand`'s allowed-transition guard; `visit_verifications.status` and
`bookings.status` stay consistent via the documented mapping (`checked_in` ↔ session `in_progress`
booking `in_progress`; all sessions `completed` ↔ booking `completed`). No transition may contradict EVV.
- **Cancellation refunds only un-started sessions.** Mid-engagement cancel refunds only sessions still
`scheduled` with no check-in; `in_progress`/`completed` sessions are not refunded. The applicable policy
is resolved by lead time + actor and **snapshotted** at cancel time.
- **Tenancy + access discipline.** `GetBookingDetail`/`ListBookings`/`ListSessionsForNurse` are scoped to
the authenticated customer or nurse via `ICurrentUser` — a customer/nurse can never read another's
bookings or sessions; admin endpoints sit behind the admin policy. Raw EVV GPS detail and care
instructions are gated to the owning nurse + admin only.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] The five tables (`bookings`, `booking_sessions`, `booking_care_instructions`, `visit_verifications`,
`cancellation_policies`) exist via one migration, each with its `IEntityTypeConfiguration<T>`: the
`gross = commission + payout` (all ≥ 0) DB CHECK on `bookings`, the `booking_request_id` /
`booking_id` (care) / `booking_session_id` (EVV) UNIQUE 1:1 indexes, the encrypted
`address_snapshot_json` + care-instruction columns, the `cancellation_policies.code` UNIQUE + seeded
tiers, and soft-delete/audit wiring per conventions.
- [ ] All §3.2 commands/queries implemented (CQRS, `OperationResult`, projected + paginated reads,
validators), with `BookingsController`, `BookingSessionsController`, `AdminEvvController`,
`AdminCancellationPoliciesController`.
- [ ] **`IPaymentCaptureSimulator`** introduced (Application interface, Infrastructure mock, DI via a
`ServiceConfiguration/` extension, config-selected). No `if (mock)` in handlers.
- [ ] Conversion computes the three amounts correctly (`gross = commission + payout`), writes both
snapshots (address encrypted), sets `session_count`, generates ≥ 1 session with reconciling
`visit_payout_amount`, and flips the request → `converted` — idempotently (replay returns the
existing booking).
- [ ] Care instructions are hidden pre-confirmation and from the customer/unassigned nurse, and decrypt
only for the assigned nurse + admin post-confirmation. EVV check-in/out marks a session completed; a
GPS mismatch raises a `location_mismatch` alert **without blocking**; the last check-out completes the
booking and `SetDisputeWindow` sets `dispute_window_ends_at` (+ per-session `payout_eligible_at`).
- [ ] Cancellation resolves + snapshots the policy code/percentage and refunds only un-started sessions
(no refund ledger posted — that's b11). The no-show `DetectNoShowSessions` command works
(admin/test-triggered; cron DEFERRED).
- [ ] Handler unit tests (NSubstitute) for: the three-amount split + session reconciliation; the
two-stage disclosure gate; the EVV mismatch-raises-alert-without-blocking path; `SetDisputeWindow` on
completion; the cancellation policy resolution/snapshot + un-started-only refund computation; the
transition guard. ≥ 1 `WebApplicationFactory` integration test per controller (happy path, 401,
validation 400, and a 403 for the disclosure boundary). `dotnet build Baya.sln` zero new warnings;
`dotnet test Baya.sln` green.
- [ ] The `Baya.Application/Features/Bookings/**` area is reflected in the **Project map** in
`server/CLAUDE.md`; the `IPaymentCaptureSimulator` seam noted where seams are documented.
- [ ] The contract `dev/contracts/domains/bookings-evv.md` written and the `swagger.json` snapshot
republished.
## 7. How to test (what a human can verify after this phase)
Seed (or reuse from b8) an `accepted_awaiting_payment` `booking_requests` row pointing at a real variant,
patient, and address; have the nurse identity and a customer identity available. Keep the
`IPaymentCaptureSimulator` mock in *succeeded* mode unless a step says otherwise.
1. **Convert a request → booking (mock capture)**`POST api/v1/bookings/convert` for the accepted
request → a `bookings` row appears (`confirmed`), the request flips to `converted`, the **three amounts
sum** (`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`, all ≥ 0), `platform_fee_rate`
and the snapshots are populated (`address_snapshot_json` encrypted), `session_count` is set, and **N
`booking_sessions` are generated** with `Σ visit_payout_amount = nurse_payout_amount`. Re-`convert` the
same request → the **same** booking is returned (no second booking — idempotent).
2. **Single-visit uniformity** — convert a `session_count = 1` request → **exactly one** `booking_sessions`
row is created.
3. **Care instructions — disclosure boundary**`POST .../care_instructions` on the confirmed booking,
then `GET .../care_instructions`: **as the customer or an unassigned nurse → `403`/not-found** (hidden);
**as the assigned nurse (post-confirmation) → the decrypted fields** are returned; **as admin →**
returned. Confirm the clinical fields never appear in `GET api/v1/bookings/{id}` or any list.
4. **EVV check-in/out marks a session completed** — as the assigned nurse, `POST
.../booking_sessions/{id}/check_in` with in-range GPS → the session → `in_progress`, the booking →
`in_progress`; `POST .../check_out``visit_verifications.status = completed`, the session →
`completed`.
5. **GPS mismatch raises an alert without blocking** — check in with out-of-range GPS (force the
`IGeocoder` mock distance past `evv_location_tolerance_meters`) → the check-in **still succeeds**, the
session goes `in_progress`, `check_in_address_match = false`, and a `location_mismatch` `support_alerts`
row + a notification are created. Confirm it appears in `GET api/v1/admin_evv?type=mismatch`.
6. **Completion sets the dispute window** — check out the **last** remaining session → the booking →
`completed`, `dispute_window_ends_at = completed_at + 72h` (from config), and each completed session's
`payout_eligible_at` is set. Confirm the booking is **not** payout-eligible before that timestamp passes.
7. **Cancellation**`POST api/v1/bookings/{id}/cancel` (or a session) → the applicable
`cancellation_policies` tier is resolved by lead time + actor, its `code` + `refund_percentage` are
**snapshotted** onto the cancellation, only **un-started** sessions are marked refundable, and the
booking/session → `cancelled`. Edit the underlying policy row afterward → the snapshot on the past
cancellation is **unchanged**. (No refund ledger is posted — that's b11.)
8. **Transition guard** — attempt an illegal transition (e.g. `confirmed → completed` skipping
`in_progress`, or `completed` while a session is still `in_progress`) → `OperationResult` failure, no
state change.
9. **No-show** — trigger `DetectNoShowSessions` (admin/test endpoint) for a session past
`scheduled_time_start + threshold` with no check-in → a `no_show` `support_alerts` + family
notification; the session → `missed`.
## 8. Hand off & document (close the phase)
- **Docs to update:** the **Project map** in [`server/CLAUDE.md`](../../../server/CLAUDE.md) (add the
`Features/Bookings/**` area + the `IPaymentCaptureSimulator` seam). If you discover/confirm a rule the
product docs don't capture (e.g. the exact `visit_payout_amount` remainder-on-last-session split, the
EVV-state ↔ booking-state mapping table, the seeded cancellation tiers, or a `no_show` threshold default),
record it in [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
/ [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
(and regenerate the HTML view per `product/CLAUDE.md`) — **don't invent rules**, record decisions.
- **Contract to write:** **`dev/contracts/domains/bookings-evv.md`** (per
[`../../contracts/domains/_TEMPLATE.md`](../../contracts/domains/_TEMPLATE.md)) — the booking endpoints
(convert/detail/list, transition), the care-instruction submit/read (with the **two-stage disclosure**
note: assigned-nurse/admin only, post-confirmation), the session/EVV endpoints (check-in/out, today's
sessions, EVV detail, admin EVV-review queue), the cancellation endpoints + admin policy CRUD; the
`BookingStatus` / `BookingSessionStatus` / `VisitVerificationStatus` / `CancellationActor` enums; the
booking/session/EVV/care-instruction DTO shapes (IRR `BIGINT`; the three amounts; **address snapshot
masked/omitted** in list views; **raw GPS gated**); auth/tenancy/rate-limit notes; and the side-effects
(dispute-window set on completion, `support_alerts` on mismatch/no-show, snapshot freezing). Republish
the `swagger.json` snapshot per [`../../contracts/openapi/README.md`](../../contracts/openapi/README.md).
This is what **f8-b9** consumes.
- **Handoff & report:** write `dev/shared-working-context/backend/handoff/after-backend-phase-9.md` (the
booking engine is live; what f8 can now build — booking detail & sessions, nurse EVV check-in/out, the
post-confirmation care-instructions form, the status timeline; which endpoints/contracts are live; that
capture is mocked behind `IPaymentCaptureSimulator` and the real conversion trigger arrives with b10
payments). Append to `backend/STATUS.md`. Write
`dev/shared-working-context/reports/backend-phase-9-report.md` (what was built, **what is now testable and
exactly how** per §7, what is mocked + how to make it real, contracts produced/consumed, follow-ups: the
no-show cron, refund execution in b11, payout eligibility consumption in b13, `partner_centers` wiring in
b15). Update `dev/shared-working-context/reports/mocks-registry.md` (the `IPaymentCaptureSimulator` row →
🟡).
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes — the
conversion-only-on-accept+capture rule, the three-amount split + `Σ visit_payout_amount =
nurse_payout_amount` reconciliation, the snapshot-freezes-history discipline, the **two-stage clinical
disclosure** gate, the advisory (never-blocking) EVV mismatch behaviour, `SetDisputeWindow` as the *only*
payout-eligibility trigger, the cut `payout_released` boolean, and the `IPaymentCaptureSimulator` seam —
with a one-line pointer in `MEMORY.md`.
+166
View File
@@ -0,0 +1,166 @@
# Frontend Phase 0 — Foundations: app shells, design system & the data/contract patterns
> **Mission:** turn the inherited starter into a clean Balinyaar foundation for the three actor
> experiences (family/customer, nurse, admin). Remove the demo leftovers, build the **app shells** and
> route groups each actor needs, and lock in the **patterns** every later frontend phase will follow:
> how a `services/{domain}` talks to the API, how TanStack Query caches it, how types come from the
> published contract, and where shareable components live. No real feature data yet (no backend
> dependency) — this phase makes f1f15 fast, consistent, and re-render-cheap.
>
> **Track:** frontend · **Depends on:** nothing (`frontend-phase-0`, no backend phase required) ·
> **Unlocks:** every frontend phase
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The client (`client/`, **Next.js 16 App Router + React 19 + MUI v9 + next-intl**, `fa` default & RTL)
already ships a full app shell and an `App*` component library — you **keep and build on** these and
**remove** the demo bits.
**What already exists (do not rebuild) — confirmed in the codebase:**
- Root layout `src/app/[locale]/layout.tsx` (renders `<html lang dir>`, providers: NextIntl → Auth →
Theme → Query → Notistack), `(private-routes)`/`(public-routes)` groups, the `TopBarAndSideBarLayout`
engine + `TopBar`/`SideBar`/`BottomBar`.
- Theme system (brand tokens **teal `#1d4a40`**, **terracotta `#d98c6a`**, cream in `theme/colors.ts` +
`tokens.css`; `APP_THEME_LTR/RTL`, dark mode), i18n (`routing.ts`, `request.ts`), the cookie manager,
`clientFetch`/`serverFetch` + `ApiError`, TanStack Query (`makeQueryClient`/`QueryProvider`), the toast
bridge, `AuthContext`, the middleware auth gate.
- The `App*` library: `AppButton`, `AppIconButton`, `AppAlert`, `AppIcon`, `AppImage`, `AppLink`,
`AppLoading` (+ `ErrorBoundary`, `UserInfo`); the `auth` service (`useLogin`/`useLogout`/`useCurrentUser`).
**What is demo scaffolding you will remove in this phase:**
- The `toastDemo` i18n namespace (both `en.json`/`fa.json`) and the placeholder `HomePage`
(`<Typography>Balin yaar</Typography>` with its unused `t`).
- The unregistered dead icons `AppIcon/icons/CurrencyIcon.tsx` and `YellowPlanIcon.tsx`.
- Fix the small drift noted in the audit: `AppLoading` missing from the `@/components` barrel export;
`BottomBar` reading global `location` instead of `usePathname`.
> **Auth note:** login is currently **username/password**. It becomes **phone-OTP** in **f1-b2** — don't
> wire login screens here; this phase only prepares the shells and patterns.
## 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) in full — the engineering contract (RSC boundary,
layouts, i18n, theme, cookies, fetch services, anti-patterns). Note the doc/code drift the audit found
(e.g. a `ColorSchemeScript` is referenced but doesn't exist) — trust the code, and fix the doc if you
touch that area.
- **Invoke the `frontend-designer` skill** — it is the design/brand contract (palette, tokens,
typography, the `App*` library, layout shells, the hard UI rules). All visual work goes through it.
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline:
mobile-first RTL app, deep-green brand, the **5-tab bottom nav** for the customer app
(خانه/Home · رزروها/Bookings · بیماران/Patients · کیف‌پول/Wallet · پروفایل/Profile), and the nurse-only
screens. This phase builds the *shells* those screens will live in.
- [`../../contracts/README.md`](../../contracts/README.md) +
[`conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) +
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — how you'll type and format
server data (envelope, IRR-as-string + Toman display, enums-as-codes, UTC + Shamsi display).
- The existing `src/services/auth/*` — the exact pattern (`types.ts`/`keys.ts`/`apis/clientApi.ts`/
`hooks/use*.ts`/`index.ts`) every new domain service copies.
## 3. Scope — build this
### 3.1 Clean up the demo scaffolding
Remove the `toastDemo` namespace, the placeholder home page content, the two dead icons; fix the
`AppLoading` barrel export and the `BottomBar` `usePathname` bug. Keep everything else. `npm run check`
stays green throughout.
### 3.2 The three actor app shells + routing
Balinyaar has three audiences with different chrome. Establish them now (empty/placeholder content is
fine — they fill up in later phases):
- **Customer (family) app** — mobile-first shell with the **bottom tab nav** (Home/Bookings/Patients/
Wallet/Profile) per the wireframe; this is the primary experience.
- **Nurse app** — its own shell (the wireframe's "نمای پرستار" screens: verification, dashboard, EVV).
- **Admin/backoffice** — a desktop-oriented shell (sidebar nav) for the ops console (f15).
Decide the routing that expresses these cleanly within the existing `[locale]` + route-group structure
(e.g. role-scoped route groups/segments under `(private-routes)`), **without** adding any layout above
`[locale]` and **without** breaking the server/client boundary. Drive nav by role from `AuthContext`
(roles arrive in f1-b2 — design the shell to read a role and render the right chrome; default gracefully
until roles exist). Build a **shared bottom-nav** and **shared sidebar-nav** component at the right level
in `src/layout/` / `src/components/`. Update the **Project Structure** tree in `client/CLAUDE.md` for any
new route group/folder.
### 3.3 The `services/{domain}` + Query caching pattern (the reference implementation)
Codify the data pattern every later phase copies, using the `auth` service as the template and the
checklist's caching rules:
- A `keys.ts` query-key factory per domain; deliberate `staleTime`/`gcTime`; **mutations invalidate or
`setQueryData`** so data already in cache is never needlessly refetched.
- `apis/clientApi.ts` (+ `serverApi.ts` only when an RSC needs it) wrapping `clientFetch`/`serverFetch`;
**one hook per file**.
- A documented way to derive `types.ts` from the published contract (`dev/contracts/`) — and, when a
backend phase isn't ready, a **mock `clientApi`** behind the same seam plus a row in your report (so
it's swapped cleanly once the real endpoint lands). Provide a tiny example domain (or thoroughly
document the `auth` one) so f1 starts by copying, not inventing.
- A small **money/format util** (`formatIrrToToman`, integer-safe parse of IRR strings, Shamsi date
display) in `src/utils/` per the money-and-types contract — used wherever prices/dates render.
### 3.4 Shared composite components (built once, reused everywhere)
Build the cross-cutting composite components the wireframe implies, at the shared level (`src/components/…`),
each with a co-located `*.test.tsx`, each composed from MUI/`App*` primitives (never re-implementing a
root primitive): an **OTP code input**, a **phone-number field** (Iranian format, RTL-safe), a **stepper/
progress header** (used by onboarding + verification), a **status chip** (verified/pending/…); a
**nurse/result card** and a **price-breakdown** can be stubbed here or deferred to their phases — your
call, but if you build them, build them shared. Keep page-only composition in pages.
### 3.5 i18n namespaces baseline
Establish the namespace conventions for the feature areas to come (e.g. `auth`, `onboarding`,
`verification`, `search`, `booking`, `payment`, `bnpl`, `reviews`, `notifications`, `admin`, `common`,
`nav`) — seed `common`/`nav` with the shell strings you add, in **both** `en.json` and `fa.json`, in
sync. RTL-first.
## 4. Mocks & seams in this phase
No backend dependency. Where you demonstrate the data pattern without a live endpoint, use a **mock
`clientApi`** behind the `services/{domain}` seam and note it in your report — this is the template f1+
follow until their backend phase is merged.
## 5. Critical rules you must not get wrong
- **Never add a layout above `[locale]`** and never break the RSC/client boundary (the audit shows the
current setup is load-bearing — see `client/CLAUDE.md`).
- **Design RTL-first**, `fa` default; every string in both locale files.
- **Colours from tokens**, MUI v9 API only, pre-built themes only.
- **Caching is a feature:** set `queryKey`/`staleTime` deliberately and invalidate on mutation — the
whole point of this phase is that later phases don't over-fetch or over-render.
- **MUI primitives stay MUI;** shareable composites live shared, not in a page.
- Keep `npm run check` green and translations in sync at every step.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] Demo scaffolding removed; the two barrel/`BottomBar` bugs fixed; `npm run check` green;
`npm run test:ci` green for the shared components you add.
- [ ] The three actor shells exist and render with role-aware nav (degrading gracefully before roles
exist); no layout added above `[locale]`.
- [ ] The `services/{domain}` + Query caching pattern is implemented and documented as the reference;
the money/format util exists.
- [ ] The shared composite components added each have a co-located test.
- [ ] `client/CLAUDE.md` *Project Structure* updated for new route groups/folders; any doc drift you
touched is corrected.
## 7. How to test (what a human can verify after this phase)
- `npm run dev` → the app boots; visiting the customer area shows the mobile shell with the 5-tab bottom
nav; the nurse and admin areas show their shells; switching locale flips `dir`/strings correctly; dark
mode still works.
- `npm run check` and `npm run test:ci` pass; the new shared components render and their interactions
fire callbacks in tests.
- The reference `services/{domain}` (or the documented `auth` one) shows a query caching + a mutation
invalidating it in React Query Devtools.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* (route groups, new shared components, the
services pattern note); fix any drift you touched.
- **Contracts:** none produced (frontend consumes). If the b0 swagger snapshot exists, wire the
types-from-contract step against it; otherwise document the intended step. File any envelope/format
question in `shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append to `shared-working-context/frontend/STATUS.md`; write
`reports/frontend-phase-0-report.md` (shells built, the reference data pattern, which composites are
shared, what's mocked client-side and how f1 swaps it).
- **Memory:** save a `project` memory note for the actor-shell/routing decision and the data pattern,
with a `MEMORY.md` pointer.
+342
View File
@@ -0,0 +1,342 @@
# Frontend Phase 1 — Auth: phone-OTP login & role routing
> **Mission:** make people able to actually sign in. Replace the inherited username/password stub with
> Balinyaar's real credential — **phone-OTP** — and wire the gate that every other screen sits behind.
> Build the customer login (A1), the OTP screen (A2), the family↔nurse login switch (B1/B2 entry), and
> the **role router** that reads `/me` after sign-in and sends a customer to the family app, a nurse to
> the nurse app, and a brand-new user with no role to role selection. After this phase the app has a real
> front door; f2-b3 (onboarding & profiles) and every authenticated screen build on it.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-0`](./frontend-phase-0.md) (shells, the OTP/phone
> composites, the `services/{domain}` + Query pattern, the cookie manager, `AuthContext`) · **backend
> contract** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md) (from
> backend-phase-2) · **Unlocks:** every authenticated screen; f2-b3 onboarding & profiles
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The client (`client/`, **Next.js 16 App Router + React 19 + MUI v9 + next-intl**, `fa` default & RTL) has
a full app shell, the three actor app shells from f0, and a cookie/fetch/Query/auth stack — but **no way
to log in**. Today's auth domain is a **username/password stub** (`useLogin({ username, password })`
`/auth/login`) with no UI; per the product docs, Balinyaar's only real credential is **phone-OTP**
([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md):
"Phone number is the primary login credential … Authentication is phone-OTP"). This phase swaps the stub
for the real thing and adds the role router that turns "authenticated" into "in the right app".
**What already exists (do not rebuild) — confirmed in the codebase:**
- **From [`frontend-phase-0`](./frontend-phase-0.md):** the three actor app shells + role-scoped route
groups under `(private-routes)`; the **shared OTP code input** and **Iranian phone-number field**
composites (`src/components/…` with co-located tests); the **`services/{domain}` + TanStack Query**
reference pattern (a `keys.ts` factory, `apis/clientApi.ts` over `clientFetch`, one-hook-per-file,
invalidate-on-mutation); the **types-from-contract** convention; the money/format util; the `auth`,
`common`, `nav` i18n namespaces seeded in **both** `en.json`/`fa.json`. Read the f0 report
([`reports/frontend-phase-0-report.md`](../../shared-working-context/reports/frontend-phase-0-report.md))
for exactly what shipped and how the mock-`clientApi` seam works.
- **The stack (built before f0):** root layout `src/app/[locale]/layout.tsx` (providers NextIntl → Auth →
Theme → Query → Notistack); `clientFetch`/`serverFetch` + `ApiError` (`@/lib/api`) — **401 already
clears cookies + toasts + redirects without throwing; 403/5xx toast+throw; other 4xx throw-no-toast**;
the **cookie manager** (`@/lib/cookies/client``setClientCookie`/`deleteClientCookie`, with
`AUTH_ACCESS_COOKIE_OPTIONS` ≈15 min and `AUTH_REFRESH_COOKIE_OPTIONS` ≈7 d, both **non-httpOnly** by
the current design); `AuthContext` (`useReducer` `[state, dispatch]`, actions `LOG_IN{user?}`/`LOG_OUT`,
server-seeded `initialState`); `makeQueryClient` (staleTime 30 s); the **middleware auth gate**
(`isTokenAlive` on `access_token`, redirects non-public paths to `/${locale}/login`).
- **The auth domain (the stub you are replacing):** `src/services/auth/``types.ts`
(`LoginDto {username,password}`, `AuthTokens {accessToken,refreshToken}`, `User {id,username}`),
`keys.ts` (`authKeys.currentUser()`), `apis/clientApi.ts` (`AuthClientApi.login/logout/getCurrentUser`),
`hooks/useLogin|useLogout|useCurrentUser`, `index.ts`. The login hook writes both tokens as non-httpOnly
cookies and dispatches `LOG_IN`; `useLogout` deletes both, dispatches `LOG_OUT`, redirects to
`/${locale}/login`. **There is no login page yet** — only the hooks. You extend this domain in place.
**What you are replacing / removing in this phase:**
- The username/password `LoginDto` + `useLogin({username,password})` + `POST /auth/login` path — replaced
by the OTP request/verify pair. Remove the dead `username/password` types and the `login` api/hook once
the OTP flow is wired; don't leave both (no-unused-vars is a lint **error**).
> **Wireframe reality check:** A1/A2 (customer) and B1/B2 (nurse) are the **same OTP mechanism** with
> different copy and a different post-login destination. Build **one** OTP flow and parameterise the
> intended-role/copy — do not fork two parallel login stacks.
## 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) — how you
work, the gate, the §6 contract-gap handoff, the caching/re-render rules.
- **The contract you consume:** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md)
(published by **backend-phase-2**) — the **source of truth** for the OTP/refresh/logout/me shapes, routes,
status codes, and the `Me`/roles enum. **Do not guess shapes.** Read it together with
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, `snake_case` routes — `[controller]/[action]` so `RequestOtp``.../request_otp`; 400/401/403/409
meanings; the locale header) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md)
(enums cross the wire as **stable string codes**`male`/`female`, roles as codes — and the frontend
mirrors them as string-literal unions; **never** hardcode a display label off a code, labels are i18n keys).
**If the contract isn't published yet or a needed shape is missing** (e.g. the exact `Me` role codes, the
resend-cooldown / max-attempts fields), follow operating-rules §6: append a request to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the `services/auth` seam meanwhile (§4 below).
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, the cookie rules (**auth state only
through the cookie manager — never `document.cookie`/`localStorage`**), the services pattern, anti-patterns.
- **Invoke the `frontend-designer` skill** before any visual work — A1/A2/B1 are full branded screens
(brand mark, teal `#1d4a40` primary CTA, RTL Persian, Vazirmatn/Mikhak). It is the palette/token/typography
and `App*`-library contract; all the login UI goes through it.
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — screens **A1, A2, B1, B2**
(and the role-router branch implied by A5 home vs B3 nurse status). The exact Persian strings are there:
A1 CTA **"دریافت کد تایید"**, nurse-switch link **"پرستار هستید؟ ورود پرستاران ←"**; A2 resend
**"ارسال مجدد کد تا ۰۰:۴۵"** + CTA **"تایید و ادامه"**; B1 subtitle
**"ویژه پرستاران دارای پروانه نظام پرستاری"**, CTA **"دریافت کد تایید"**; B2 CTA **"تایید و ورود"**.
- [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) —
the auth/role rules: **phone is the primary credential** (email never a login key), customer browses with
only a verified phone, **sessions are revocable with refresh-token rotation + stolen-token detection**,
admin roles are **internal-only / never self-assigned**.
- The existing `src/services/auth/*`, `src/context/auth/*`, `src/lib/cookies/*`, `src/lib/api/*`,
`src/middleware.ts` — the exact code you extend.
## 3. Scope — build this
A vertical slice: **service → hooks → screens → role router**, all over the published contract.
### 3.1 Rewrite `services/auth` for OTP (replace the username/password stub)
Keep the f0 service shape (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`);
swap its contents to OTP. **Mirror the published contract for every shape** — the names below are the
intended surface; reconcile field names/casing against `swagger.json` and file a `for-backend.md` request
for any gap.
- **`types.ts`** — replace `LoginDto` with:
- `OtpRequest``{ phone: string; intended_role?: RoleCode }` (the role the user is logging in *as*,
from the A1/B1 switch — used only for routing/copy; the server decides actual roles).
- `OtpVerify``{ phone: string; code: string }`.
- `Tokens``{ accessToken: string; refreshToken: string }` (rename/keep from `AuthTokens`; match wire casing).
- `Me` — `{ id: number; phone: string; roles: RoleCode[]; active_role?: RoleCode;
profile_completed: boolean; nurse_verification_status?: NurseVerificationStatus }` — the role-router
input. Trim/extend to **exactly** what `GET /me` returns in the contract.
- `RoleCode` — string-literal union `'customer' | 'nurse' | 'admin'` (codes, not labels).
- `NurseVerificationStatus` — string-literal union mirroring the contract's enum (e.g.
`'not_started' | 'in_progress' | 'pending_review' | 'verified' | 'rejected'`); used by the router to
decide whether a nurse lands on B3 status vs the nurse home.
- `OtpRequestResult` — whatever `request_otp` returns (e.g. `{ resend_after_seconds: number;
code_length: number; expires_in_seconds: number }`); drive the **resend countdown** and **OTP box
count** from this, not from a hardcoded `45`/`4`.
- **`keys.ts`** — `authKeys.me()` (replaces `currentUser()`); keep `authKeys.all`.
- **`apis/clientApi.ts`** — `AuthClientApi` over `clientFetch`:
- `requestOtp(body: OtpRequest): Promise<OtpRequestResult>``POST .../auth/request_otp`
- `verifyOtp(body: OtpVerify): Promise<Tokens>``POST .../auth/verify_otp`
- `refresh(refreshToken: string): Promise<Tokens>``POST .../auth/refresh`
- `logout(): Promise<void>``POST .../auth/logout`
- `getMe(): Promise<Me>``GET .../me`
(Use the **exact snake_case routes** from the contract; the examples follow the `[controller]/[action]`
transform.) Remove the old `login` call.
- **`hooks/` (one per file):**
- `useRequestOtp.ts``useMutation` over `requestOtp`. `onSuccess` hands the cooldown/length back to A2.
**Do not toast 401/403/5xx** (the fetch layer does) — only surface **domain 4xx** (e.g. 429
rate-limit "try again in N s", invalid-phone 400) as inline field/screen errors.
- `useVerifyOtp.ts``useMutation` over `verifyOtp`. `onSuccess`: **persist tokens via the cookie
manager** (`setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS`/`AUTH_REFRESH_COOKIE_OPTIONS`),
dispatch **`LOG_IN`**, **`invalidateQueries(authKeys.me())`**, then trigger the role router (3.4).
Map the contract's wrong-code / expired-code / max-attempts (lockout) failures to inline states.
- `useLogout.ts` — keep behaviour: call `logout`, `deleteClientCookie` both tokens, dispatch `LOG_OUT`,
**`invalidateQueries(authKeys.me())`** (or `queryClient.clear()` for auth keys), redirect to
`/${locale}/login`.
- `useMe.ts``useQuery(authKeys.me(), getMe)` (replaces `useCurrentUser`); `enabled` only when
authenticated; sensible `staleTime`. This is the role router's data source and the shell's role source.
- `useRefresh.ts` (DEFERRED token-rotation UI is out of scope, but) — expose `refresh` as a callable so
the **silent refresh** path can rotate the access token when it expires (see 3.5). If the existing
fetch layer is to own refresh, document that instead and don't duplicate it; flag the decision in your
report.
- **`index.ts`** — re-export the new hooks; drop `useLogin`/`useCurrentUser`.
### 3.2 A1 — customer phone login screen
The public login page (the route the middleware redirects to: `/${locale}/login`). Per A1 / the
frontend-designer skill:
- Brand mark + tagline; the **Iranian phone-number field** composite from f0 (placeholder `۰۹۱۲ ۰۰۰ ۰۰۰۰`,
RTL-safe, validates an Iranian mobile).
- Primary CTA **"دریافت کد تایید"** → `useRequestOtp({ phone, intended_role: 'customer' })`; on success,
advance to A2 (carry the masked phone + cooldown + code length).
- Secondary link **"پرستار هستید؟ ورود پرستاران ←"** → switches to the **nurse login** variant (B1).
- States: **sending** (CTA spinner/disabled), **invalid/non-Iranian number** (inline), **rate-limit hit**
("تا N ثانیه دیگر دوباره تلاش کنید"), **network error** (the fetch layer toasts; keep the field intact).
### 3.3 B1/B2 — nurse login switch (same flow, nurse intent)
Not a separate stack — the **same** login screen with `intended_role: 'nurse'`, nurse copy (B1 subtitle
**"ویژه پرستاران دارای پروانه نظام پرستاری"**, OTP CTA **"تایید و ورود"**), and a link back to family
login. Carry `intended_role` through A1→A2 (B1→B2) so the OTP screen shows the right CTA and the router
knows the entry intent. Implement the variant via a query param or a small client switch on the login
page — **no second route tree**.
### 3.4 A2 — OTP screen + auto-advance
Per A2/B2, using the **shared OTP code input** from f0:
- N-digit boxes (N from `OtpRequestResult.code_length`, default 4 per the wireframe), **masked phone echo**
("کد به شماره ۰۹۱۲•••۰۰۰۰ ارسال شد"), **resend countdown** **"ارسال مجدد کد تا ۰۰:۴۵"** driven by
`resend_after_seconds` (a single timer, cleaned up on unmount — no leaked intervals).
- CTA **"تایید و ادامه"** (customer) / **"تایید و ورود"** (nurse) → `useVerifyOtp`.
- States: **auto-advance** (verify fires automatically when the last box is filled), **wrong code**
(inline, clear boxes, keep focus), **expired code** (offer resend), **max-attempts lockout** (disable
input + show the locked message + when it lifts), **resend** (re-calls `useRequestOtp`, restarts the
countdown, disabled until the countdown hits 0).
- On verify success the hook stores tokens + dispatches `LOG_IN` + invalidates `/me`; then the **role
router** takes over (next).
### 3.5 The role router (after `/me`)
A small client component/hook (`useRoleRouter` or a `RoleRouter` boundary placed where the shell decides
chrome) that consumes `useMe()` and routes:
- **loading** (`/me` in flight) → the f0 `AppLoading` splash; never flash the wrong shell.
- **roles includes `nurse`** → the **nurse app** home; if `nurse_verification_status` is not `verified`,
land on **B3 verification status** (built in f5) — for now route to the nurse shell's home/placeholder
and leave a clear pointer (f5 fills B3). A persistent "verification in progress" banner is **f5's** job;
here just route correctly.
- **roles includes `customer`** (and not nurse, or `active_role === 'customer'`) → the **customer/family
app** home (A5, built in f4 — route to the customer shell's home/placeholder now).
- **roles empty / no usable role** → the **SelectRole** screen (3.6).
- **`active_role`** (if the contract returns one) wins when a user has multiple roles; otherwise prefer the
`intended_role` carried from login, else default customer.
- **admin** → the admin shell (f15) — route correctly but don't build admin screens here.
Honour the **middleware gate**: it already redirects unauthenticated users to `/login`; the role router
runs **after** auth and only decides *which app*. Don't duplicate the auth check in the router.
### 3.6 SelectRole screen (first use)
A minimal post-login screen for a brand-new user with **no role**: choose **خانواده (customer)** or
**پرستار (nurse)** → call the role-selection mutation (`POST /me/role`, `SelectRoleCommand` per the
digest; consume the contract's exact route/body) → `invalidateQueries(authKeys.me())` → re-run the router.
Admin is **never** selectable here (admin roles are internal-only). If `intended_role` from login is set,
pre-select it. Keep this screen shared/simple; the full onboarding (A3 "who is care for", A4 add-patient,
nurse profile bootstrap) is **f2-b3** — link it, don't build it.
### 3.7 Seed `AuthState` with roles
Extend the auth context beyond `{ id, username }`:
- `src/context/auth/types.ts` — extend `AuthState.currentUser` to carry `{ id; phone; roles: RoleCode[];
active_role?: RoleCode }` (mirror the `Me` essentials the shell needs for chrome) and keep
`isAuthenticated`. Keep the reducer's `LOG_IN{user?}`/`LOG_OUT` actions; just widen the `user` payload.
- The server-seeded `initialState` (root layout) should seed roles when it can derive them
(it reads cookies/`getServerAuthState`); if roles aren't available server-side yet, seed
`isAuthenticated` only and let `useMe()` hydrate roles client-side — **don't** put a second source of
truth for roles. Document which it is in your report.
- The f0 shells already read "a role" to pick chrome — now feed them the real `roles`/`active_role`.
### 3.8 i18n
All A1/A2/B1/B2/SelectRole/role-router strings as keys in the **`auth`** namespace (and `common`/`nav`
for shared bits) in **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first. No label is
ever derived from a role/status **code** in code — codes map to i18n keys. (DEFERRED) onboarding copy
(A3/A4) belongs to f2's `onboarding` namespace.
## 4. Mocks & seams in this phase
This phase **introduces no new external-service seam** — OTP/SMS delivery is the **backend's** seam
(`ISmsSender`, introduced in backend-phase-2; the frontend never sends SMS). The only frontend "mock" is
the standard **contract-gap fallback** from operating-rules §6 and the f0 pattern:
- **If [`identity-auth.md`](../../contracts/domains/identity-auth.md) / the b2 swagger snapshot isn't
merged when you start**, build a **mock `AuthClientApi`** behind the same `services/auth` seam (the f0
mock-`clientApi` pattern): `requestOtp` returns a fixed `{ resend_after_seconds: 45, code_length: 4,
expires_in_seconds: 120 }`; `verifyOtp` accepts a fixed dev code (e.g. `0000`) and returns fake tokens
+ a `Me` you can toggle (customer / nurse-unverified / no-role) to exercise all three router branches;
`getMe` returns the matching `Me`. Selection is by config (env/flag), **never** an `if (mock)` in a
hook or screen.
- **Record it:** append a row to
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam =
`AuthClientApi` mock + file, what's faked, the config key, exact swap steps = point the service at the
real `clientApi` once the contract is live) and a follow-up in your phase report.
- Any shape the contract doesn't cover (resend/lockout fields, `Me` role codes, `active_role`) →
**append a request** to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md); do **not** edit
backend files.
(Reuse the f0 mock-seam mechanism — don't invent a new one.)
## 5. Critical rules you must not get wrong
- **Phone is the only credential.** No username/password anywhere after this phase; email is never a login
key. Remove the stub `login` path completely (no dead code — it's a lint error).
- **No token in `localStorage`, ever.** Tokens live **only** in cookies via the cookie manager
(`@/lib/cookies/client`), non-httpOnly per the current design. Never `document.cookie`/`localStorage`
for auth. Logout must actually clear both cookies *and* the server session (call `logout`).
- **Sessions are revocable; refresh rotates.** Wire `refresh` so an expired access token is silently
rotated (or confirm the fetch layer owns this and don't duplicate it). The server does reuse-detection;
the client must send the current refresh token and replace **both** tokens on a successful refresh.
- **`invalidateQueries(authKeys.me())` on login and logout.** `/me` is the role router's input — a stale
`/me` routes the wrong person to the wrong app. Never read roles from two sources.
- **Don't toast 401/403/5xx in hooks** — the fetch layer already does. Hooks surface only **domain 4xx**
(invalid phone, rate-limit/429, wrong/expired code, lockout) as inline UI states.
- **OTP states are explicit, not edges:** sending, resend-cooldown, wrong code, expired code,
**max-attempts lockout**, auto-advance. A single timer, cleaned up on unmount (no leaked intervals →
no re-render storms).
- **The router never flashes the wrong shell.** Show `AppLoading` while `/me` is in flight; only render a
shell once the role is known. **admin role is never self-assignable** — SelectRole offers customer/nurse
only.
- **RSC/client boundary:** login screens, OTP, the router, and the cookie writes are **client**
(`'use client'`); no `next/headers`/`@/lib/cookies/server` in them. The middleware already gates routes —
don't re-implement the gate in a component.
- **Both locales, RTL-first, tokens for colour, MUI v9, MUI primitives stay MUI.** Every string in
`en.json` **and** `fa.json`; no label derived from a code.
- **Minimise re-renders:** the countdown timer and per-box OTP state stay **low** (in the OTP component),
not in a high context; `useMe` subscribers use `select` if they only need `roles`.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/auth` is OTP-based: `OtpRequest`/`OtpVerify`/`Tokens`/`Me`(+roles) types, `authKeys.me()`,
`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe` apis, and the `useRequestOtp`/`useVerifyOtp`/
`useMe`/`useLogout` hooks — the username/password stub is **removed**, not left dangling.
- [ ] A1 (customer login), A2 (OTP, auto-advance, resend countdown, lockout), and the **B1/B2 nurse
switch** render per the wireframe via the f0 phone/OTP composites, RTL, both locales, branded.
- [ ] The **role router** sends customer→family app, nurse→nurse app (B3 status if unverified),
no-role→**SelectRole**, admin→admin shell; it shows `AppLoading` during `/me` and never flashes the
wrong shell.
- [ ] Tokens are stored **only** via the cookie manager; `LOG_IN`/`LOG_OUT` dispatched; `authKeys.me()`
invalidated on login **and** logout; refresh wired (or fetch-layer ownership documented).
- [ ] `AuthState` carries `roles`/`active_role`; the f0 shells pick chrome from the real role.
- [ ] All strings in `auth`/`common`/`nav` in **both** `en.json` and `fa.json`, in sync.
- [ ] `npm run check` green; `npm run test:ci` green (you touched/added shared composites and the
router — add tests for the OTP state machine and the router branches).
- [ ] `client/CLAUDE.md` updated: the OTP login flow, the role-router seam, and the widened `AuthState`
noted in *Project Structure* / the auth notes; the now-stale username/password references corrected.
## 7. How to test (what a human can verify after this phase)
With the b2 backend running (or the mock seam configured), `npm run dev`:
- **Customer happy path:** open `/fa/login` → enter a valid mobile → "دریافت کد تایید" → A2 shows the
masked phone + a counting-down resend → enter the code (auto-advance fires verify) → tokens appear in
cookies (DevTools → Application → Cookies; **nothing** in localStorage) → redirected to the **customer**
app home. `/me` is in the Query cache (React Query Devtools).
- **Nurse switch:** from A1 tap "پرستار هستید؟ ورود پرستاران ←" → B1 nurse copy/subtitle → request →
verify ("تایید و ورود") → routed to the **nurse** app (B3 status placeholder if unverified).
- **No-role user:** verify as a user whose `/me` has empty `roles` → lands on **SelectRole** → choose a
role → re-routed into that app.
- **OTP edge states:** wrong code → inline error + boxes clear; let the code expire → resend re-enabled by
the countdown; trigger max attempts → input locks with a clear message.
- **Session:** log out → both cookies cleared, `/me` invalidated, redirected to `/login`; let the access
token expire and act → silent refresh rotates it (or you're sent to `/login` cleanly).
- **i18n/RTL:** switch to `en` → all auth strings translate, layout stays correct; `fa` is RTL.
- `npm run check` and `npm run test:ci` pass (OTP state-machine + router-branch tests included).
## 8. Hand off & document (close the phase)
- **Docs:** update [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the phone-OTP flow, the `useRoleRouter`
seam, the widened `AuthState` (roles/active_role), and the new auth screens/route; **fix the stale
username/password references**. If you add a route group/folder, update *Project Structure* in the same
change. No `product/` rule changes expected (you're implementing decided rules); if you discover drift,
record it there.
- **Contract:** **consume** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md)
— types come from it, not guesses. Append every gap (resend/lockout fields, exact `Me` role codes,
`active_role`, role-selection route/body) to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
Frontend produces **no** contract.
- **Handoff & report:** append to
[`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`reports/frontend-phase-1-report.md`](../../shared-working-context/reports/frontend-phase-1-report.md)
(what was built, **what is now testable and exactly how**, what's mocked behind the `AuthClientApi` seam
+ how f-next swaps it, the contract consumed + gaps filed, follow-ups: B3 banner is f5, A3/A4 onboarding
is f2, admin screens are f15). Update
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) for the
`AuthClientApi` mock if you used it.
- **Memory:** save a `project` memory note for the phone-OTP decision, the role-router seam + branch logic,
and the widened `AuthState`, with a `MEMORY.md` pointer — what a future agent can't cheaply re-derive.
@@ -0,0 +1,279 @@
# 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 (~710
> 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 ~710 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 **~710 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` 710-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 710-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 **~710 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 ~710-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 **~710 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.
@@ -0,0 +1,331 @@
# Frontend Phase 11 — BNPL checkout (installments)
> **Mission:** give the family an alternative to full-card payment at checkout — pay a booking **in
> installments** through a provider (دیجی‌پی / اسنپ‌پی / اقساط بالین‌یار). Build the five BNPL screens
> from the wireframe (D1 method → D2 plan → D3 eligibility → D4 contract/schedule → D5 wallet status),
> wired to the b12 BNPL endpoints, styled in the **financial terracotta** language the design system
> reserves for money/installments. The load-bearing product truth you must encode in the UI: **the
> installment repayment is owned by the provider, not Balinyaar** — the provider pays Balinyaar the full
> amount up-front and bears 100% of customer-default risk, so Balinyaar *shows* the installment status
> it is told about; it does **not** run the schedule. D5 is provider-reported status, never a
> Balinyaar-managed ledger.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md) (checkout
> & payment) + the **b12 BNPL contract** ([`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md)) ·
> **Unlocks:** nothing downstream depends on it (BNPL is an alternate checkout branch)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the **alternate-checkout branch** of the customer money path. By f9 a family can already see the
price breakdown (C6 خلاصه و پرداخت) and pay a confirmed booking by card. This phase adds the second exit
off C6: instead of paying the full amount on a card, the family chooses **اقساط (installments)** and is
taken through a provider's BNPL flow — pick a provider, pick a plan, pass a credit check, accept a
repayment schedule, pay the down-payment — after which the **booking confirms exactly as the card path
confirms it** (the provider has paid Balinyaar in full). The family then tracks repayment from the
**کیف‌پول (Wallet)** tab.
The single rule that shapes every screen: in Balinyaar's books a BNPL order is **identical to a card
payment that lands net-of-fee in one inbound settlement** ([product/business/09](../../../product/business/09-installments-bnpl.md)).
The customer's 4-installment (or 3/6/12-month) repayment is **decoupled** from Balinyaar's escrow/EVV/payout
cycle. So the Wallet screen (D5) renders a **provider-reported** balance and due-date list — not a ledger
Balinyaar owns or can settle. The copy must make that ownership clear without scaring the user.
**What already exists (do not rebuild) — from prior phases:**
- **Foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the three actor shells, the
**customer 5-tab bottom nav** (خانه · رزروها · بیماران · **کیف‌پول/Wallet** · پروفایل), the
`services/{domain}` + TanStack Query pattern (a `keys.ts` factory, `apis/clientApi.ts`,
one-hook-per-file, mutation-invalidates-cache), the **money/format util** (`formatIrrToToman`,
integer-safe IRR parse, Shamsi date display) in `src/utils/`, the shared composites (stepper/progress
header, status chip, price-breakdown), the i18n namespaces — including a reserved **`bnpl`** namespace
— and the RTL baseline. Reuse all of it; do not re-derive the data pattern.
- **Checkout & payment** ([`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md)): the C6 summary-&-pay
screen with the commission/tax breakdown and the **escrow notice**, the card-payment redirect flow, the
confirmation screen, and the booking-confirmed state. This phase **adds a branch** to C6's payment-method
step and **reuses C6's confirmation/booking-confirmed UI** once the down-payment clears — it does not
build a second confirmation.
- **Refund & cancellation** ([`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)): the cancellation
flow and the customer refund-status view, including the **BNPL revert ETA** copy ("~710 business days,
provider-owned"). When a BNPL booking is cancelled, the refund status surface from f10 is what the user
sees — **do not** build a BNPL-specific refund screen here; that path belongs to f10. This phase owns only
the *forward* checkout (D1D4) and the *status* view (D5).
> **Branch note:** BNPL is gated to bookings above a configurable threshold (e.g. total/duration) — a
> **config flag**, not a feature, per [scope notes](../README.md#scope-notes--deferrals). If the contract
> exposes an `bnplEligible` flag on the booking/checkout summary, hide the "اقساط" option when it's false;
> otherwise show it always and let D3 eligibility be the gate. Do not invent a client-side threshold rule.
## 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, cookies, the fetch services, anti-patterns). You add a `services/bnpl` domain and screens
under the customer shell; nothing above `[locale]`.
- **Invoke the `frontend-designer` skill before any visual work.** It is the design/brand contract. For
this phase it carries the rule that matters most: **money/installment surfaces use the terracotta
financial accent** (`--bal-terracotta` `#d98c6a`, the wireframe's "installments/financial" legend
colour) — D1D5 are terracotta-accented, distinct from the teal of the core booking flow. Ask it for the
installment-plan card, the eligibility-result panel, the repayment-schedule table, and the
outstanding-balance Wallet card.
- **Product truth:**
- [`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md) — the
full-upfront / provider-bears-risk / decoupled-repayment model. This is *why* D5 is provider-reported.
- [`product/payments/bnpl-landscape.md`](../../../product/payments/bnpl-landscape.md) — the provider
comparison: SnappPay (4 interest-free), Digipay (3/6/12 + 4-installment), Torob Pay (25% down, 6.6%),
Balinyaar in-house plan. Use these for the **provider/plan copy and fee/down-payment shapes** D1/D2 show.
Note: settlement timing is **not instant** and commission is **per-contract** — never hardcode a fee in
the client; render whatever the contract returns.
- **Wireframe:** [`product/wireframes/index.html`](../../../product/wireframes/index.html), **Section D
(D1D5)** — the exact screens, RTL Persian, terracotta financial accent. D1 روش پرداخت, D2 انتخاب طرح
اقساط, D3 اعتبارسنجی, D4 تایید طرح و قرارداد, D5 پیگیری اقساط (in Wallet). D5 carries the bottom tab nav
(Wallet active); D1D4 are mid-flow (no tab bar).
- **Contract to consume:** [`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md) (from b12) —
the request/response shapes, routes, the BNPL `status` enum codes, and the eligibility result shape. Plus
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) (IRR-as-string,
Toman display, enums-as-codes, UTC + Shamsi) and [`api-conventions.md`](../../contracts/conventions/api-conventions.md)
(the envelope). **Types come from the contract — do not guess shapes.**
- The existing `src/services/payment/*` from f9 (the checkout service) and `src/services/auth/*` — the
exact service pattern (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`) your
new `services/bnpl` copies; and the C6 screen you branch from.
## 3. Scope — build this
A new **`services/bnpl`** domain + the five wireframe screens, all under the **customer** shell, all
terracotta-financial. Build the forward checkout (D1D4) and the Wallet status view (D5).
### 3.1 `services/bnpl` (the data layer — copy the f0 pattern)
Under `src/services/bnpl/`:
- **`types.ts`** — derived from [`bnpl.md`](../../contracts/domains/bnpl.md). At minimum:
`BnplProvider` (`providerCode` e.g. `digipay` / `snapppay` / `balinyaar`, display name, supported plans),
`BnplPlanOption` (`termMonths` 3/6/12 or `installmentCount` 4, `feePercent`, `downPaymentPercent`,
`monthlyAmountIrr`, `totalIrr` — all IRR amounts as **string**), `BnplEligibilityResult`
(`eligibilityStatus`: `eligible` / `not_eligible` / `ceiling_exceeded`, `creditCeilingIrr`),
`BnplSchedule` (`downPaymentIrr`, `dueDate` + `amountIrr` per installment), `BnplTransaction`
(`status` state-machine code: `eligible` / `token_issued` / `verified` / `settled` / `reverted` /
`cancelled` / `failed`, `installmentCount`, `outstandingBalanceIrr`, `installments[]` with per-row
`status` and `dueDate`). **All money is the IRR-string type from the money-and-types contract.**
- **`keys.ts`** — a query-key factory: `bnplKeys.providers(bookingId)`,
`bnplKeys.eligibility(bookingId)`, `bnplKeys.transaction(bookingId)` / `bnplKeys.walletStatus()`.
- **`apis/clientApi.ts`** wrapping `clientFetch``getBnplOptions(bookingId)`,
`checkEligibility({ bookingId, nationalId, mobile, consent })`, `issueToken({ bookingId, providerCode,
planSelection })`, `acceptSchedule({ bookingId, ... })` / pay-down-payment, `getBnplTransaction(bookingId)`,
and `getWalletInstallments()`. Map these to the **b12 routes** from the contract
(`POST /checkout/bnpl/eligibility`, `POST /checkout/bnpl/token`, the provider-handoff/verify return, and
the read endpoints). If a route or shape is missing from the contract, see §4.
- **`hooks/`** — one hook per file: `useBnplOptions` (query), `useCheckEligibility` (mutation),
`useIssueBnplToken` (mutation), `useAcceptBnplSchedule` (mutation, **invalidates the booking + checkout
query** so the confirmed booking is not refetched stale), `useBnplTransaction` (query),
`useWalletInstallments` (query). Deliberate `staleTime` (eligibility/options are short-lived;
wallet-status is moderate). Don't toast 401/403/5xx — only domain 4xx (ineligible, ceiling-exceeded,
token-expired) get a message.
- **`index.ts`** barrel.
### 3.2 D1 · روش پرداخت (Payment method) — the branch off C6
The payment-method chooser the family reaches from **C6 (Summary & pay)**. Shows the **payable amount**
(reuse the f0 price-breakdown / money util — Toman display), then the method options:
- **پرداخت کامل با کارت (full card)** — selecting it returns to / continues the **f9 card flow** (do not
rebuild it).
- **Installment providers** (terracotta-accented option cards, each with provider branding/label and a
one-line plan summary): **دیجی‌پی** (312 installments), **اسنپ‌پی** (۴ قسط بدون بهره / 4 interest-free),
**اقساط بالین‌یار** (in-house plan). Render the provider list **from `useBnplOptions`** — do not hardcode
the provider set or fees; the contract is the source. Primary action: "ادامه با {provider}".
States: options-loading (skeleton), loaded, empty/none-eligible (only card shown), error (retry / fall back
to card). If the booking is not BNPL-eligible (§1 branch note), the installment options are hidden and only
card shows.
### 3.3 D2 · انتخاب طرح اقساط (Choose plan)
For the chosen provider, the plan selector. Shows the **total amount** and the plan options the contract
returned — e.g. **۳ ماهه (بدون کارمزد)**, **۶ ماهه (کارمزد ۴٪)**, **۱۲ ماهه (کارمزد ۹٪)** — each rendering
its **monthly amount** and (where present) **down-payment %** (پیش‌پرداخت ۲۰٪). A single-select plan card
group (terracotta), plus a down-payment indicator. **Every amount comes through the money util from the
contract's IRR strings** — the client computes nothing about money beyond formatting; if the contract gives
per-plan `monthlyAmountIrr` use it, otherwise show only what the contract provides. Primary action: "ادامه".
States: loading, loaded, none (no plans for this provider → back to D1).
### 3.4 D3 · اعتبارسنجی (Credit eligibility)
The provider credit check. Fields: **کد ملی (national ID)**, **موبایل (mobile, prefilled from the session)**,
and a **consent checkbox** — "با استعلام اعتبارسنجی … موافقم" — which **gates** the submit (no consent →
disabled). On submit, call `useCheckEligibility`. Result panel:
- **approved** → "اعتبار شما تایید شد" + the returned **credit ceiling** (سقف اعتبار, money util). Action:
"تایید و ادامه" → D4.
- **not_eligible / ceiling_exceeded** → a clear declined panel with a **"پرداخت با کارت" fall-back** to the
f9 card flow. (Ceiling-exceeded copy: the booking total exceeds the available credit.)
- **error/timeout** → retry or fall back to card.
National-ID input validation is client-side format only (10 digits) — the real check is the provider's;
surface its result, don't pre-judge. Reuse the f0 phone-field for the mobile display.
### 3.5 D4 · تایید طرح و قرارداد (Schedule & contract)
The repayment schedule + contract acceptance. Renders the **repayment table** from the contract's schedule:
a **پیش‌پرداخت (down-payment) — today** row, then **قسط ۱…N** rows each with a **Shamsi due date** and an
**amount** (money util). A **terms/contract acceptance checkbox** that **gates** the final action. Primary
action: **"تایید نهایی و پرداخت پیش‌پرداخت"** → `useIssueBnplToken` / `useAcceptBnplSchedule`, which performs
the **provider handoff** (redirect or in-app token state — follow whatever the contract specifies, mirroring
f9's card redirect handling) and pays the down-payment. On success: **the booking confirms** — route to the
**f9 confirmation / booking-confirmed UI** (reused, not rebuilt), now reflecting "paid via installments".
States: schedule-loading, handoff/redirect in-progress (spinner + "در حال انتقال به {provider}"), success
(→ confirmation), declined/expired-token (→ retry or card).
The contract-acceptance copy must reflect the ownership truth: **the installment agreement is between the
customer and the provider** (provider-financed, provider bears risk); Balinyaar is the merchant being paid
in full. Get this exact copy from the frontend-designer skill / product docs, in both locales.
### 3.6 D5 · پیگیری اقساط (Installment status — in Wallet)
The Wallet-tab view of an active installment plan (bottom tab nav, **Wallet active**). It reads
`useWalletInstallments` and renders a **provider-reported** status:
- **Outstanding-balance card** (مانده بدهی, money util) — terracotta.
- **Next-installment** date + an **"پرداخت زودهنگام" (early pay)** affordance — but **early-pay is a
provider action, not a Balinyaar transaction**: link out / hand off to the provider; do **not** build a
Balinyaar payment for it.
- **Due-date list** — one row per installment with a **status chip** (reuse the f0 status chip):
پرداخت‌شده (paid) / سررسید نزدیک (due soon) / آینده (future), each with a Shamsi due date and amount.
- A short **"وضعیت اقساط نزد {provider} ثبت می‌شود" / provider-owned** note so the user understands
Balinyaar is displaying, not managing, this schedule.
States: no-active-plan (empty Wallet installments section), loading, error (provider status unavailable →
"وضعیت اقساط در دسترس نیست"). If the f12 nurse-earnings Wallet content also lands here later, keep D5 a
self-contained section under the Wallet route.
### 3.7 i18n & tokens
Every user-visible string is a key in the **`bnpl`** namespace (seeded in f0) in **both** `messages/en.json`
and `messages/fa.json`, in sync, **`fa` default & RTL-first**. Provider names render from the contract but
their surrounding copy is i18n. Colours from `tokens.css` (the terracotta financial accent via the
designer's tokens) — never hardcoded hex in `sx`.
### Out of scope (DEFERRED — do not build here)
- **BNPL refund / revert UI** — the cancellation + refund-status surface (incl. the BNPL "~710 business
days, provider-owned" ETA) is **f10** ([`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)). Don't
duplicate it.
- **Admin BNPL revert/cancel console** — admin-side BNPL ops live in **f15**
([`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md)).
- **Multiple-provider routing / tranched settlement** — b12 ships one provider mock; treat the provider
list as data, but don't build provider-comparison or multi-provider reconciliation UI.
- **Customer per-installment webhook / default handling** — there is none on Balinyaar's side; the provider
owns it (D5 is read-only status).
## 4. Mocks & seams in this phase
No new client seam family is *introduced* here — you **reuse the `services/{domain}` seam pattern from
[`frontend-phase-0.md`](./frontend-phase-0.md)**. The BNPL provider integration itself is mocked **on the
backend** behind `IBnplProvider` (b12) — the frontend just consumes the b12 endpoints.
- **If the b12 contract is published and live:** wire `services/bnpl/apis/clientApi.ts` to the real routes;
no client mock needed.
- **If b12 is not yet merged when you run:** build a **mock `clientApi`** behind the same `services/bnpl`
seam (per operating-rules §6) that returns deterministic shapes — a provider list, an always-`eligible`
eligibility result with a sample ceiling, a sample 6-month plan + schedule, and a sample D5 status with a
paid/due/future mix — so D1D5 are fully exercisable. Record the mock in your report and as a row in
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md); it is swapped for
the real `clientApi` (one file) once b12 lands.
- **Contract gaps:** any shape the contract doesn't provide that D1D5 need (e.g. per-plan `monthlyAmountIrr`,
the schedule rows, the D5 provider-reported `installments[]`, a `bnplEligible` flag on the booking) →
**append a request to**
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the seam meanwhile. Never edit backend files.
## 5. Critical rules you must not get wrong
- **The installment repayment is OWNED BY THE PROVIDER. Balinyaar shows status; it does not run the
schedule.** D5 renders **provider-reported** balance/due-dates/status — it is **not** a Balinyaar-managed
ledger and Balinyaar never settles a customer installment. "Early pay" hands off to the provider.
- **The provider pays Balinyaar the full amount up-front and bears 100% of customer-default risk** — the
contract-acceptance copy (D4) and the D5 ownership note must reflect that the installment agreement is
**customer ↔ provider**, interest-free-to-customer, provider-financed. Do not imply Balinyaar lends.
- **Money correctness (verbatim, the payments-track invariants that bind the client):** all internal money
is **IRR `BIGINT`, no floats anywhere** — the client receives IRR as **strings** and **must use the money
util** (`formatIrrToToman` / integer-safe parse) for every amount; never `Number()`-coerce an IRR string
or do arithmetic on money in the client. The split is **gross = commission + payout**; the **ledger is
append-only and balanced**; settlement is reconciled via **webhook idempotency**; payout is **one per
booking** and **dispute-window-gated**. The client never computes commission, fee, ceiling, or schedule
amounts — it **renders whatever the contract returns** (commission and BNPL fee are per-contract config,
never hardcoded in the UI).
- **A confirmed BNPL booking is, to Balinyaar, a card payment that landed net-of-fee** — after the
down-payment clears, **reuse the f9 booking-confirmed/confirmation UI**; do not build a parallel
confirmation, and do not show the customer a Balinyaar-side installment ledger.
- **Eligibility & consent are gates.** D3 submit is disabled without the consent checkbox; D4 final action is
disabled without contract acceptance. Decline / ceiling-exceeded must offer the **card fall-back** (f9),
never a dead end.
- **RSC/client boundary** — payment interactions are client components; don't pull `next-intl/server` or
server cookies into them. **Caching:** set `queryKey`/`staleTime` deliberately; the
accept-schedule mutation **invalidates the booking/checkout queries** so the confirmed booking isn't
refetched stale and the wallet status reflects the new plan.
- **RTL-first, `fa` default, both locales in sync; terracotta financial accent from tokens** (not teal, not
hardcoded hex); MUI **v9** primitives reused (no re-implemented Button/Card); shareable composites at the
shared level. Keep `npm run check` green throughout.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/bnpl` exists following the f0 pattern (types from the b12 contract, `keys.ts`, `clientApi`,
one-hook-per-file, deliberate caching + mutation invalidation).
- [ ] **D1D5** are built under the customer shell, terracotta-financial (frontend-designer-driven), with
all states (loading / loaded / empty / declined / error) handled; D5 lives under the **Wallet** tab.
- [ ] The **branch off C6** works: choosing اقساط leads into D1D4 and, on a cleared down-payment, **routes
to the reused f9 confirmation with the booking confirmed**; the card fall-back is reachable from
decline/ceiling-exceeded/error.
- [ ] D5 is presented as **provider-reported status** with the ownership note; early-pay hands off to the
provider; no Balinyaar-side installment ledger is implied anywhere.
- [ ] Every money value renders through the money util; nothing about money is computed client-side; no
hardcoded fee/ceiling/provider set.
- [ ] `bnpl` strings in **both** `en.json`/`fa.json`, in sync, RTL-correct; colours from tokens.
- [ ] `npm run check` green; `npm run test:ci` green for any shared composite you add/touch (e.g. a
reusable installment-schedule row/card or plan-option card gets a co-located `*.test.tsx`).
- [ ] `client/CLAUDE.md` *Project Structure* updated if you add a route group/folder for the BNPL screens
or the `services/bnpl` domain.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev`, sign in as a customer, reach a **confirmed** booking's checkout (C6) — using the f9
flow (or the seam mock if b12 isn't merged):
1. On **C6**, choose **اقساط****D1** shows the payable amount + provider options (دیجی‌پی / اسنپ‌پی /
اقساط بالین‌یار) loaded from the contract/mock, terracotta-accented. Pick a provider → "ادامه با …".
2. **D2** shows the plan options (e.g. ۳/۶/۱۲ ماهه) with monthly amount + down-payment for the chosen
provider; select a plan → "ادامه". Verify amounts render in Toman via the money util.
3. **D3** — enter کد ملی, confirm the prefilled mobile, **tick consent** (submit stays disabled until you
do), submit → **mock approves** with a credit ceiling → "تایید و ادامه". Then verify the **declined**
path (mock a `not_eligible`/`ceiling_exceeded`) shows the panel **and the card fall-back**.
4. **D4** — the repayment table shows پیش‌پرداخت (today) + قسط rows with **Shamsi due dates** and amounts;
**accept the contract** (final action disabled until ticked) → "تایید نهایی و پرداخت پیش‌پرداخت" →
provider handoff → **the booking confirms** and you land on the **f9 confirmation** marked as paid via
installments.
5. Open the **کیف‌پول (Wallet)** tab → **D5** shows the **provider-reported** outstanding balance, next due
date, the per-installment due list with status chips (پرداخت‌شده / سررسید نزدیک / آینده), the ownership
note, and an early-pay hand-off (not a Balinyaar payment).
6. Switch locale to `en` → all BNPL strings translate, layout flips correctly (RTL ⇄ LTR); dark mode intact.
7. `npm run check` and (if a shared composite changed) `npm run test:ci` pass.
This becomes the "what can be tested" section of your report.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* for the BNPL route(s) and the `services/bnpl`
domain; if you establish a reusable installment-schedule/plan-option composite, note it. If you discover a
BNPL business-rule gap while building (e.g. the eligibility/ceiling copy, the threshold flag), record the
decision in [`product/business/09-installments-bnpl.md`](../../../product/business/09-installments-bnpl.md)
— don't invent rules; flag uncertain ones in your report.
- **Contract:** **consume** [`dev/contracts/domains/bnpl.md`](../../contracts/domains/bnpl.md) (b12) for all
types/routes — do **not** guess shapes. Any gap (per-plan monthly amount, schedule rows, D5
`installments[]`, a `bnplEligible` flag) goes to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
- **Handoff & report:** append your phase summary to
[`shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
`reports/frontend-phase-11-report.md` (the D1D5 screens + `services/bnpl` built; **what is now testable
and exactly how** — the C6→D1→…→D4→confirm→D5 walkthrough; what is mocked behind the seam and how it
swaps to the real b12 `clientApi`; the contract consumed + any gaps filed; follow-ups for f15 admin BNPL).
Add/refresh the BNPL row in
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) if you mocked the
client seam.
- **Memory:** save a `project` memory note for the non-obvious decision that **D5 is provider-reported,
not a Balinyaar-managed ledger**, and that BNPL confirmation **reuses the f9 confirmation** — with a
`MEMORY.md` pointer, so a future agent doesn't build a Balinyaar installment ledger or a duplicate
confirmation screen.
@@ -0,0 +1,334 @@
# Frontend Phase 12 — Nurse earnings & payout history
> **Mission:** give a nurse a clear, trustworthy view of the money they have earned and when it
> arrives. Build the **nurse earnings** screen that distinguishes the four money states a nurse cares
> about — **pending** (still in escrow, dispute window open), **eligible** (cleared, awaiting the weekly
> batch), **paid** (transferred, with a `transfer_reference` and `paid_at`), and **clawback-applied**
> (a refunded-after-payout amount netted out of the total) — plus the **payout history** list and a
> **batch detail** view. Money is read-only here (nurses don't trigger transfers); the job is to render
> the ledger-derived numbers correctly, explain the weekly cadence and the dispute-window gate in plain
> Persian, and never confuse "earned" with "paid".
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) (booking
> detail · sessions · EVV completed-work view) + the **b13** payouts contract
> ([`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md)) · **Unlocks:**
> (last money-path frontend phase; the nurse earnings surface other phases link into)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **last money-path frontend phase**. The customer-side money flows are already built
(checkout/payment in f9, refund/cancellation in f10, BNPL in f11); this phase finally closes the loop
on the **nurse** side — *"I did the work, where is my money?"*. A nurse completes visits (EVV check-out
in f8), the booking enters a **72h dispute window**, then the amount becomes eligible for the **weekly
payout batch**, which an admin runs (b13). Once the batch processes, the nurse sees the transfer
landed against their verified primary IBAN. This phase renders all of that as a read-only nurse view —
no nurse ever initiates a transfer, retries a payout, or runs a batch (those are admin actions in
b13 / f15).
**What already exists (do not rebuild) — link the prior phases:**
- **Foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the **nurse app shell** and its
route group/segment, role-aware nav from `AuthContext`, the `services/{domain}` + TanStack Query
caching pattern (copy the `auth` service shape), the contracts→`types.ts` step, the shared composite
components (status chip, card, stepper/progress header), the **money/format util** in `src/utils/`
(`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display), and the i18n namespace
conventions. **Reuse the money util and the status chip — do not re-implement them.**
- **Booking detail · sessions · EVV** ([`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md)): the
nurse's view of completed visits, the per-session schedule, the EVV check-in/out flow, and the booking
status timeline. Earnings rows **link back to** these booking/session screens; do not duplicate the
booking detail here — deep-link to it.
- **Checkout & refund money UI** ([`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md),
[`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md)): the price-breakdown / money-display
conventions on the customer side. Match the same Toman-display + IRR-string handling; **reuse the same
money util**, don't fork a second formatter.
- **The contract** ([`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md), produced by
**b13**): the exact request/response shapes, routes, status codes, and enum codes for the nurse
earnings & payout endpoints. This is the **source of truth for types** — do not guess shapes.
> **Read note:** the file [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) is the prior nurse
> phase you build on; if it is not yet on disk when you run, rely on its handoff
> (`dev/shared-working-context/reports/frontend-phase-8-report.md`) and the nurse-shell facts from f0.
## 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) — how
you work and the tick-list this phase is graded against.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client
boundary, the `services/{domain}` shape, TanStack Query caching/invalidation, i18n in both locales,
tokens-based colours, RTL, the `App*` library). Non-negotiable.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(palette, tokens, typography, the `App*` library, status-chip styling, the money-display look,
empty/loading/error treatments, RTL mirroring). **All UI in this phase goes through it.** The four
earnings states must be visually distinct and instantly readable; the designer skill owns how.
- **Business truth — read before designing anything:**
- [`../../../product/business/10-payouts.md`](../../../product/business/10-payouts.md) — the weekly
batch model, the **EVV + dispute-window eligibility gate**, the **payout amount =
`gross_price_irr balinyaar_commission_irr`** rule, clawback netting (`gross_earnings`,
`clawback_applied`, `net_amount`), one-payout-per-booking, holiday-aware scheduling, verified-IBAN
payout with `transfer_reference`. This is *why* each state exists.
- [`../../../product/payments/cancellation-and-payout.md`](../../../product/payments/cancellation-and-payout.md)
— Q2 ("who pays the nurse, and when"): the nurse is paid by **Balinyaar** on the **normal weekly
schedule after the dispute window closes**, the **same amount whether the family paid by card or
BNPL** (the BNPL provider commission is a Balinyaar expense, **never** deducted from the nurse). The
worked example (gross 5,000,000 → nurse 4,250,000) is the copy you explain to the nurse.
- **The contract you consume:** [`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md)
— the b13 nurse-read endpoints and shapes. Also read the cross-cutting conventions:
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
(envelope, snake_case routes, pagination `page`/`page_size`, status codes) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md)
(**IRR as integer string on the wire**, parse with integer-safe helpers, Toman is display-only, UTC
timestamps → Shamsi on the client, enums as stable string codes).
- **Code to mirror:** `client/src/services/auth/*` (the canonical `types.ts`/`keys.ts`/
`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts` shape every new domain copies) and the f0 money/format
util + status-chip component. The nurse earnings list pattern (paginated, status-filtered) mirrors the
nurse booking list from f8.
## 3. Scope — build this
Build the **`payouts` domain service** (nurse read) and the **two nurse screens** it feeds. Everything
admin-side (create/process/retry a batch, the clawback write-off queue, eligible-earnings preview) is
**(DEFERRED)** to the admin console — see §3.5.
### 3.1 `services/payouts` domain (nurse read)
Copy the `auth` service shape into `client/src/services/payouts/`:
- **`types.ts`** — string-literal union types and response shapes **derived from
[`payouts.md`](../../contracts/domains/payouts.md)** (not guessed). Expect at least:
- an **earnings-summary** shape — the four-bucket roll-up: `pending_total_irr`, `eligible_total_irr`,
`paid_total_irr`, `clawback_outstanding_irr`, and a derived **payable/net balance** (which **may be
negative** when clawbacks exceed earnings — model it as a signed string, never clamp to zero).
- an **earnings-item** shape — one row per completed booking/session contributing to earnings:
`booking_id` (+ enough to deep-link), `gross_price_irr`, `balinyaar_commission_irr`,
`nurse_payout_amount` (= gross commission), an **earnings state** enum
(`pending` | `eligible` | `paid` | `clawback_applied`), `dispute_window_ends_at` (for the
pending countdown/explanation), `payout_eligible_at`, and — when paid — `paid_at` +
`transfer_reference` + `nurse_payout_id` / `batch_id`.
- a **payout (history) item** shape — one row per `nurse_payouts`: `id`, `batch_id`,
`gross_earnings_irr`, `clawback_applied_irr`, `net_amount_irr` (= gross clawback), `amount`
(actually transferred), `iban_snapshot` (masked, last-4 only), `transfer_reference`, a **payout
status** enum (`pending` | `processing` | `paid` | `failed`), `paid_at`, `failure_reason`.
- a **batch detail** shape — the `nurse_payout_batches` context for one payout: `period_start`/
`period_end` (holiday-shifted), `status`, `total_amount`, `payout_count`, `processed_at`, and the
list of **booking links** (`nurse_payout_booking_links`) so the nurse sees exactly which bookings a
payout covered.
- paginated list envelopes (`items` + `total` + `page`/`page_size`) per the api-conventions.
- **Use string-literal unions for every enum**; never hardcode a display label off a code — labels are
i18n keys.
- **`keys.ts`** — a query-key factory: `payoutKeys.all`, `payoutKeys.earningsSummary()`,
`payoutKeys.earningsList(filters)` (key includes the state filter + page so each filter caches
separately), `payoutKeys.history(page)`, `payoutKeys.batchDetail(payoutId|batchId)`.
- **`apis/clientApi.ts`** — a `PayoutsClientApi` namespace wrapping `clientFetch` for the nurse-read
routes (exact paths from the contract; expected from b13):
- `GET .../get_nurse_earnings_balance` → earnings summary (the four buckets + net balance).
- `GET .../get_nurse_earnings?state=&page=&page_size=` → paginated earnings items, filterable by state.
- `GET .../get_nurse_payout_history?page=&page_size=` → paginated payout history.
- `GET .../get_nurse_payout/{id}` (or batch detail) → one payout + its batch context + booking links.
- (Routes are `snake_case`; derive the exact segments from the published contract — don't assume.)
- Add a `serverApi.ts` **only** if an RSC prefetches the summary for first-paint (optional; see 3.4).
- **`hooks/`** — one hook per file: `useNurseEarningsBalance.ts` (`useQuery`),
`useNurseEarnings.ts` (`useQuery`, takes the state filter + page), `useNursePayoutHistory.ts`
(`useQuery`, paged), `useNursePayoutDetail.ts` (`useQuery` by id). All **read-only `useQuery`** — there
are **no mutations** in this phase (nurse never writes payout state). Set a deliberate `staleTime`
(earnings move slowly — a generous `staleTime`, e.g. minutes, avoids needless refetch).
- **`index.ts`** — re-export the **hooks only** (not `types`/`keys`/`apis`), per the client barrel rule.
### 3.2 Nurse earnings screen
The nurse's money home, in the nurse shell. Composition:
- A **balance header** (the f0 money util formats every amount; Toman display, Shamsi where dates show):
the **net payable balance** prominently, with the four-bucket breakdown beneath —
**pending / eligible / paid (lifetime) / clawback outstanding**. When the net balance is **negative**
(clawbacks exceed earnings), show an explicit **"owed back" state** (don't render a bare minus sign as
if it were a positive amount) — the designer skill owns the visual.
- A short, plain-Persian **explainer** of the cadence and gate (an info callout / collapsible "how
payouts work"): *paid in weekly batches; an amount becomes eligible only after the visit is verified
and the 72-hour dispute window closes; the same amount whether the family paid by card or installments.*
This copy comes straight from `product/business/10-payouts.md` + `cancellation-and-payout.md` — both
i18n keys, both locales.
- A **state-segmented earnings list** (tabs/segmented control filtering by earnings state →
`useNurseEarnings(state, page)`), each item a **shared earnings-row component** showing the booking
reference, the **three-amount breakdown** (gross / commission / nurse payout) via the price-breakdown
primitive, the **earnings-state chip** (reuse the f0 status chip), and the state-specific affordance:
- **pending** → "in escrow · dispute window open" with the time-to-eligible derived from
`dispute_window_ends_at` (display-only; the *server* decides eligibility — never compute eligibility
client-side, only render the countdown).
- **eligible** → "cleared · awaiting the next weekly batch" (+ an estimated window if the contract
supplies one; otherwise generic copy — never invent a date).
- **paid** → "paid" with `paid_at` (Shamsi) + the `transfer_reference`; links to the payout detail.
- **clawback_applied** → a **net explanation**: the original earned amount, the clawback amount, and
the resulting net — so the nurse understands *why* a paid total is lower than expected (a booking was
refunded after payout). Link to the refund/booking context.
- Each row **deep-links** to the booking/session detail from f8 (don't rebuild it).
- **Empty / loading / error** states for the list (loading skeletons; "no earnings yet" empty; a retry
affordance on error — but **don't** toast 401/403/5xx in the hook; the fetch layer already does).
### 3.3 Payout history list + batch detail
- **Payout history** — a paginated list (`useNursePayoutHistory`) of the nurse's `nurse_payouts`, newest
first: per row the **net amount transferred**, the **payout-status chip**
(`pending`/`processing`/`paid`/`failed`), `paid_at` (Shamsi), the masked IBAN (last-4 only — it is an
encrypted/masked field), and the `transfer_reference`. A **failed** payout shows its `failure_reason`
as an informational banner (read-only — the nurse cannot retry; retry is an admin action).
- **Payout / batch detail** — one payout expanded (`useNursePayoutDetail`): the batch period
(holiday-shifted `period_start`/`period_end`, Shamsi), `processed_at`, the **money decomposition**
(`gross_earnings_irr` `clawback_applied_irr` = `net_amount_irr`; `amount` actually transferred), the
`transfer_reference`, and the **list of bookings this payout covered** (the
`nurse_payout_booking_links` rows), each deep-linking to its booking detail. This is the nurse's
reconciliation view — "this transfer paid for these specific visits."
- **Empty / loading / error** for both (loading skeletons; "no payouts yet" empty; error retry).
### 3.4 Caching & data-flow rules (this is graded)
- All reads go through **`clientFetch` in `services/payouts/apis`** — never raw `fetch()`.
- **TanStack Query with deliberate keys + `staleTime`**: the summary and lists key separately (state
filter + page are part of the key) so switching the state tab or paging never refetches data already
in cache. A generous `staleTime` is correct here (earnings change on a weekly cadence, not per second).
- **No needless refetch / re-render**: subscribe to slices with `select` where a screen needs only part
of the payload; keep the state-filter tab state colocated low; stable references for row callbacks.
- **No mutations** ⇒ no invalidation logic to write this phase; if a future phase adds a nurse action,
it invalidates `payoutKeys` then. Optionally **prefetch the summary in the nurse-shell RSC** for a
no-flash first paint (via `serverApi.ts` + `initialData`/hydration) — only if it removes a real
round-trip and respects the RSC/client boundary.
### 3.5 Out of scope (DEFERRED — do not build here)
- **Admin payout console** — create/process/retry batch, the eligible-earnings preview wizard, the
clawback write-off queue, per-payout failure retry → **(DEFERRED** to
[`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md), the admin & partner console).
- **Nurse bank-account add/verify (استعلام شبا) UI** — the add-IBAN → pending-verification → verified/
failed flow → **(DEFERRED**; built in the nurse onboarding/profile phase
[`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md). This phase only *displays* the masked
`iban_snapshot` on a payout; it never edits bank accounts.)
- **On-demand / instant withdrawal**, per-nurse payout-frequency settings → **(DEFERRED** product-side;
MVP is weekly batches only — see `product/business/10-payouts.md` (c)).
- **Computing eligibility or payout dates on the client** → never; the **server** owns eligibility and
holiday-shifted dates. The client only renders what the contract returns (see §5).
## 4. Mocks & seams in this phase
- **No new seam is introduced here.** This phase consumes the b13 nurse-read endpoints; the
bank-transfer rail (`IBankTransferProvider`) and IBAN-ownership (`IIbanOwnershipVerifier`) seams live
**server-side** and were introduced in **b13** — the frontend never touches them.
- **If the b13 contract isn't merged when you run** (or a needed shape is missing): build a **mock
`PayoutsClientApi`** behind the **same `services/payouts` seam** (the namespace object the hooks call),
returning real-shaped fixtures that cover **all four earnings states** + a **negative net balance**
(clawback > earnings) + a **failed** payout, so every UI state is exercisable. Then:
- append the missing/needed shape to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(you **request** it; you never edit backend files), and
- record the mock in your phase report + the
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so it's swapped for the
real `clientApi` cleanly once b13 lands (per operating-rules §67). The hooks/screens stay unchanged
on swap — only the `apis/clientApi.ts` implementation flips.
## 5. Critical rules you must not get wrong
**Money correctness (verbatim, the sacred invariants across b9b13):** money is **IRR `BIGINT`, no
floats** — parse the wire integer string with the integer-safe util, never `Number()`/float math;
**Toman is display-only**. The three booking amounts always satisfy **gross = commission + payout**
(`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`); render the breakdown so it sums.
The ledger is an **append-only, balanced double-entry ledger** — the nurse's **payable balance is
derived from the ledger and may go negative** (don't clamp it to zero); a clawback **nets**, it does not
auto-reverse. Payout gating is **dispute-window gating**: an amount is eligible only after EVV
completion **AND** `dispute_window_ends_at < now()` — never show "eligible"/"paid" for an amount still in
its dispute window, and **never compute eligibility on the client**. **One payout per booking**
(`nurse_payout_booking_links.booking_id` is UNIQUE) — a booking appears in exactly one payout; the batch
detail's booking links reflect that, don't double-count. **Webhook idempotency** is a server concern, but
its consequence on the client is real: never assume a settlement/transfer is instant — render the status
the contract returns (`pending`/`processing`/`paid`/`failed`), not an optimistic "done".
**Payout-amount rule (do not get this wrong):** the nurse payout is **`gross_price_irr
balinyaar_commission_irr`**, identical whether the family paid by **card or BNPL**. The **BNPL provider
commission (`bnpl_commission_irr`) is a Balinyaar expense and is NEVER deducted from the nurse** — never
surface it on the nurse earnings screen, never subtract it from a nurse amount. The nurse's number is
payment-method-invariant.
**Read-only & authority:** the nurse view is **strictly read-only** — no transfer, no retry, no batch
action, no eligibility computation. The **server is the only authority** on eligibility, holiday-shifted
dates, and amounts; the client renders the contract's values and only ever *displays* a countdown
derived from `dispute_window_ends_at` (cosmetic, never a gate).
**PII / masking:** `iban_snapshot` is an encrypted/masked field — show **last-4 only**, never a full
IBAN; `transfer_reference` is an opaque string shown for reconciliation. Don't log full sensitive values.
**Frontend invariants:** respect the **RSC/client boundary** (no `next/headers`/`next-intl/server`/
`@/lib/cookies/server` in client components); **design RTL-first**, `fa` default, **every string in both
`en.json` and `fa.json`** in sync; **colours from `tokens.css`** (the four state chips use the
`--bal-{success,warning,info,error}` semantic tokens, never hardcoded hex); **MUI v9 API only**, pre-built
themes only; **MUI primitives stay MUI**, shared composites (earnings row, payout row, balance header)
live at the **shared** level with a co-located `*.test.tsx`, not inline in the page.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus this phase's specifics:
- [ ] `services/payouts/` exists in the `auth`-service shape (`types.ts` from the **contract**, `keys.ts`,
`apis/clientApi.ts`, read-only `hooks/use*.ts`, hooks-only `index.ts`); no raw `fetch()`.
- [ ] The **nurse earnings screen** renders the **net payable balance** (correct when **negative**), the
four-bucket breakdown, the cadence/dispute-window explainer (both locales), and a **state-segmented
earnings list** whose four states (**pending / eligible / paid / clawback_applied**) are visually
distinct, each with the three-amount breakdown and the correct state affordance, deep-linking to the
f8 booking detail.
- [ ] The **payout history** list + **payout/batch detail** render the net decomposition
(`gross_earnings clawback_applied = net_amount`), the masked IBAN (last-4), the
`transfer_reference`, `paid_at` (Shamsi), the status chip, a **failed** payout's `failure_reason`,
and the **list of bookings each payout covered**.
- [ ] **Empty / loading / error** states exist for both lists and the detail; hooks don't toast 401/403/5xx.
- [ ] Caching is deliberate: state-filter + page are part of the query key, `staleTime` set sensibly, no
needless refetch on tab/page switch; minimal re-renders.
- [ ] All money via the **f0 money util** (IRR-string integer-safe parse → Toman display); the three
amounts sum; no float math anywhere.
- [ ] `en.json`/`fa.json` in sync; RTL-correct; colours from tokens (state chips off the semantic tokens).
- [ ] `npm run check` green; `npm run test:ci` green for the shared components added (earnings row, payout
row, balance header each have a co-located test).
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/payouts` domain and any new
shared components / nurse route segment; the `frontend-designer` skill was invoked for the visual work.
## 7. How to test (what a human can verify after this phase)
Prereq: the b13 nurse-read endpoints are reachable (`npm run dev` against the API), **or** the mock
`PayoutsClientApi` (§4) is active with fixtures covering every state. Then:
1. **Pending earnings.** As a nurse with a **just-completed** booking (EVV checked out, inside the 72h
dispute window), open the earnings screen → the amount shows under **pending / "in escrow · dispute
window open"** with a countdown derived from `dispute_window_ends_at`; the **net balance** includes it
as pending, not as paid. *Expected:* no "eligible"/"paid" label while the window is open.
2. **Becomes eligible, then paid.** After the dispute window passes (or with a fixture past it), the item
moves to **eligible / "awaiting the weekly batch"**. After a **(mock) batch processes** (b13 admin
action / fixture), it shows as **paid** with a **`transfer_reference`** and **`paid_at`** (Shamsi), and
it appears in **payout history**; the detail lists the exact booking(s) the payout covered.
3. **Clawback nets the total.** With a fixture where a booking was **refunded after payout**, the
earnings row shows **clawback_applied** with the net explanation (original clawback = net), and the
**net payable balance** reflects the netting — when the clawback exceeds earnings, the balance renders
as an explicit **negative / "owed back"** state (not a bare minus).
4. **Failed payout.** A fixture payout with status `failed` shows its `failure_reason` as a read-only
banner in history/detail; **no retry control is present** for the nurse.
5. **Money correctness.** Spot-check a row: `gross commission = nurse payout`; the displayed Toman
equals the IRR string ÷ 10; no BNPL provider commission appears anywhere on the nurse view; the
amount is identical for a card-funded vs BNPL-funded booking of the same gross.
6. **i18n / RTL / caching.** Switch `fa``en` → all labels translate, layout mirrors correctly. Switch
the state tabs and page the lists → React Query Devtools shows separate cache entries per
filter/page and **no refetch** of data already loaded.
7. **Gate:** `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs to update:** `client/CLAUDE.md` *Project Structure* — add the `services/payouts` domain, the new
shared earnings/payout/balance components, and any new nurse route segment. If you discover/decide any
business rule the `product/` docs don't capture (e.g. an eligible-window estimate shown to the nurse),
record it in [`../../../product/business/10-payouts.md`](../../../product/business/10-payouts.md) — don't
invent rules; record decisions and flag uncertain ones in your report.
- **Contract:** *consume* [`dev/contracts/domains/payouts.md`](../../contracts/domains/payouts.md) — derive
`types.ts` from it, do **not** guess shapes. Any missing/needed shape (e.g. the four-bucket summary, the
eligible-window estimate, the booking-links payload on batch detail) is **appended** to
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
— you request it; the backend delivers it in a later change.
- **Handoff & report:** append your phase summary to
[`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
[`dev/shared-working-context/reports/frontend-phase-12-report.md`](../../shared-working-context/reports/frontend-phase-12-report.md)
— what was built, **what is now testable and exactly how** (the §7 steps), what is mocked behind the
`services/payouts` seam and how it swaps to the real b13 `clientApi`, contracts consumed, follow-ups
(the deferred admin console + bank-account UI). Update
[`dev/shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
if you mocked `PayoutsClientApi`.
- **Memory:** save a `project` memory note for any non-obvious decision this phase made (the four
earnings-state model + how each maps to a contract field, the negative-balance "owed back" treatment,
the read-only nurse-view boundary vs admin actions), with a one-line `MEMORY.md` pointer. Don't record
what the code/docs already make obvious.
@@ -0,0 +1,326 @@
# Frontend Phase 13 (b14) — Reviews & patient care records
> **Mission:** close the trust loop and the continuity-of-care loop in the client. After a visit is
> completed, the family leaves **one moderated review** that surfaces on the nurse's public profile only
> once it clears moderation; and the **family-owned, patient-scoped care record** becomes a real screen —
> the customer reads/edits it (داروها/روتین/سوابق/وظایف) under an ownership banner, while the assigned
> nurse may only **append a visit note** (the EVV check-in/out itself already shipped in f8). This is the
> brand-survival surface: vulnerable patients are cared for unobserved at home, so we never render
> unmoderated content publicly, we never let a nurse edit the family's record, and we treat clinical
> fields as sensitive.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) (booking
> detail · sessions · EVV) + the **b14** contract [`reviews-records.md`](../../contracts/domains/reviews-records.md) ·
> **Unlocks:** (last vertical-feature frontend phase — the support/notification surfaces in
> [`frontend-phase-14-b15.md`](./frontend-phase-14-b15.md) reuse these patterns)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **last feature-domain frontend phase** before the support/admin consoles. The booking
lifecycle is fully built: a customer can search, request, get accepted, pay (escrow / BNPL), and the
nurse runs the visit with EVV. The two things still missing on the client are the **post-visit review**
(what makes the marketplace's rating signal real) and the **patient care record viewer/authoring**
(what makes continuity-of-care real across nurse changes). Both are described in the wireframe's
**Section E** (E1/E2 patient record, E3 visit note) and **Section C** (the review snippet on the nurse
profile C3). You implement the family-facing review + record screens and the nurse-facing append-only
visit-note part of E3.
**What already exists (do not rebuild) — confirmed in the codebase + prior handoffs:**
- **The whole frontend foundation** from [`frontend-phase-0.md`](./frontend-phase-0.md): the three actor
shells (customer mobile + 5-tab bottom nav خانه/رزروها/بیماران/کیف‌پول/پروفایل, nurse, admin), the
`services/{domain}` + TanStack Query caching pattern (copy the `auth` service shape:
`types.ts`/`keys.ts`/`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts`), the contracts→types pattern, the
shared composite components (status chip, stepper, cards), the money/format util, and the i18n
namespace baseline (a `reviews` namespace was reserved in f0 — fill it; add a `records` namespace).
- **Booking detail, sessions & EVV** from [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md): the
booking-detail screen, the status timeline, the nurse EVV **check-in/check-out** banner and the
post-confirmation care-instructions surface, and the booking status enum (the **completed/closed**
state that gates a review). **Reuse the booking-detail screen and the booking `services` domain** — the
"Leave a review" entry point hangs off a completed booking; the visit-note authoring is the **note +
task-checklist** part of E3 that sits *below* the EVV banner f8 already built. Do **not** rebuild EVV.
- **The patients list & a patient's identity** from [`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md):
E1 (patients list, Patients tab) and the patient header (name, age/gender, conditions) already exist —
the **record viewer E2 is a new screen reached from a patient**; reuse the patient header, don't
re-fetch the patient identity from scratch.
- **The nurse public profile (C3)** from [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md): it already
renders avatar/badges/services and a *single* review snippet. This phase adds the **reviews tab** to
that existing profile — extend it, don't fork it.
- The **contract** [`reviews-records.md`](../../contracts/domains/reviews-records.md) produced by
backend phase b14 — the source of truth for every shape below. If it is not yet published, mock behind
the seam (§4) and file the gap (§8).
## 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) — how you
work and the tick-list you are graded on.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) in full — the RSC/client boundary, the
`services/{domain}` + Query rules, i18n, theme/tokens, cookies. Non-negotiable.
- **Invoke the `frontend-designer` skill before any visual work.** It is the design/brand contract
(palette: teal `#1d4a40`, terracotta `#d98c6a` for the nurse-view E3 accent, cream; tokens, typography,
the `App*` library, layout shells, the hard UI rules). Every screen in this phase goes through it — the
star input, the tag chips, the tabbed record viewer, the ownership banner, the visit-note composer.
- [`reviews-records.md`](../../contracts/domains/reviews-records.md) — **the b14 contract you consume.**
Read it end-to-end for exact request/response shapes, routes, status codes, the `review_status` enum
(`pending_moderation`/`published`/`hidden`/`rejected`), the care-record tab/section shape, and which
clinical fields are masked vs. full. Derive your `types.ts` from this, not from guesses.
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) +
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — envelope, `snake_case`
routes/JSON, pagination (`page`/`page_size`, `items`+`total`), enums-as-codes (mirror as string-literal
unions, **never** hardcode a label off a code), UTC + **Shamsi display is a client concern**, and the
**PII/sensitive-field** rule (clinical notes are encrypted-at-rest, returned only to authorized callers,
sometimes masked — the two-stage clinical-disclosure rule applies).
- [`../../../product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
— the business rules: one review per completed booking, `pending_moderation` default, recompute-on-every-
transition (a server concern, but it means a hidden review must *vanish* from the profile — your cache
must invalidate), low-rating → support alert (server-side; you just render the "under review" state).
- [`../../../product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
`reviews` (1:1 booking, rating 15 CHECK, body, moderation status), `review_tags_master`/
`review_tag_links` (the tag vocabulary), `patient_care_records` (nurse-authored, **patient-scoped not
booking-scoped**, encrypted, strict access: owning customer + nurse with a confirmed booking for that
patient + admin).
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **Section E** (E1
patients list, **E2 patient record** with the four tabs + ownership banner "این پرونده متعلق به خانواده
است …", **E3 visit note** in the terracotta "نمای پرستار" frame: EVV banner [already built] + today's
task checklist [give متفورمین, measure blood pressure, short walk] + free-text visit-note field) and
**Section C** (C3 nurse profile with the latest-review snippet you turn into a tab).
- The existing `client/src/services/auth/*` — the exact `services/{domain}` shape to copy, and the
booking + patients services from f8 / f2 you will reuse.
## 3. Scope — build this
Two new `services/{domain}` domains (`reviews`, `patientRecords`), their hooks, and four wireframe
screens (+ one tab added to an existing screen). Every screen is RTL-first, `fa` default, both locales in
sync, colours from tokens, MUI v9 primitives reused, query-cached with deliberate keys and
invalidate-on-mutation. **Invoke the `frontend-designer` skill for each screen.**
### 3.1 `services/reviews` domain
Copy the `auth` service shape. Consume the b14 contract.
- `types.ts``Review` (`id`, `booking_id`, `nurse_id`, `customer_display_name`, `rating` 15,
`body`, `status: 'pending_moderation' | 'published' | 'hidden' | 'rejected'`, `tag_codes: string[]`,
`created_at`), `NurseReviewsResponse` (`items`, `total`, `aggregate_rating`, `review_count`),
`ReviewTag` (`code`, plus the i18n key is the client's, **not** a label off the wire),
`CreateReviewRequest` (`booking_id`, `rating`, `body`, `tag_codes`), `ReviewEligibility`
(`can_review: boolean`, `reason?`). Mirror the **exact** wire shape/casing from `swagger.json`.
- `keys.ts` — a query-key factory: `reviews.nurse(nurseId, page)`, `reviews.eligibility(bookingId)`,
`reviews.myReviewForBooking(bookingId)`.
- `apis/clientApi.ts` — wrap `clientFetch`: `getNurseReviews(nurseId, page)`
(`GET .../get_nurse_reviews`, **published only** — the server already filters; never request or render
other statuses publicly), `getReviewEligibility(bookingId)`, `createReview(body)`
(`POST .../create_review`). A `serverApi.ts` only if the nurse-profile reviews tab is prefetched in the
RSC (prefer it — removes a client round-trip on C3).
- `hooks/` (one per file): `useNurseReviews` (`useInfiniteQuery` or paged `useQuery` with `select` for
the aggregate slice), `useReviewEligibility`, `useCreateReview` (`useMutation` → on success
`invalidateQueries` for `reviews.eligibility(bookingId)` **and** `reviews.myReviewForBooking(bookingId)`
so the booking-detail CTA flips to the "under review" state immediately; do **not** optimistically push
the review into the public nurse list — it is `pending_moderation` and must not appear publicly).
- `index.ts` barrel.
### 3.2 `services/patientRecords` domain
Same shape. Consume the b14 contract. **Patient-scoped**, not booking-scoped.
- `types.ts``CareRecordTab = 'medications' | 'routine' | 'history' | 'tasks'`; `Medication`
(`name`, `frequency`, `timing_note`), `RoutineItem`, `HistoryEntry`, `CareTask` (`label`, `done`),
`VisitNote` (`id`, `booking_id`, `nurse_display_name`, `body`, `task_results`, `created_at` — **read-
only/append-only** from the client's perspective), `PatientCareRecord` (the family-owned editable
record: medications/routine/tasks the customer maintains), `RecordAccess`
(`can_view`, `can_edit`, `can_append_note`, `denied_reason?`), `CreateVisitNoteRequest`
(`booking_id`, `body`, `task_results`). Clinical fields are **sensitive** — treat masked/full per the
contract; never log them.
- `keys.ts``records.patient(patientId)`, `records.history(patientId, page)`,
`records.access(patientId)`.
- `apis/clientApi.ts``getPatientCareRecord(patientId)` (`GET .../get_patient_care_record`, the
four-tab payload), `getPatientHistory(patientId, page)` (longitudinal visit-note history, paged),
`updateCareRecord(patientId, body)` (customer edits — medications/routine/tasks),
`createVisitNote(patientId, body)` (**nurse append** — `POST .../create_visit_note`). The access check
rides on the read responses (403 from the envelope → render access-denied, don't crash).
- `hooks/``usePatientCareRecord`, `usePatientHistory` (paged/infinite), `useUpdateCareRecord`
(customer mutation → invalidate `records.patient`), `useCreateVisitNote` (nurse mutation → invalidate
`records.history` and `records.patient`; the nurse **cannot** call `updateCareRecord` — don't even wire
that hook into the nurse view).
- `index.ts` barrel.
### 3.3 Screens & flows
**(a) Leave-a-review flow** (customer; entry from completed booking detail / completed-bookings list)
- A `<LeaveReviewSheet>` (or page) reached only when `useReviewEligibility(bookingId).can_review` is true
**and** the booking status is completed/closed. Contains: a **15 star input** (a new shared
`<RatingInput>` composite — see §3.4), a multiline **body** field, and **tag chips** (multi-select from
the contract's tag vocabulary; chip labels are i18n keys keyed off `tag_codes`, never off the wire).
Primary action "ثبت نظر".
- States: **not-eligible** → CTA hidden/disabled with a clear reason ("نظر فقط برای ویزیت‌های تکمیل‌شده
امکان‌پذیر است"); **eligible** → the form; **submitting**; **submitted** → an **"در حال بررسی" / "under
review"** banner (the review is `pending_moderation`, not yet public) and the CTA becomes a passive
"نظر شما ثبت شد و در حال بررسی است"; **already-reviewed** (1:1) → show the existing pending/published
state, never a second form; **error** → domain 4xx message, preserve the draft.
**(b) Nurse public-profile reviews tab** (customer; on the existing C3 nurse profile)
- Add a **reviews tab** to the existing nurse-profile screen. Render **only `published`** reviews via
`useNurseReviews`, with the **aggregate rating + review count** header, paginated/infinite list, each
row: stars, body, tag chips, masked customer display name, Shamsi date. States: **loading** (skeleton),
**empty** ("هنوز نظری ثبت نشده"), **error**. Never render `pending_moderation`/`hidden`/`rejected` — if a
review is hidden server-side, the next fetch simply omits it (the aggregate recompute is the server's
job; the client just trusts the published list and its `aggregate_rating`).
**(c) Patient record viewer E2** (customer; reached from a patient in the Patients tab / E1)
- Header (reuse the f2 patient header: name, age/gender, condition chips, an **ویرایش/edit** affordance)
+ a **tabbed** body: **داروها (Medications)** [default], **روتین (Routine)**, **سوابق (History)**,
**وظایف (Tasks)**. Medication cards (drug, frequency, timing/notes). The **سوابق** tab shows the
longitudinal visit-note history (§(e)). A persistent **ownership banner**: "این پرونده متعلق به خانواده
است" (the record belongs to the family). Customer can edit medications/routine/tasks
(`useUpdateCareRecord`); the **سوابق** (nurse visit notes) are read-only to everyone.
- States: **loading** (skeleton per tab), **empty** per tab ("دارویی ثبت نشده" / "یادداشتی ثبت نشده"),
**access-denied** (403 → a clear, non-leaking "شما به این پرونده دسترسی ندارید" card — never show
partial clinical data), **error**.
**(d) Nurse visit-NOTE authoring E3 — the note + task-checklist part only** (nurse; terracotta
"نمای پرستار" frame, on the booking-visit screen f8 built)
- **Below the EVV check-in/out banner f8 already renders**, add: **today's task checklist** (from the
patient's care tasks — render each `CareTask` as a checkbox the nurse ticks: give متفورمین, measure
blood pressure, short walk) and a **free-text visit-note field**. Primary action "ثبت یادداشت"
(`useCreateVisitNote`). The nurse view is **append-only**: it must **never** expose the customer's
edit affordances (no medication/routine/tasks editing, no `updateCareRecord` hook wired) — the nurse
can read prior history for continuity and append a note, nothing more.
- States: **append form** (default), **submitting**, **saved** (note appended → it appears in the
longitudinal history), **error** (preserve draft). If the nurse lacks a confirmed booking for that
patient (`access.can_append_note === false`), hide the composer.
**(e) Longitudinal patient history** (customer in the سوابق tab + nurse for continuity)
- A patient-scoped, paginated visit-note timeline (`usePatientHistory`): each entry = nurse display name,
Shamsi date, note body, completed-task summary — ordered newest-first. It **persists across nurse
changes** (patient-scoped, so a new nurse reads it before/at the visit). Read-only. States:
loading/empty/error.
> **(DEFERRED)** — do **not** build in this phase: review *moderation* UI (the admin approve/hide/reject
> queue → [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md)); two-way (nurse-reviews-customer)
> reviews; structured tag *aggregation* dashboards ("% punctual") — render the tag chips, but the
> aggregate analytics are deferred per the product doc; the in-app "raise a concern" flag and emergency
> banner → [`frontend-phase-14-b15.md`](./frontend-phase-14-b15.md). Tag those entry points with a pointer
> if a placeholder is unavoidable; otherwise leave them out.
## 4. Mocks & seams in this phase
- **Reuse the `services/{domain}` seam pattern** from [`frontend-phase-0.md`](./frontend-phase-0.md): all
data goes through `clientFetch`/`serverFetch` in `services/reviews` and `services/patientRecords`. No
raw `fetch()`.
- If the **b14 contract** [`reviews-records.md`](../../contracts/domains/reviews-records.md) (or the
`swagger.json` snapshot) is **not yet published** when you run, build a **mock `clientApi`** behind the
same domain seam returning real-shaped fixtures (a completed-booking eligibility, a small published-
review list with an aggregate, a four-tab care record, an append-able history) — selected by config,
never an `if (mock)` in a component — and:
1. append the missing/uncertain shapes to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(per operating-rules §6), and
2. record the mock in your phase report + the
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so it swaps out cleanly
once the real endpoint lands.
- No third-party client seam is introduced here (AI moderation `IReviewModerationService` and field
encryption `IFieldEncryptor` are **server-side** b14 concerns — the client never sees plaintext-vs-
ciphertext, only the authorized/masked payload).
## 5. Critical rules you must not get wrong
- **Review eligibility is gated on a completed/closed booking.** The "Leave a review" CTA is enabled
**only** when the booking status is completed/closed (from f8's booking enum) **and** the server says
`can_review`. Never offer a review for a cancelled/expired/other-customer booking, and enforce **one
review per booking** (1:1) — if a review already exists, show its state, never a second form.
- **Never render `pending_moderation` (or `hidden`/`rejected`) content publicly.** The nurse-profile
reviews tab requests and renders **published only**. After a user submits, show an **"under review"**
state locally — do **not** optimistically inject the new review into any public list or aggregate.
Trust the server's published list + `aggregate_rating`; when a review is hidden server-side, invalidate
and re-fetch rather than mutating the count yourself.
- **The patient care record is FAMILY-OWNED and PATIENT-scoped.** The customer owns and edits it
(medications/routine/tasks); the record persists **across nurse changes** because it is keyed to the
**patient, not the booking**. Render the ownership banner "این پرونده متعلق به خانواده است" on E2.
- **The nurse can ONLY append a visit note — never edit the record.** The nurse view exposes the task
checklist + a note composer and the read-only history; it must **not** wire `updateCareRecord` or any
medication/routine/task editing. Append-only is a hard boundary, not just a hidden button.
- **Strict access; surface access-denied clearly.** Only the owning customer, a nurse with a **confirmed**
booking for that patient, and admin may view a record. A `403` from the envelope → render a clear,
non-leaking access-denied card (no partial clinical data), never a crash or a blank tab.
- **Clinical fields are sensitive.** Treat masked vs. full strictly per the contract (two-stage clinical
disclosure spirit); never log clinical text, never persist it to `localStorage`, never put it in a
query string.
- **RSC/client boundary, caching, re-renders, i18n, RTL, tokens.** No layout above `[locale]`; no
`next/headers`/`next-intl/server` in client components. Set `queryKey`/`staleTime` deliberately and
**invalidate on every mutation** (review create → eligibility/my-review; note append → history+record;
record edit → record) so nothing over-fetches. Use `select` for the aggregate/tab slices to avoid
needless re-renders. Every string is a key in **both** `en.json` and `fa.json`; `fa` default & RTL;
colours from `tokens.css` (terracotta accent for the nurse E3 frame via tokens, never hardcoded);
MUI v9 primitives reused; Shamsi date display is the client's job.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/reviews` and `services/patientRecords` exist with the f0 shape (`types`/`keys`/`apis`/
`hooks`/`index`); types are derived from the published b14 contract (or mocked behind the seam with a
`for-backend.md` entry), never guessed.
- [ ] The leave-a-review flow enforces completed-booking eligibility + 1:1, shows the **under-review**
state on submit, and never injects unmoderated content into a public list.
- [ ] The nurse-profile **reviews tab** renders published-only with aggregate rating + count, paginated,
with loading/empty/error states.
- [ ] The **E2 patient record viewer** renders the four tabs (داروها/روتین/سوابق/وظایف), the ownership
banner, customer edit of medications/routine/tasks, and a clear **access-denied** state on 403.
- [ ] The **nurse E3 visit-note** authoring (task checklist + note composer) is **append-only**, sits
below the f8 EVV banner, exposes no record-editing affordances, and the appended note appears in the
longitudinal history.
- [ ] The **longitudinal history** is patient-scoped, paginated, read-only, newest-first, and persists
across nurse changes.
- [ ] New shared composites (`<RatingInput>`, the tag-chip selector, the tabbed record viewer if reused)
live at the right shared level with co-located `*.test.tsx`; `npm run check` and (if a shared
component changed) `npm run test:ci` are green; `en.json`/`fa.json` in sync (`reviews` + `records`
namespaces).
- [ ] `client/CLAUDE.md` *Project Structure* updated for the two new service domains + any new shared
component/route; the `frontend-designer` skill was invoked for the visual work.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and have the b14 backend reachable, or the seam mock active).
1. **Leave a review on a completed booking → pending → appears after moderation.** As a customer on a
**completed** booking, open "ثبت نظر", give 4 stars + body + a tag chip, submit → the screen shows the
**"در حال بررسی / under review"** state and the CTA does not offer a second review. The review does
**not** appear on the nurse's profile yet. After the admin publishes it (b14/f15 path, or flip the
mock to `published`), it appears on the **nurse profile reviews tab** and the aggregate rating/count
updates on next fetch. Confirm a **cancelled** booking shows no review CTA.
2. **View a patient record with tabs + ownership banner.** From the Patients tab, open a patient → E2
shows the four tabs, medication cards under داروها, the **"این پرونده متعلق به خانواده است"** banner,
and the customer can edit a medication/routine/task and see it persist (cache invalidates, no full
reload). Visiting a patient you don't own returns the **access-denied** card, not a crash.
3. **A nurse appends a visit note (cannot edit the record).** As the assigned nurse on today's visit
(E3, terracotta frame), below the EVV banner: tick the task checklist, write a note, "ثبت یادداشت" →
the note saves and shows in the history. Confirm there is **no** medication/routine/task edit control
anywhere in the nurse view.
4. **History persists across nurse changes.** The سوابق tab (customer) and the nurse's continuity view
show the full patient-scoped, newest-first visit-note timeline — including notes from a *different*
nurse — paginated.
5. **Gate checks:** `npm run check` green; `npm run test:ci` green for the new shared components; toggling
locale flips `dir`/strings; the reviews tab/list and the record edit show query caching + invalidation
in React Query Devtools (no needless refetch).
## 8. Hand off & document (close the phase)
- **Docs:** update the *Project Structure* tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md)
for `services/reviews`, `services/patientRecords`, and any new shared component/route; note the
`reviews`/`records` i18n namespaces. If you discovered a business-rule detail the product docs don't
capture (e.g. an exact masking behaviour), record it in
[`../../../product/business/11-reviews-trust-and-safety.md`](../../../product/business/11-reviews-trust-and-safety.md)
or [`../../../product/data-model/10-reviews-and-records.md`](../../../product/data-model/10-reviews-and-records.md)
— don't invent rules.
- **Contract:** **consume** [`reviews-records.md`](../../contracts/domains/reviews-records.md) (b14) as
the source of truth for every shape. The frontend does **not** write contracts — if a shape is missing,
wrong, or unmasked when it should be masked, append a request to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the `services/{domain}` seam meanwhile.
- **Handoff & report:** append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
[`../../shared-working-context/reports/frontend-phase-13-report.md`](../../shared-working-context/reports/frontend-phase-13-report.md)
(what was built, **what is testable and exactly how** per §7, what is mocked client-side + how it swaps,
contracts consumed, follow-ups — e.g. the deferred moderation UI for f15); update
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for any client-side mock you used.
- **Memory:** save a `project`-type memory note for the non-obvious decisions this phase locks in (review
is published-only on the client and never optimistically injected; the patient record is family-owned
and patient-scoped with the nurse strictly append-only; access-denied is a first-class state), with a
one-line pointer in `MEMORY.md`.
@@ -0,0 +1,330 @@
# Frontend Phase 14 — Messaging (tickets) & notifications
> **Mission:** give families and nurses the *only* sanctioned way to talk after a booking — the
> admin-readable **ticket** system — plus the in-app **notification center** that pulls them back in
> and deep-links them to the right place. There is no live chat by design: communication is structured,
> auditable, and anti-disintermediation. This phase ships the customer/nurse "My Tickets" inbox + thread,
> the notification bell with a polled unread count, and the prominent **emergency playbook banner** on
> booking/support entry. It must never leak an internal admin note into a user's view.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) (booking
> detail — the ticket/support entry points hang off it) · backend **b15** contract
> ([messaging-notifications-admin](../../contracts/domains/messaging-notifications-admin.md)) + the
> **b1** notifications endpoints · **Unlocks:** [`frontend-phase-15-b15`](./frontend-phase-15-b15.md)
> (admin & partner consoles — reuses the same ticket/notification services with the admin lens) ·
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the social/communication layer of the customer & nurse apps. Bookings, sessions, EVV,
payments, refunds, and reviews already exist; what is missing is the **channel** that ties them
together for humans. Balinyaar deliberately has **no direct nurse↔customer messaging and no live chat**
all post-booking communication runs through **tickets** that admin can read in full (anti-disintermediation
and patient-safety; see [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md)).
A booking-coordination ticket is auto-created on confirmation; users also open support/refund tickets.
In parallel, the **notification center** is the app's pull mechanism — in-app only (no push at launch),
**polled**, with a typed `data_json` payload that tells the front-end where to deep-link.
This phase builds **two new domain services** (`services/tickets`, `services/notifications`) and the
screens on top of them, for the **customer and nurse apps only**. The admin lens over the very same
data — the global ticket queue with the internal-note composer, the support-alert worklist — is
**(DEFERRED to [`frontend-phase-15-b15`](./frontend-phase-15-b15.md))**; build the services so f15 reuses
them without rewrite.
**What already exists (do not rebuild) — confirmed from prior phases:**
- The app shells, role-aware nav, the **5-tab customer bottom nav** and the nurse shell, the
`services/{domain}` + TanStack Query caching pattern, the contracts→types pattern, the money/format
utils, and the shared composites (status chip, stepper, cards) — [`frontend-phase-0`](./frontend-phase-0.md).
- `AuthContext` with roles, the OTP login/role router — [`frontend-phase-1-b2`](./frontend-phase-1-b2.md).
- The **booking detail & sessions** screen, the status timeline, and the nurse EVV check-in/out — these
are where the **"Get support / Open ticket"** entry point and the nurse **emergency banner** attach —
[`frontend-phase-8-b9`](./frontend-phase-8-b9.md). Reuse its booking-detail layout and booking query;
**do not** rebuild booking fetching here.
- Reviews & patient-record screens (the prior phase) — [`frontend-phase-13-b14`](./frontend-phase-13-b14.md);
unrelated to this phase except that both consume the b14/b15 contract bundle.
- `clientFetch`/`serverFetch` + `ApiError`, the toast bridge (already toasts 401/403/5xx — do **not**
re-toast those in your hooks), the cookie manager, `APP_THEME_LTR/RTL`, `tokens.css`.
> **Backend readiness note.** The contract you consume,
> [`messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md), is
> produced by **backend-phase-b15** (tickets) and the notification endpoints by **b1**. If a shape you
> need is absent or wrong when you start, **do not guess and do not block** — append a request to
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
> and mock behind the `services/tickets` / `services/notifications` seam meanwhile (operating-rules §6).
> Record every mock in your report so it swaps cleanly.
## 2. Required reading (do this first)
**Operating rules & checklists**
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you work and the tick-list.
- [`../_shared/definition-of-done.md`](../_shared/definition-of-done.md) — the bar this phase adds to (§6).
**Product / business truth (read before designing any screen)**
- [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md) —
**the core rules**: no live chat / no direct channel, ticket-only, `is_internal` admin notes, the
emergency playbook ("call the surfaced emergency contact, then open a ticket"), why the family's phone
is never exposed beyond the controlled emergency surface.
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md) —
in-app, polled notifications (no push), 90-day retention, deep-link via the typed payload; the admin
tooling spine is **(DEFERRED to f15)**.
- [`product/data-model/09-messaging.md`](../../../product/data-model/09-messaging.md) — `tickets` /
`ticket_participants` / `ticket_messages`, `is_internal`, `reference_code`, optional booking/refund links.
- [`product/data-model/11-notifications.md`](../../../product/data-model/11-notifications.md) —
`notifications` (`data_json` typed payload, polled, 90-day read retention); `support_alerts` are
internal-only (not in this phase's user app).
**Contracts & types (the source of truth for shapes — do not guess)**
- [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md) —
the b15 ticket endpoints + the b1 notification endpoints, request/response shapes, enums, status codes,
the user-vs-admin filtering note for `is_internal`.
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
the envelope, enums-as-codes, UTC timestamps (render Shamsi), pagination.
**Code to mirror (existing patterns — copy, don't invent)**
- `client/src/services/auth/*` (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` /
`index.ts`) — the exact shape every new domain service copies.
- The booking-detail screen + its booking query from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) —
where the support/emergency entry points mount.
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC boundary, layouts, i18n, theme, fetch services,
anti-patterns.
**Design**
- **Invoke the `frontend-designer` skill** before building any screen. All visual work (the inbox list,
the thread bubbles, the bell + badge, the emergency banner, message-send states) goes through it —
brand palette, tokens, typography, the `App*` library, RTL rules. Do not hand-roll colours or spacing.
## 3. Scope — build this
Two new domain services + the customer/nurse screens that consume them. Customer **and** nurse apps share
these screens (role decides chrome, not the components). The admin global queue is **(DEFERRED to
[`frontend-phase-15-b15`](./frontend-phase-15-b15.md))**.
### 3.1 `services/tickets` (new domain seam)
Copy the `auth` service skeleton exactly.
- **`types.ts`** — derive from the b15 contract: `Ticket` (`id`, `referenceCode`, `subject`/`category`,
`status` enum e.g. `open|pending|closed`, optional `bookingId`/`refundId`, `lastMessageAt`,
`unreadCount`), `TicketMessage` (`id`, `ticketId`, `body`, `authorRole` e.g. `customer|nurse|admin`,
`authorName`, `createdAt`, `isMine`). **Do not model an `isInternal` field in the user-app types**
the server strips internal messages from the user view; modelling it invites a leak (§5).
- **`keys.ts`** — query-key factory: `tickets.list(params)`, `tickets.detail(id)`,
`tickets.thread(id, page)`. Deliberate `staleTime` (thread is short-lived; list moderate).
- **`apis/clientApi.ts`** wrapping `clientFetch``listMyTickets(params)`, `getTicket(id)`,
`getThread(id, page)`, `openTicket(body)`, `postMessage(ticketId, body, clientMessageId)`.
- **`hooks/` (one hook per file):** `useMyTickets`, `useTicket`, `useTicketThread`, `useOpenTicket`,
`usePostMessage`. `usePostMessage` is **optimistic** (§3.5). Mutations **invalidate**
`tickets.list`/`tickets.thread` on settle so cached data isn't refetched needlessly.
- **`index.ts`** barrel.
### 3.2 `services/notifications` (new domain seam)
Copy the same skeleton.
- **`types.ts`** — from the b1 contract: `AppNotification` (`id`, `type` enum, `title`/`body`, `isRead`,
`createdAt`, `dataJson`), and a **discriminated-union `NotificationData`** typed off `type` (e.g.
`booking_confirmed → { bookingId }`, `payment_captured → { bookingId }`, `payout_paid → { batchId }`,
`review_published → { reviewId }`, `ticket_message → { ticketId }`). `data_json` is a *typed contract*
— parse it into the union; never trust an arbitrary blob (§5).
- **`keys.ts`** — `notifications.list(params)`, `notifications.unreadCount()`.
- **`apis/clientApi.ts`** — `listNotifications(params)` (paged, unread-first), `getUnreadCount()`,
`markRead(id)`, `markAllRead()`.
- **`hooks/`:** `useNotifications`, `useUnreadCount` (the **polling** query — §3.4), `useMarkNotificationRead`,
`useMarkAllRead`. Mark-read mutations **`setQueryData`** to flip `isRead` and decrement the cached count
optimistically, then invalidate on settle — no full refetch of the list.
- **`index.ts`** barrel.
- A small **`notificationDeepLink(n: AppNotification): string`** util that maps the parsed `dataJson` to an
in-app route (e.g. `bookingId → /bookings/{id}`, `ticketId → /support/tickets/{id}`). Centralise it so the
bell and the center both deep-link identically.
### 3.3 Ticket screens (customer + nurse apps)
- **"My Tickets" inbox** (`/support/tickets` in the customer shell; the equivalent under the nurse shell) —
a paginated list of `Ticket` cards built from MUI/`App*` primitives: **`reference_code` shown prominently**,
subject/category, status chip (reuse the f0 status chip), linked-booking/refund hint (handle the
null link gracefully), relative Shamsi time, and an **unread indicator**. States: **empty**
("هنوز گفتگویی ندارید" / "No conversations yet"), loading skeleton, error→retry. A **"Contact support"**
CTA opens a new ticket (category select → submit → confirmation showing the new `reference_code`).
- **Ticket thread view** (`/support/tickets/[id]`) — role-aware message bubbles (mine vs theirs, mirrored
for RTL), the `reference_code` in the header, linked-booking chip, a sticky composer at the bottom.
States: thread skeleton, **empty** ("هنوز پیامی نیست، هماهنگی را شروع کنید" / "No messages yet — start
coordinating"), per-message send states (sending / sent / **failed→retry, draft preserved**),
not-a-participant / ticket-closed errors. **The user thread NEVER renders internal-note content or
styling** — there is no internal-note affordance anywhere in the user app (§5).
- **Open-from-booking entry point** — on the booking-detail screen from
[`frontend-phase-8-b9`](./frontend-phase-8-b9.md), add a **"Get support / Open ticket"** action that
opens a new ticket pre-linked to that `bookingId` (or jumps to the existing coordination ticket if one
exists). Wire it through `services/tickets`; don't fetch the booking again — reuse its query.
### 3.4 Notification center + bell
- **Notification bell** — a shared component in the app chrome (customer bottom-nav/top-bar and nurse
shell) showing the **unread count badge**. The count comes from `useUnreadCount`, which **polls with
stale-while-revalidate**: set a sensible `refetchInterval` (e.g. 60s), `refetchOnWindowFocus`, and a
matching `staleTime` so you serve the cached count instantly and revalidate in the background — **do
not hammer the endpoint** (§5).
- **Notification center** (`/notifications`, or a drawer/sheet off the bell) — a paged, **unread-first**
list; each row deep-links via `notificationDeepLink` and **marks itself read on open** (optimistic
`setQueryData` flips `isRead` and decrements the cached unread count, invalidate on settle). A
**"Mark all read"** action. States: **empty** ("به‌روز هستید" / "You're all caught up"), loading,
error→retry, unread badge styling. Reuse the f0 status chip / list primitives; build the row as a
shared composite with a co-located test.
### 3.5 Optimistic message send (the interaction that must feel instant)
`usePostMessage` is `useMutation` with `onMutate`: generate a `clientMessageId`, `cancelQueries` on the
thread key, snapshot, and `setQueryData` to append a **pending bubble**; on error **roll back to the
snapshot but keep the typed text in the composer draft** so the user can retry without retyping; on
success replace the pending bubble with the server message; `onSettled` invalidate the thread key.
Reconcile by `clientMessageId` so you never double-render a message. The composer disables submit while
sending but never clears the draft until the server confirms.
### 3.6 Emergency banner (the operational playbook)
A shared **emergency banner** composite shown on the **booking detail (esp. nurse app)** and on the
**support entry**: prominent, branded, copy = "For emergencies, call the emergency contact, then open a
ticket" (both locales). It surfaces the **emergency-contact number as a `tel:` click-to-call link** drawn
from the booking's (post-confirmation, decrypted) care instructions exposed by
[`frontend-phase-8-b9`](./frontend-phase-8-b9.md)/the booking contract — **the platform never exposes a
nurse's or family's general phone number; only this controlled, post-confirmation emergency surface**
(§5). Pre-confirmation, render **nothing** (no placeholder). If the contact can't be loaded, still show
the banner with a path to open a support ticket. This is `tel:` only — **do not build any calling/VoIP
seam** (telephony is out-of-platform by design).
### 3.7 i18n + types housekeeping
- Add a `messaging`/`tickets` and a `notifications` namespace to **both** `messages/en.json` and
`messages/fa.json`, in sync, RTL-first (`fa` default). Every user-visible string is a key.
- Types come from the published contract; any gap → append to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the seam.
**(DEFERRED)** — admin global ticket queue + internal-note composer, support-alert worklist
([`frontend-phase-15-b15`](./frontend-phase-15-b15.md)); push/real-time message delivery, SignalR/SSE
replacing polling, file attachments on messages, a first-class incidents/SLA UI (product-doc DEFERRED).
Build the services so f15 layers the admin lens on top; do not stub admin screens here.
## 4. Mocks & seams in this phase
This is a **frontend** phase — its only "seams" are the two domain services behind which a mock
`clientApi` lives until the backend is merged (operating-rules §6, frontend-checklist last bullet).
- **`services/tickets` seam** — if b15 isn't merged, ship a mock `clientApi` (same method signatures as
the real one) returning realistic tickets/threads (with `reference_code`, **no internal messages** — the
mock must mimic the server's user-view filtering), and an in-memory append for optimistic-send testing.
- **`services/notifications` seam** — likewise: a mock returning a paged unread-first list, a decrementing
unread count, and a `data_json` payload per type so `notificationDeepLink` can be exercised.
Record both mocks in your **frontend report** and (since they're client-side mocks behind a seam) note
them so f15/real-endpoint swap is a one-file change. **Do not** introduce backend seams (`IFieldEncryptor`,
`INotificationDispatcher`, etc.) — those are b1/b15's; **reuse** the booking-detail care-instruction
disclosure from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) for the emergency contact, do not
re-implement decryption client-side.
## 5. Critical rules you must not get wrong
- **`is_internal` never reaches the user app.** Internal admin notes are server-filtered, but **do not
leak the concept**: don't model an `isInternal` field in the user-app types, don't render internal
styling, and never add an internal-note affordance in the customer/nurse thread. Treat any internal
message in a user-view payload as a backend defect — file it via `for-backend.md`, don't render it.
- **No direct out-of-band channel.** There is no live chat and no nurse↔customer phone exchange. The
**only** sanctioned bypass is the **emergency-contact `tel:` surface**, and only **post-confirmation**.
Never expose a nurse's or family's general phone number; never turn the emergency surface into a contact
directory. The emergency path is an **operational playbook, not a real-time feature** — no SLA timers,
no VoIP/calling seam, just `tel:` + "then open a ticket".
- **Show `reference_code` prominently** in the inbox and thread header — it is what a user quotes to
support and must be stable and visible.
- **Poll the unread count politely.** `useUnreadCount` uses **stale-while-revalidate** (sensible
`refetchInterval` + `staleTime`, refetch-on-focus) — serve the cached count, revalidate in the
background, and **do not hammer** the endpoint. Don't poll the full notification list on an interval;
only the count.
- **Optimistic send is draft-preserving.** On failure, roll the thread back to its snapshot **but keep the
user's text in the composer** with a retry; reconcile by `clientMessageId` to avoid double-render. Never
clear the draft until the server confirms.
- **`data_json` is a typed contract.** Parse it into the discriminated `NotificationData` union and
deep-link off that; never `eval`/trust an arbitrary blob, and degrade gracefully (no deep-link) for an
unknown `type`.
- **Tenancy / null links.** A user sees only their own tickets and notifications (server-enforced — don't
fetch by raw id you don't own). Ticket↔booking/refund links are **optional** — render the linked-entity
chip only when present; handle `null` gracefully.
- **Frontend conventions (non-negotiable):** fetch only through `clientFetch` in `services/{domain}`;
TanStack Query caching with deliberate keys + invalidation/`setQueryData` (no needless refetch); minimise
re-renders (`select`, stable refs, colocated state — the bell's fast-changing count must not re-render the
whole shell); MUI primitives stay MUI, shared composites (notification row, emergency banner, message
bubble) live at `src/components/…` with co-located tests; colours from `tokens.css`; both locales in sync;
RTL-correct (mirror message bubbles); no layout above `[locale]`; respect the RSC/client boundary.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/tickets` and `services/notifications` exist following the `auth` service shape (types from
the b15/b1 contract, keys factory, `clientApi`, one hook per file, barrel), with mutations
invalidating / `setQueryData`-ing cache.
- [ ] **My Tickets inbox** (customer + nurse) lists tickets with prominent `reference_code`, status chip,
unread indicator, null-safe linked-entity hint, and empty/loading/error states; **Contact support**
opens a ticket and shows the new `reference_code`.
- [ ] **Ticket thread** renders role-aware bubbles, **never any internal-note content/styling**, with
empty/skeleton/error and per-message send states; **optimistic send** appends instantly and, on
failure, rolls back while **preserving the composer draft** and offering retry.
- [ ] **Open-from-booking** entry on the f8 booking detail opens/links a ticket without re-fetching the booking.
- [ ] **Notification bell** shows a **polled** unread count (stale-while-revalidate, not hammering); the
**center** lists unread-first, **marks read on open** (optimistic), has **Mark all read**, and
**deep-links via `data_json`** through `notificationDeepLink`.
- [ ] **Emergency banner** appears on booking detail (nurse) + support entry **only post-confirmation**,
with a `tel:` click-to-call and the "call the emergency contact, then open a ticket" copy; hidden
pre-confirmation; degrades to a support path if the contact can't load. No calling/VoIP seam built.
- [ ] `messaging`/`tickets` + `notifications` i18n namespaces added to `en.json` **and** `fa.json` in sync;
RTL verified (mirrored bubbles, badge placement).
- [ ] Shared composites (notification row, message bubble, emergency banner) live at the shared level each
with a co-located `*.test.tsx`; `npm run check` green; `npm run test:ci` green.
- [ ] Any contract gap is in [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and the corresponding client-side mock is behind the seam and recorded in the report.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/tickets`, `services/notifications`,
the new route segments (`/support/tickets`, `/notifications`), and the shared bell/banner components.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (with the b15/b1 endpoints live, or the seam mocks if not yet merged):
1. **Open a ticket from a booking.** Go to a confirmed booking's detail → tap **"Get support / Open
ticket"** → a ticket opens pre-linked to that booking and lands in **My Tickets** with its
`reference_code` shown. *Expected:* the new ticket appears at the top of the inbox without a manual refresh.
2. **Post a message (optimistic).** Open the thread, type, send → the bubble appears **immediately** with a
"sending" state, then resolves to "sent". Kill the network (or trigger the mock's error path) and send
again → the bubble shows **failed→retry** and **the text stays in the composer**; retry succeeds when the
network returns. *Expected:* no duplicate bubble, no lost draft.
3. **No internal notes leak.** With a ticket that has an admin internal note (mock or seeded), confirm the
**user thread shows none of it** — no hidden styling, no affordance. *Expected:* user view is identical
whether or not internal notes exist.
4. **Notification bell unread count.** Trigger a notification (e.g. a new ticket message) → the **bell badge
increments** within the poll interval. Open the **center**, open a notification → it **marks read**, the
**badge decrements**, and it **deep-links** to the right screen (booking/ticket/etc.) via `data_json`.
**Mark all read** clears the badge. *Expected:* count is served from cache instantly, revalidates in the
background, and the endpoint is **not** hit more often than the interval (check the network tab).
5. **Emergency banner.** On a **confirmed** booking (nurse app), the **emergency banner** is visible with a
`tel:` link and the playbook copy; on an **unconfirmed** booking it is **absent** (not a placeholder).
*Expected:* tapping the link initiates a phone call; "open a ticket" routes to the ticket flow.
6. **RTL + locales.** Switch `fa`/`en`: bubbles mirror, the badge sits correctly, every string is
translated. `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs to update:** `client/CLAUDE.md` *Project Structure* — add `services/tickets`,
`services/notifications`, the `/support/tickets` + `/notifications` route segments, the shared
notification-bell / message-bubble / emergency-banner components, and a one-line note on the polling
unread-count pattern and the optimistic-send/draft-preserve pattern so f15 reuses them. If you discover a
business-rule drift (e.g. the contract exposes `is_internal` to users), record it and file the request —
do not invent rules.
- **Contract to consume:** [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md)
(b15 tickets) + the b1 notification endpoints — derive all types from it; **never** guess shapes. Any
missing field/filter/endpoint → append a `REQ-NNN` entry to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(e.g. a `clientMessageId`/idempotency field for optimistic send, an `unreadCount` on the ticket list, the
emergency-contact field on the booking payload) and mock behind the seam meanwhile.
- **Handoff & report:** append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
`../../shared-working-context/reports/frontend-phase-14-report.md` (operating-rules §7) — what was built,
**what is now testable and exactly how** (the 6 steps above), which client-side mocks sit behind the two
seams and how f15/the real endpoint swaps them, the contracts consumed, and the follow-ups left for
[`frontend-phase-15-b15`](./frontend-phase-15-b15.md) (the admin lens over these services). Update the
mock registry [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for the two client-side service mocks.
- **Memory:** save a `project` memory note (with a `MEMORY.md` pointer) for the non-obvious decisions this
phase locks in — the user-app types deliberately omit `isInternal`, the polled-unread-count
stale-while-revalidate pattern, the optimistic draft-preserving send + `clientMessageId` reconciliation,
the `data_json``notificationDeepLink` typed-union mapping, and the post-confirmation `tel:`-only
emergency surface (no VoIP seam).
@@ -0,0 +1,661 @@
# Frontend Phase 15 — Admin backoffice & partner-center consoles
> **Mission:** ship the **operational cockpit** that runs Balinyaar — the internal, role-gated admin
> backoffice in the **desktop sidebar shell** from f0, plus the **partner-center portal** (a separate
> authz scope for the licensed sponsoring centers). The backoffice consolidates the worklists ops needs:
> the **verification review queue** (pass/reject nurse steps with a signed-URL document viewer + structured
> credential entry), **refund admin** (ticket-linked, fee/payout-decomposed, channel-aware, BNPL ETA),
> the **payout dashboard** (batch preview → processing → completed/partially-failed, retry), **review
> moderation** (publish/hide/reject), the **config editor** (typed inputs by `data_type`, audited save +
> change-history), the **holiday calendar manager**, the **audit-log viewer** (filtered, paginated), and
> the **support-alert worklist** (assign/resolve). The partner portal shows a center its onboarding/
> verification state, its sponsored nurses, its sponsored bookings, and — when it is merchant-of-record —
> its settlement/invoice view. There is **no wireframe** for any of these screens — you design them with
> the **frontend-designer** skill. Internal-only data (`support_alerts`, internal ticket notes) must
> **never** leak to a non-admin. When this lands, **MVP is complete.**
>
> **Track:** frontend · **Depends on:** [`frontend-phase-14-b15`](./frontend-phase-14-b15.md) (the
> `services/tickets` + `services/notifications` domains this phase layers the admin lens over) + the
> **b15** contract ([`messaging-notifications-admin`](../../contracts/domains/messaging-notifications-admin.md))
> **and** the admin endpoints across **b1** (config/holidays/audit/support-alerts), **b6** (verification
> queue), **b11** (refunds/invoices), **b13** (payout batches), **b14** (review moderation) ·
> **Unlocks:** **MVP complete** — this is the final frontend phase.
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the **last frontend phase** and the only one whose primary audience is **internal staff** (and
the licensed partner centers), not families or nurses. Every customer- and nurse-facing surface already
exists; what is missing is the back office that makes the marketplace *run*: an admin must be able to
**verify a nurse**, **process a refund**, **preview and run a payout batch**, **moderate a review**,
**edit a config value**, and **resolve a support alert** — and a partner-center admin must be able to see
the nurses and bookings their license covers. All of this rides the **desktop sidebar admin shell**
established in [`frontend-phase-0`](./frontend-phase-0.md) (the third actor shell) and the existing
`services/{domain}` + TanStack Query patterns. No new app-shell architecture; this phase fills the admin
shell with real worklists and adds a **separately-scoped** partner portal.
The backoffice is a **read-and-act** surface over data other domains own. It does **not** re-implement
verification logic, refund math, payout scheduling, moderation recompute, or config typing — those are
**server authority** (b1/b6/b11/b13/b14). The client renders the contract's values and issues the
sanctioned admin commands; it **never** computes eligibility, money decomposition, holiday shifts, or the
`is_verified` flip on the client (§5).
**What already exists (do not rebuild) — link the prior phases:**
- **f0 foundations** ([`frontend-phase-0`](./frontend-phase-0.md)): the three actor app shells + route
groups, the **admin/backoffice desktop sidebar shell** (the shell this phase lives in), role-aware nav
from `AuthContext`, the `services/{domain}` + TanStack Query caching pattern (`keys.ts` factory,
`apis/clientApi.ts`, one-hook-per-file, hooks-only `index.ts`), the contracts→`types.ts` step, the
**money/format util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date
display), the shared composites (**status chip**, **stepper/progress header**, **price-breakdown**,
cards), and the i18n namespace conventions (the `admin` namespace was reserved in f0 — fill it).
**Reuse the money util, the status chip, and the price-breakdown — do not re-implement them.**
- **f1-b2 auth** ([`frontend-phase-1-b2`](./frontend-phase-1-b2.md)): phone-OTP login, the role router,
**roles in `AuthContext`**. Admins arrive authenticated with an admin role (`super_admin` / `admin` /
`support` / `finance` / `moderator`); partner-center admins arrive with the **partner-center scope**.
This phase **role-gates** every admin route and command off these roles — there is no separate admin
login to build.
- **f5-b6 nurse verification flow** ([`frontend-phase-5-b6`](./frontend-phase-5-b6.md)): the nurse-facing
verification checklist, the per-step status enums, the `services/verification` types, the document
uploader, and the trust badge. **The admin verification queue is the staff lens over the same
`services/verification` domain** — extend it with the admin-review endpoints; reuse the step/status
enums and the badge, do not fork them.
- **f10-b11 refund & cancellation** ([`frontend-phase-10-b11`](./frontend-phase-10-b11.md)): the
customer-side cancellation/refund-status UI, the policy-fee disclosure, the BNPL ETA display, and the
refund money-display conventions. **The admin refund tool is the staff lens** — reuse the same
fee/payout decomposition rendering and the money util; the admin **initiates/approves** refunds the
customer can only watch.
- **f12-b13 nurse earnings & payouts** ([`frontend-phase-12-b13`](./frontend-phase-12-b13.md)): the
read-only nurse earnings/payout-history view, the four earnings states, the payout-status enum
(`pending`/`processing`/`paid`/`failed`), the batch-detail shape with booking links. **The admin payout
dashboard is the action surface** explicitly deferred from f12 — build the **batch preview → run →
retry** flow here; reuse the payout shapes and money rendering.
- **f13-b14 reviews & patient records** ([`frontend-phase-13-b14`](./frontend-phase-13-b14.md)): the
`services/reviews` domain, the review shapes, the star/tag rendering. **The admin moderation queue is
the staff lens** — add the `publish`/`hide`/`reject` moderation actions over the same domain.
- **f14-b15 messaging & notifications** ([`frontend-phase-14-b15`](./frontend-phase-14-b15.md)): the
`services/tickets` and `services/notifications` domains, the ticket thread, the `reference_code`
rendering. **This phase layers the admin ticket lens** (the global queue, the **internal-note
composer**, the refund-from-ticket entry) on top of `services/tickets`, and reuses the notification
bell in the admin shell. The user-app types deliberately omit `isInternal`; the **admin** types include
it (admins *see* internal notes) — keep the user/admin type surfaces distinct (§5).
- **f9-b10 / f11-b12** money UI conventions (checkout, BNPL) — the Toman-display + IRR-string handling the
admin money screens must match. **Reuse the single money util; do not fork a formatter.**
- `clientFetch`/`serverFetch` + `ApiError`, the toast bridge (already toasts 401/403/5xx — do **not**
re-toast those in hooks), the cookie manager, `APP_THEME_LTR/RTL`, `tokens.css`.
> **Backend readiness note.** The primary contract you consume,
> [`messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md), is
> produced by **backend-phase-b15** (admin ticket queue, support-alert worklist, partner centers,
> RBAC role grants) and is the consolidation point for the admin endpoints. The other admin endpoints
> live in their own domain contracts — verification (**b6**, [`verification.md`](../../contracts/domains/verification.md)),
> refunds/invoices (**b11**, [`refunds.md`](../../contracts/domains/refunds.md)), payouts (**b13**,
> [`payouts.md`](../../contracts/domains/payouts.md)), reviews (**b14**, [`reviews.md`](../../contracts/domains/reviews.md)),
> config/holidays/audit (**b1**, [`config-reference.md`](../../contracts/domains/config-reference.md)).
> If a shape you need is absent or wrong when you start, **do not guess and do not block** — append a
> `REQ-NNN` request to
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
> and mock behind the `services/admin` / `services/partnerCenter` seam meanwhile (operating-rules §6).
> Record every mock in your report so it swaps cleanly once the endpoint lands.
## 2. Required reading (do this first)
**Operating rules & checklists**
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how
you work, the gate, the contract/handoff lanes, the mock-then-swap rule (§6).
- [`../_shared/definition-of-done.md`](../_shared/definition-of-done.md) — the bar this phase adds to (§6).
**Product / business truth (read before designing any screen)**
- [`../../../product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md) —
**the admin operational spine**: the five worklists (verification / refund / payout / support-alert /
RBAC), the append-only audit trail, config-change auditing, in-app notifications, and that
back-office must reason over the Shamsi calendar + `iranian_holidays`. This is *why* each console exists.
- [`../../../product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md) —
the **partner-center / merchant-of-record** model: the licensed center (پروانه تأسیس + مسئول فنی +
نماد اعتماد الکترونیکی) sponsors nurses and **may be the merchant-of-record / invoice issuer**; the
commission invoice (gross / platform commission / BNPL commission / VAT on commission) and the
config-driven VAT rate. This drives the partner portal's settlement/invoice view and what it may show.
- [`../../../product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md) —
`audit_logs` (immutable, append-only, `changed_fields_json`), `platform_configs` (typed by `data_type`;
the seeded keys — `platform_fee_rate`, `vat_rate`, `dispute_window_hours`, `nurse_payout_interval_days`,
`evv_location_tolerance_meters`, `min_rating_for_support_alert`, cancellation-tier defaults, BNPL keys),
and `iranian_holidays` (`holiday_date`, `name_fa`, `type`, `is_bank_closed`). These shape the config
editor, the holiday manager, and the audit viewer.
- [`../../../product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md) —
`partner_centers` fields (`name`, `legal_entity_type`, `moh_establishment_permit_no`,
`technical_director_nurse_user_id` + `technical_director_license_no`, `enamad_code`, `settlement_iban`
(enc), `is_merchant_of_record`, `commission_rate`, `admin_user_id`, `is_active`, `verified_at`); the
1:N relations to `nurse_profiles` / `bookings` / `invoices`; and the **deferred** tables
(`organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`) that have **no
UI** this phase.
> **No wireframe exists for the admin or partner screens** (the wireframe is the mobile customer/nurse
> flow A1E3 — see [`product/wireframes/index.html`](../../../product/wireframes/index.html) for the
> brand/RTL baseline only). The GTM notes explicitly flag the backoffice/ticket/partner surfaces as a
> design gap. **You design these screens from scratch with the `frontend-designer` skill** against the
> brand system — desktop, sidebar-driven, dense worklist layout, RTL-first.
**Contracts & types (the source of truth for shapes — do not guess)**
- [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md) —
**the primary contract** (b15): admin ticket queue + internal-note composer, the **support-alert
worklist** (list/filter, assign, resolve), **partner-center** CRUD/verify/sponsor + roster, and **RBAC**
role grant/revoke. The user-vs-admin `is_internal` filtering note is here.
- The per-domain admin contracts: [`verification.md`](../../contracts/domains/verification.md) (b6 — queue,
record-step, upsert-credential, approve/reject, signed-URL document fetch),
[`refunds.md`](../../contracts/domains/refunds.md) (b11 — initiate/approve/reject, decomposition,
channel, BNPL ETA, invoices), [`payouts.md`](../../contracts/domains/payouts.md) (b13 — batch
preview/list/detail, initiate, retry, transfer-reference reconcile),
[`reviews.md`](../../contracts/domains/reviews.md) (b14 — moderation queue, set-status),
[`config-reference.md`](../../contracts/domains/config-reference.md) (b1 — list/update config, config
change history, holidays CRUD, audit-log list, support-alert raise/list).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope (`OperationResult`
→ already unwrapped by `clientFetch`), `snake_case` routes/properties, pagination (`page`/`page_size`,
default/max), **enums as stable string codes** (mirror as string-literal unions; labels are i18n keys),
**IRR as integer string on the wire** (parse integer-safe, Toman display-only), UTC timestamps →
**Shamsi** on the client.
**Code to mirror (existing patterns — copy, don't invent)**
- `client/src/services/auth/*` (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` /
`index.ts`) — the exact shape every new domain service copies.
- The prior domains this phase extends: `services/verification` (f5), `services/refunds` (f10),
`services/payouts` (f12), `services/reviews` (f13), `services/tickets` + `services/notifications` (f14).
**Extend these with the admin endpoints; do not re-create them.** Only the genuinely new admin-owned
data (config, holidays, audit, support-alerts, RBAC) and partner centers get **new** domains
(`services/admin`, `services/partnerCenter` — §3).
- The f5 document uploader / signed-URL handling (the verification document viewer reuses the signed-URL
pattern), the f0 money util + status chip + price-breakdown, the f10 refund decomposition rendering, the
f12 payout shapes, the f14 ticket thread.
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, the admin route group, layouts,
i18n, theme, fetch services, anti-patterns.
**Design**
- **Invoke the `frontend-designer` skill** before building any screen. Every admin worklist, the document
viewer, the refund/payout action panels, the config editor, the holiday manager, the audit table, the
support-alert board, and the entire partner portal go through it — brand palette, tokens, typography,
the `App*` library, the desktop sidebar density, table/virtualization treatment, empty/loading/error
states, RTL mirroring. **Do not hand-roll colours, spacing, or table styling.** Because there is no
wireframe, the designer skill owns the visual language for the whole back office.
## 3. Scope — build this
Two **new** domain services (`services/admin`, `services/partnerCenter`) plus **admin endpoint additions**
to the existing `verification` / `refunds` / `payouts` / `reviews` / `tickets` domains, and the
**admin-shell screens** + the **partner-center portal** on top of them. Everything is **internal-only**,
behind **role-gated routes** in the f0 admin shell (the partner portal is its own scope). Build desktop-
first, RTL, both locales, query-cached, virtualized lists, minimal re-renders.
> **Routing & RBAC.** All admin screens mount under the f0 **admin route group** (e.g.
> `(private-routes)/admin/…`) gated by an admin role from `AuthContext`; the partner portal mounts under a
> **distinct** partner-scope segment (e.g. `(private-routes)/partner/…`) gated by the partner-center
> scope. A `support` admin must not see the payout-run control, a `moderator` must not see the refund
> tool, etc. — the **server enforces** role scopes on every command (don't rely on UI hiding for
> security), but the UI **also** hides/disables actions the current role can't perform so a user never
> sees a control that will 403. Drive this from a small `useAdminCapabilities()` selector off `AuthContext`
> roles. Update the **Project Structure** tree in `client/CLAUDE.md` for the new route segments + services.
### 3.1 `services/admin` (new domain — config, holidays, audit, support-alerts, RBAC)
Copy the `auth` service shape into `client/src/services/admin/`. Types come from the b1 + b15 contracts —
do not invent. (Sub-namespace by area if it keeps files small: `config`, `holidays`, `audit`,
`supportAlerts`, `rbac`.)
- **`types.ts`** — string-literal unions + DTOs from the contracts:
- **Config:** `PlatformConfig` (`key`, `value` (string on the wire), `data_type` enum
`string|int|decimal|bool|json`, `description`, `updated_at`, `updated_by`), and a
`ConfigChange` (audit) row (`changed_at`, `actor`, `old_value`, `new_value`).
- **Holidays:** `Holiday` (`id`, `holiday_date`, `name_fa`, `type` enum `official|religious|national`,
`is_bank_closed`).
- **Audit:** `AuditLogEntry` (`id`, `entity_type`, `entity_id`, `action`, `changed_fields_json`
(parsed to a typed record), `actor`, `created_at`) + paged list envelope; filter params
(`entity_type`, `entity_id`, `actor_id`, `from`/`to`, `action`).
- **Support alerts:** `SupportAlert` (`id`, `type` enum e.g.
`low_rating|no_show|evv_mismatch|verification_expiry|fraud_signal`, `status` enum
`open|assigned|resolved`, `entity_type`, `entity_id`, nullable `booking_id`/`review_id`,
`assigned_to`, `resolved_at`, `resolution_note`, `created_at`).
- **RBAC:** `AdminRole` enum (`super_admin|admin|support|finance|moderator`), `RoleGrant`
(`user_id`, `role`, `granted_by`, `granted_at`, `revoked_at`).
- **`keys.ts`** — `adminKeys.config()`, `adminKeys.configHistory(key)`, `adminKeys.holidays(yearOrRange)`,
`adminKeys.audit(filters, page)`, `adminKeys.supportAlerts(filters, page)`, `adminKeys.roles(userId?)`.
Filters + page are part of the key so each filter caches separately.
- **`apis/clientApi.ts`** wrapping `clientFetch` (exact `snake_case` routes from the contracts):
config `list_platform_configs` / `update_platform_config` / `get_config_change_history`; holidays
`list_holidays` / `create_holiday` / `update_holiday`; audit `list_audit_logs`; support-alerts
`list_support_alerts` / `assign_support_alert` / `resolve_support_alert`; RBAC `list_roles` /
`grant_role` / `revoke_role`.
- **`hooks/` (one per file):** `usePlatformConfigs`, `useUpdatePlatformConfig`, `useConfigChangeHistory`,
`useHolidays`, `useUpsertHoliday`, `useAuditLogs`, `useSupportAlerts`, `useAssignSupportAlert`,
`useResolveSupportAlert`, `useAdminRoles`, `useGrantRole`, `useRevokeRole`. Mutations **invalidate**
the relevant `adminKeys` (and the config-history key after a config save) on settle so cached data
isn't refetched needlessly.
- **`index.ts`** barrel (hooks only).
### 3.2 `services/partnerCenter` (new domain — partner portal + admin-side center management)
Copy the same skeleton into `client/src/services/partnerCenter/`. Types from the b15 contract.
- **`types.ts`** — `PartnerCenter` (`id`, `name`, `legal_entity_type`, `moh_establishment_permit_no`
(پروانه تأسیس), `technical_director_nurse_user_id`, `technical_director_license_no`, `enamad_code`,
**`settlement_iban` masked last-4 only** (never the full IBAN), `is_merchant_of_record`,
`commission_rate`, `admin_user_id`, `is_active`, `verified_at`); `SponsoredNurse` (nurse summary +
verification badge); `SponsoredBooking` (booking summary the center covers); `CenterSettlementRow` /
`CenterInvoice` (only meaningful when `is_merchant_of_record` — gross / platform commission / BNPL
commission / VAT / total, `moadian_reference_number`, `pdf` signed-URL link). A **center
verification/onboarding state** enum (`draft|pending_verification|verified|suspended`).
- **`keys.ts`** — `centerKeys.list(filters)`, `centerKeys.detail(id)`, `centerKeys.sponsoredNurses(id)`,
`centerKeys.sponsoredBookings(id, filters)`, `centerKeys.settlement(id, filters)`,
`centerKeys.myCenter()` (the partner-scope "my center" view).
- **`apis/clientApi.ts`** — admin-side: `list_partner_centers`, `get_partner_center`,
`create_partner_center`, `update_partner_center`, `verify_partner_center`, `set_partner_center_active`,
`assign_nurse_to_partner_center`; partner-scope: `get_my_partner_center`,
`list_my_sponsored_nurses`, `list_my_sponsored_bookings`, `list_my_settlement`.
- **`hooks/`:** admin — `usePartnerCenters`, `usePartnerCenter`, `useCreatePartnerCenter`,
`useUpdatePartnerCenter`, `useVerifyPartnerCenter`, `useSetPartnerCenterActive`,
`useAssignNurseToPartnerCenter`; partner-scope — `useMyPartnerCenter`, `useMySponsoredNurses`,
`useMySponsoredBookings`, `useMySettlement`. Mutations invalidate `centerKeys`.
- **`index.ts`** barrel (hooks only).
### 3.3 Admin: verification review queue
The staff lens over `services/verification` (f5) — add the admin endpoints to that domain (queue list,
record-step, upsert-credential, approve/reject, signed-URL document fetch); reuse the f5 step/status enums
and the trust badge.
- **Queue list** (`/admin/verification`) — a paginated, **status-filtered** worklist of nurses with
pending verification (filter by aggregate status `pending|in_review`; sort by oldest-first). Each row:
nurse name/photo, the **step progress** (e.g. "۳ از ۵"), the next pending step, submitted-at (Shamsi),
and any expiring credential warning. Virtualize/paginate; empty state "صف خالی است / Queue clear".
- **Per-nurse review screen** (`/admin/verification/[nurseId]`) — the ordered steps with status chips, a
**document viewer** that fetches each `verification_documents` item via a **signed URL** (never a public
URL — handle **loading / expired-link → re-request / load-error** states; the URL is short-lived, so
fetch on demand, don't cache the URL in a long-lived query). For each step an admin can **pass** or
**reject (with a required reason)**`record_step`; for credential steps a **structured credential
entry** form (`credential_number`, issuing authority, issue/expiry dates) → `upsert_credential`. The
screen's **Approve / Reject** action (`approve_verification` / `reject_verification`) is enabled **only
when all required steps are `passed`** (the **server** flips `is_verified` transactionally — the client
only enables the button and shows a confirmation; it **never** writes `is_verified` itself, §5).
Approve/reject require a confirmation dialog and, on reject, a reason. On success, **invalidate** the
queue + the nurse detail so the row leaves the queue.
### 3.4 Admin: refund tooling (inside the ticket lens)
The staff lens over `services/refunds` (f10) + `services/tickets` (f14). Refunds are **admin-only and
ticket-linked** — the entry point is **from a ticket**, not a standalone form.
- **Refund panel in the admin ticket view** — opened from a ticket (the admin global ticket queue, §3.9):
shows the linked booking, computes a **preview** of the tiered `refund_percentage_applied` from the
cancellation policy and the **fee/payout decomposition** (`platform_fee_refunded_irr` +
`nurse_payout_refunded_irr`) via the f0 **price-breakdown** primitive (the **server** computes these —
the client renders the preview the contract returns, never recomputes the percentage), a **channel
selector** (`psp_card` / `bnpl_revert` / `manual_bank`), and — for BNPL — an **ETA banner**
(`expected_customer_refund_eta`). Actions: **Initiate refund** (`initiate_refund`, carries the
`ticket_id`), **Approve** (`approve_refund`), **Reject** (`reject_refund`). If the nurse was already
paid, surface that a **clawback** will be created (read-only notice — the server creates it). States:
preview / confirm / **provider-revert-failure → retry** (BNPL/PSP), success. Invalidate the refund +
ticket + (if shown) the customer refund-status query on settle.
### 3.5 Admin: payout dashboard
The action surface over `services/payouts` (b13), explicitly deferred from f12. Reuse the f12 payout
shapes, the payout-status enum, and the money util.
- **Batch dashboard** (`/admin/payouts`) — a list of `nurse_payout_batches` with status
(`pending|processing|paid|partially_failed|failed`), period (holiday-shifted `period_start`/
`period_end`, Shamsi), `payout_count`, `total_amount`, and a **holiday-shift indicator** when the
processing date moved off a bank-closed day. Empty/loading/error states.
- **Batch preview → run** — a **"preview next batch"** action (`preview_payout_batch`) that shows the
**eligibility breakdown**: which completed/unpaid bookings qualify (EVV confirmed **and**
`dispute_window_ends_at` passed), the per-nurse roll-up, the **clawback-netting line**
(`clawback_applied_irr`), and the holiday-shifted processing date — all **server-computed**; the client
only renders the preview (never computes eligibility or the holiday shift, §5). A **"run batch"** action
(`initiate_payout_batch`) behind a **confirmation dialog** that requires an **idempotency key** (the
contract's mechanism) so a double-click or retry **cannot pay a booking twice** (one-payout-per-booking,
§5). After running, the batch moves to **processing** → poll/refetch to **completed** or
**partially-failed**.
- **Batch detail + per-nurse drill-down** (`/admin/payouts/[batchId]`) — the per-nurse `nurse_payouts`
rows with status, the net decomposition (`gross_earnings_irr clawback_applied_irr = net_amount_irr`),
the masked IBAN (last-4), and the `transfer_reference`. A **failed** payout shows its `failure_reason`
and a **retry** action (`retry_payout`) — also idempotency-keyed. The
**`RecordPayoutTransferReference`** reconciliation action (`record_transfer_reference`) lets finance
attach the real bank transfer reference to a payout. Invalidate the batch/detail on each action.
### 3.6 Admin: review moderation queue
The staff lens over `services/reviews` (f13).
- **Moderation queue** (`/admin/reviews`) — a paginated list of reviews in `pending_moderation`
(filterable by status), each showing the rating, body, tags, the booking/nurse context, and a
**low-rating flag** (when `rating < min_rating_for_support_alert`). Actions per review:
**publish / hide / reject** (`moderate_review` with the target status; reject carries a reason).
Each transition triggers a **server-side aggregate recompute** of the nurse's rating — the client just
invalidates the review + the nurse's reviews query; it **never** computes the aggregate (§5). States:
empty ("صف بررسی خالی است / Nothing to moderate"), loading, error, optimistic-vs-confirmed on the
action. Never render `pending_moderation` content as if public.
### 3.7 Admin: config editor + change history
The config surface over `services/admin` (b1). Config edits are **money-correctness sensitive** and
**audited** (§5).
- **Config list** (`/admin/config`) — all `platform_configs` rows grouped sensibly (fees/VAT, deadlines,
EVV, BNPL, cancellation tiers), each rendered with a **typed input by `data_type`**: `bool` → switch,
`int`/`decimal` → numeric field with **range validation** (e.g. a rate field validates **01**), `json`
→ a validated JSON editor, `string` → text. Show the `description` and `updated_at`/`updated_by`.
- **Audited save**`update_platform_config` behind a **confirmation dialog** that states "this change is
audited and takes effect immediately; it does **not** retroactively change already-computed
bookings/ledger" (copy, both locales). On success show the **optimistic-vs-confirmed** save state and
invalidate the config + the change-history key.
- **Change-history drawer** — per config key, a drawer (`get_config_change_history`) listing each change
(old → new value, actor, Shamsi timestamp) so finance can prove the rate in effect at any past moment.
### 3.8 Admin: holiday calendar manager
Over `services/admin` holidays (b1).
- **Holiday manager** (`/admin/holidays`) — a calendar/list of `iranian_holidays` (by year/range), each
row `holiday_date` (Shamsi), `name_fa`, `type` chip, and an **`is_bank_closed` toggle** (this is what
shifts payout scheduling — surface that consequence in the UI copy). Add/edit a holiday
(`create_holiday`/`update_holiday`). The client **does not** compute next-business-day shifts — it only
maintains the calendar the **server** uses for scheduling (§5). States: empty, loading, error,
save-confirmation.
### 3.9 Admin: support-alert worklist + audit viewer + global ticket queue
- **Support-alert worklist** (`/admin/alerts`) — the **internal-only** triage board over
`services/admin` support-alerts: filter by `type` / `status` (`open|assigned|resolved`) / `assigned_to`;
each card shows the alert type (low-rating / no-show / EVV-mismatch / verification-expiry / fraud-signal),
the linked entity (deep-link to the booking/review/nurse), and severity styling (admin-only). Actions:
**assign** (`assign_support_alert` — to self or another admin) and **resolve**
(`resolve_support_alert` with a resolution note). States: empty ("هیچ هشدار بازی نیست / No open
alerts"), loading, error. **`support_alerts` content NEVER appears in any non-admin surface** (§5).
- **Audit-log viewer** (`/admin/audit`) — a **read-only** table over `list_audit_logs` with **filters**
(entity type/id, actor, action, date range) and **pagination/virtualization** for large result sets;
each row shows the entity, action, actor, Shamsi timestamp, and an expandable `changed_fields_json`
diff. Append-only — there are **no edit/delete affordances** (§5). Empty/loading/error states.
- **Global ticket queue + internal-note composer** (`/admin/tickets`, `/admin/tickets/[id]`) — the **admin
lens** over `services/tickets` (f14): a queue across **all** tickets (filter by status / linked booking /
`reference_code`), and the admin thread view that — unlike the user thread — **renders internal
(`is_internal`) messages distinctly** and provides an **internal-note composer** (post a message with
`is_internal=true`). The refund panel (§3.4) opens from here. The admin types include `isInternal`; the
**user-app types from f14 do not** — keep them separate so an internal note can never bleed into the
user view (§5).
- **(Optional) RBAC admin** (`/admin/roles`) — a user↔role grid over `services/admin` RBAC with
grant/revoke (confirmation + records `granted_by`/`granted_at`). If the b15 contract doesn't expose the
role endpoints when you run, **defer this screen** (file a `REQ` and build it when the endpoints land);
it is not part of the testable acceptance path. Tag it **(DEFERRED-IF-MISSING)** in your report.
### 3.10 Partner-center portal (separate authz scope)
The partner portal mounts under the **distinct partner segment** gated by the partner-center scope (a
center admin is **not** a Balinyaar admin and must see **only their own center's** data — server-enforced
tenancy; never fetch a center by raw id the user doesn't own, §5).
- **Center home / onboarding state** (`/partner`) — the center's
onboarding/verification state (`draft|pending_verification|verified|suspended`) with an
**unverified banner** when not yet active, its license fields read-mostly (پروانه تأسیس / مسئول فنی /
نماد اعتماد الکترونیکی), and `is_merchant_of_record` clearly indicated.
- **Sponsored-nurse list** (`/partner/nurses`) — the nurses this center sponsors
(`list_my_sponsored_nurses`), each with their verification badge; empty ("هنوز پرستاری اسپانسر نشده /
No nurses sponsored yet").
- **Sponsored-bookings list** (`/partner/bookings`) — the bookings the center legally covers
(`list_my_sponsored_bookings`), filterable by status/date; read-only summaries (no PII beyond what the
contract exposes to a center).
- **Settlement / invoice view** (`/partner/settlement`) — **rendered only when
`is_merchant_of_record === true`** (otherwise show a "not merchant-of-record / settlement runs through
Balinyaar" state, no settlement table): the per-booking **commission invoices** (gross / platform
commission / BNPL commission / **VAT on the commission line** / total, via the price-breakdown), the
`moadian_reference_number` when issued, and the **invoice PDF** via a signed-URL download. Money via the
f0 util (Toman display, IRR-string integer-safe). States: empty, loading, error on PDF fetch → retry.
- **Admin-side partner management** (`/admin/partners`, `/admin/partners/[id]`) — the Balinyaar-admin
surface for centers: list/create/edit a center (`create_partner_center`/`update_partner_center` — the
`settlement_iban` field is **write-then-masked**: submit a full IBAN, but the list/detail only ever
shows last-4), **verify** (`verify_partner_center`) and **activate/suspend** (`set_partner_center_active`)
toggles, and the **sponsored-nurse roster** with **assign-nurse** (`assign_nurse_to_partner_center`).
### 3.11 i18n + types housekeeping
- Fill the **`admin`** namespace (reserved in f0) and add a **`partner`** namespace to **both**
`messages/en.json` and `messages/fa.json`, in sync, RTL-first (`fa` default). Every user-visible string
is a key, including the **Persian legal terms** (پروانه تأسیس, مسئول فنی, نماد اعتماد الکترونیکی,
سامانه مودیان) and the admin worklist labels.
- Types come from the published contracts; any gap → append a `REQ-NNN` to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the
`services/admin` / `services/partnerCenter` seam (or the extended-domain seam) meanwhile.
**(DEFERRED)** — `organizations` / `organization_nurses` employer model, `fraud_flags` ML console,
`recurring_booking_schedules` recurrence UI ([`data-model/13`](../../../product/data-model/13-partner-centers-and-future.md)
— modeled-but-inactive, **no UI**); full سامانه مودیان e-invoice automation / digital-signature pipeline
(the portal only **views** the issued invoice/ref + PDF — it does not submit); push/SMS notification
channels; an analytics-warehouse dashboard over `system_events`; on-demand/instant payout; per-nurse
payout-frequency settings. Build none of these — flag them in the report if a contract field hints at them.
## 4. Mocks & seams in this phase
This is a **frontend** phase — its only "seams" are the domain services behind which a mock `clientApi`
lives until each backend endpoint is merged (operating-rules §6, frontend-checklist last bullet). **Do
not** introduce backend seams — `IObjectStorage` (signed URLs), `IBankTransferProvider` (payouts),
`IBnplProvider`/`IPaymentGateway` (refund reverts), `IMoadianClient` (invoices), `ILicenseVerificationService`
(partner-center verify), the audit interceptor, the notification dispatcher — those are **server-side**
(b1/b6/b11/b13/b14/b15) and the frontend never touches them. **Reuse** the f5 signed-URL document pattern,
the f10 refund decomposition, the f12 payout shapes, and the f14 ticket thread — do not re-implement them.
- **`services/admin` seam** — if b1/b15 admin endpoints aren't merged, ship a mock `clientApi` (same
method signatures) returning realistic **typed configs** (one per `data_type` so the typed inputs +
the 01 range validation are exercisable), a **change-history** trail, **holidays** (some bank-closed),
a **paged audit log** with `changed_fields_json` diffs, and a **support-alert** list spanning all
statuses/types — including at least one of **each** alert type so the worklist filters are testable.
- **`services/partnerCenter` seam** — a mock returning a **merchant-of-record** center (so the settlement
view renders) **and** a non-MoR center (so the "settlement runs through Balinyaar" state renders),
sponsored nurses (verified + unverified), sponsored bookings, and a couple of commission invoices with a
fake 22-digit `moadian_reference_number` and a stub PDF URL. The `settlement_iban` mock returns **last-4
only**.
- **Extended-domain seams** (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) — for the admin
endpoints added to existing domains, mock the **new** admin methods behind the same domain `clientApi`
(e.g. a verification queue with documents needing a signed URL, a refund preview with a fee/payout split
and a BNPL ETA, a batch **preview** with an eligibility breakdown + a **partially-failed** batch + a
**failed** payout to retry, a moderation queue with a low-rating review, an admin ticket thread **with
an internal note**).
Record **every** mock in your **frontend report** and the
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) so the real-endpoint swap is
a one-file change per domain (the hooks/screens stay unchanged — only `apis/clientApi.ts` flips). Append
the corresponding shape requests to
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
## 5. Critical rules you must not get wrong
- **Internal-only data never reaches a non-admin.** `support_alerts` and internal ticket notes
(`is_internal=true`) are **staff-only** — they appear **only** in admin routes, are fetched **only** by
admin-scoped queries, and must never be joined into or rendered in any customer/nurse/partner surface.
The **admin** ticket types include `isInternal` (admins see internal notes, styled distinctly); the
**user-app types from f14 deliberately omit it** — keep the two type surfaces separate so an internal
note cannot bleed into a user view. Treat any internal content in a non-admin payload as a backend
defect — file it via `for-backend.md`, don't render it.
- **Internal-only & role-gated routes.** Every admin screen is behind the role-gated admin shell; the
partner portal is a **separate authz scope**. The **server enforces** role scopes on every command (a
`support` admin can't run a payout, a `moderator` can't refund, a center admin sees only their own
center) — **never rely on UI hiding for security** — but the UI must **also** hide/disable controls the
current role can't use so a user never sees a button that 403s.
- **The server is the only authority; the client never computes the load-bearing values.** Never compute
on the client: the `is_verified` flip (the server flips it transactionally when all required steps pass —
the UI only enables Approve and confirms), the refund **percentage / fee-vs-payout decomposition**
(render the server's preview), payout **eligibility** or the **holiday-shifted** processing date (render
the server's breakdown), the review **aggregate recompute** (just invalidate), and config parsing beyond
rendering by `data_type`. The client renders contract values and issues commands.
- **Money correctness (verbatim — the sacred invariants across b9b13):** money is **IRR `BIGINT`, no
floats** — parse the wire integer string with the f0 integer-safe util, never `Number()`/float math;
**Toman is display-only**. The three booking amounts always satisfy **gross = commission + payout**
(`gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`); render every breakdown so it sums.
Escrow is an **append-only, balanced double-entry ledger** — refund/payout/clawback figures are
ledger-derived; a clawback **nets**, it does not auto-reverse, and a nurse's payable balance **may go
negative** (don't clamp). Payout gating is **dispute-window gating**: an amount is eligible only after
EVV completion **AND** `dispute_window_ends_at < now()` — never show "eligible"/run a payout for an
amount still in its dispute window, and **never compute eligibility on the client**. **One payout per
booking** (`nurse_payout_booking_links.booking_id` is UNIQUE) — the "run batch"/"retry payout" commands
are **idempotency-keyed** so a double-click or retry can never pay a booking twice; render the status the
contract returns (`pending`/`processing`/`paid`/`failed`/`partially_failed`), never an optimistic "done".
**Webhook idempotency** is a server concern, but its client consequence is real: settlement/transfer is
**never instant** — poll/refetch the status, don't assume completion.
- **Commission invoice / VAT (partner portal):** the platform issues **only its commission invoice** (never
the nurse's service invoice); **VAT applies to the commission line, not the gross service fee**; the VAT
rate is **config-driven** (read from `platform_configs.vat_rate`, snapshotted on the invoice) — never
hardcode 10%. **Merchant-of-record drives the settlement/invoice view**: render the settlement table
**only** when `is_merchant_of_record === true`; the issuer/settlement target follows `partner_centers`,
not a hardcoded platform.
- **Refunds are admin-only and ticket-linked.** No customer self-service initiation — the refund panel
opens **from a ticket** and every initiate carries the `ticket_id`. The decomposition (fee leg vs payout
leg) and channel (`psp_card`/`bnpl_revert`/`manual_bank`) come from the server; a post-payout refund
creates a **clawback** (server-created — show it read-only).
- **Append-only audit is read-only.** The audit viewer has **no edit/delete affordance**; config edits are
**audited** and a config change does **not** retroactively alter already-computed bookings/ledger — say
so in the save confirmation. Finance must be able to prove the commission/VAT rate at any past moment via
the change-history drawer.
- **Config typing + validation.** Render each config by its `data_type` and **validate** at the boundary
(a rate field is **01**, an int is integer, a bool is a switch, json must parse) before allowing the
audited save.
- **Signed URLs are short-lived.** Verification documents and invoice PDFs load via **signed URLs**
(never public) — fetch the URL **on demand**, don't cache it in a long-lived query; handle
**loading / expired → re-request / load-error → retry**.
- **PII / masking.** `settlement_iban` (center) and the payout `iban_snapshot` are encrypted/masked —
show **last-4 only**, never a full IBAN; `transfer_reference`/`moadian_reference_number` are opaque
strings shown for reconciliation. Don't log full sensitive values. Verification documents are PII —
signed-URL only, never embedded as a public asset.
- **Tenancy.** A partner-center admin sees **only their own center**; an admin sees only what their role
scopes. Never fetch by a raw id the current principal doesn't own (server-enforced — don't bypass it).
- **Frontend conventions (non-negotiable):** fetch only through `clientFetch` in `services/{domain}`;
TanStack Query caching with deliberate keys (filters + page in the key) + invalidation/`setQueryData`
(no needless refetch — switching a worklist filter or paging must not refetch loaded data); **large
worklists are paginated/virtualized** with **empty/loading/error** states each; minimise re-renders
(`select` to subscribe to slices, stable refs, colocate filter state low — a fast-changing config-input
value must not re-render the whole config table); MUI primitives stay MUI, shared composites (the
worklist row/table, the document viewer, the refund panel, the config row, the audit row, the
support-alert card, the partner settlement row) live at `src/components/…` with co-located tests;
colours from `tokens.css` (status chips off the `--bal-{success,warning,info,error}` semantic tokens);
**both locales in sync**, RTL-correct (the desktop sidebar mirrors; Persian legal terms render
correctly); no layout above `[locale]`; respect the RSC/client boundary.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus this phase's specifics:
- [ ] `services/admin` and `services/partnerCenter` exist in the `auth`-service shape (types from the
b1/b15 contracts, `keys.ts` with filters/page in the key, `apis/clientApi.ts`, one hook per file,
hooks-only `index.ts`), and the **admin endpoints are added to the existing**
`verification`/`refunds`/`payouts`/`reviews`/`tickets` domains (not re-created); mutations invalidate
the right keys.
- [ ] **Verification queue** lists pending nurses, the **per-nurse review** loads documents via **signed
URLs** (loading/expired/error handled), supports **pass/reject + reason** per step and **structured
credential entry**, and **Approve** is enabled only when all required steps pass — the client never
writes `is_verified`.
- [ ] **Refund tooling** opens **from a ticket**, renders the **server-computed** fee/payout decomposition
+ channel + BNPL ETA, supports initiate/approve/reject with provider-revert-failure → retry, and
shows the read-only clawback notice when applicable.
- [ ] **Payout dashboard** shows batches with status + holiday-shift indicator, a **batch preview** with
the **server-computed eligibility breakdown** + clawback netting, a **run-batch** action that is
**idempotency-keyed** (no double-pay), and a **batch detail** with per-nurse rows, masked IBAN,
`transfer_reference`, a **failed-payout retry**, and transfer-reference reconciliation.
- [ ] **Review moderation** queue supports **publish/hide/reject** (reject reason); the client invalidates
and never computes the aggregate; `pending_moderation` content is never shown as public.
- [ ] **Config editor** renders each value by `data_type`, **validates** (rate 01), saves with an
**audited-save confirmation** ("audited; effective immediately; not retroactive"), and exposes a
**change-history drawer**.
- [ ] **Holiday manager** lists/adds/edits holidays with the **`is_bank_closed` toggle** (consequence
surfaced); the client never computes the next-business-day shift.
- [ ] **Audit-log viewer** is **read-only** (no edit/delete), filtered (entity/actor/action/date) and
**paginated/virtualized** with a `changed_fields_json` diff.
- [ ] **Support-alert worklist** filters by type/status/owner and supports **assign/resolve** with a note;
**no `support_alerts` content appears in any non-admin surface**.
- [ ] **Global ticket queue** + **internal-note composer** exist (admin sees `is_internal` notes, styled
distinctly); the refund panel opens from here.
- [ ] **Partner-center portal** (separate scope) shows the center's onboarding/verification state,
sponsored nurses, sponsored bookings, and — **only when `is_merchant_of_record`** — the
settlement/invoice view (commission/VAT decomposition, signed-URL PDF, masked IBAN); the **admin-side
partner management** supports create/edit/verify/activate + assign-nurse, IBAN write-then-masked.
- [ ] Every admin route is **role-gated** (controls hidden/disabled per the current role) and the partner
portal is a **separate scope**; UI hiding never substitutes for the server's enforcement.
- [ ] `admin` + `partner` i18n namespaces in `en.json` **and** `fa.json` in sync (incl. پروانه تأسیس /
مسئول فنی / نماد اعتماد الکترونیکی / سامانه مودیان); RTL verified on the desktop shell.
- [ ] Shared composites (worklist row/table, document viewer, refund panel, config row, audit row,
support-alert card, partner settlement row) live at the shared level each with a co-located
`*.test.tsx`; **all money via the f0 util** (no float math); `npm run check` green; `npm run test:ci`
green.
- [ ] Any contract gap is a `REQ-NNN` in
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and the
corresponding client-side mock is behind the seam and recorded in the report + mock registry.
- [ ] `client/CLAUDE.md` *Project Structure* updated for `services/admin`, `services/partnerCenter`, the
admin endpoint additions to existing domains, the `/admin/*` route segments, the `/partner/*` scope,
and the new shared admin components; the **frontend-designer skill was invoked** for the visual work.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` signed in as an **admin** (with the b1/b6/b11/b13/b14/b15 admin endpoints live, or the
seam mocks if not yet merged):
1. **Verify a nurse.** Open **/admin/verification** → pick a pending nurse → the per-nurse screen loads
each document via a **signed URL** (let one expire → it offers re-request). **Pass** the remaining
steps (reject one with a reason to see the reason captured), enter a **structured credential**, then
**Approve** → confirm dialog → the nurse leaves the queue and their trust badge flips. *Expected:*
Approve is disabled until all required steps are `passed`; the client never wrote `is_verified` itself.
2. **Process a refund.** From the **/admin/tickets** queue, open a ticket linked to a booking → open the
**refund panel** → see the **server-computed** tiered percentage + **fee/payout decomposition** + the
**channel selector**; for a BNPL booking see the **ETA banner**. **Initiate → Approve** → success; the
decomposition sums (`fee_refunded + payout_refunded` reconciles). If the nurse was already paid, a
**clawback notice** shows (read-only). *Expected:* the refund carries the `ticket_id`; a forced
provider-revert failure shows **retry**.
3. **Preview + run a payout batch.** Open **/admin/payouts** → **preview next batch** → the **eligibility
breakdown** lists only EVV-confirmed, dispute-window-closed bookings, the **clawback-netting** line, and
the **holiday-shifted** processing date. **Run batch** (confirmation requires an **idempotency key**) →
it goes **processing****completed** (or **partially-failed**). Open the batch detail → a **failed**
payout offers **retry**; reconcile a `transfer_reference`. *Expected:* clicking "run" twice does **not**
pay any booking twice; an amount still in its dispute window never appears as eligible.
4. **Moderate a review.** Open **/admin/reviews** → a `pending_moderation` review (with a low-rating flag)
**publish** it → it leaves the queue and the nurse's public reviews update (server-recomputed
aggregate); **hide**/**reject** behave likewise (reject captures a reason). *Expected:* pending content
is never shown publicly; the client didn't compute the aggregate.
5. **Edit a config value (audited + history).** Open **/admin/config** → edit `vat_rate` → the input
validates **01** (try `1.5` → blocked) → **save** → the confirmation states it's **audited, immediate,
non-retroactive** → open the **change-history drawer** → the old→new change, actor, and Shamsi
timestamp appear. *Expected:* the change is recorded; already-computed bookings are unaffected.
6. **Resolve a support alert.** Open **/admin/alerts** → filter to **open****assign** an alert to
yourself (status → assigned) → **resolve** it with a note (status → resolved). *Expected:* the alert is
internal-only — it appears in **no** customer/nurse/partner view anywhere.
7. **Holiday manager.** Open **/admin/holidays** → add a holiday with **`is_bank_closed` on** → it's
listed; (cross-check in §3 step 3 that the next payout preview's processing date shifts off it,
server-side). *Expected:* the client only maintains the calendar; it doesn't compute the shift.
8. **Audit viewer.** Open **/admin/audit** → filter by entity/actor/date → results paginate/virtualize;
expand a row to see the `changed_fields_json` diff. *Expected:* **no** edit/delete control exists.
9. **Partner portal (separate scope).** Sign in as a **partner-center admin****/partner** shows the
center's onboarding/verification state; **/partner/nurses** and **/partner/bookings** show **only that
center's** sponsored nurses/bookings; **/partner/settlement** renders the commission/VAT invoice view
**only when the center is merchant-of-record** (a non-MoR center shows the "settlement via Balinyaar"
state), with a signed-URL PDF and a **masked IBAN (last-4)**. Sign back in as a Balinyaar admin →
**/admin/partners** → create/verify/activate a center and **assign a nurse**. *Expected:* a center admin
cannot see another center's data; the IBAN is never shown in full.
10. **RBAC + i18n + RTL + caching.** A `support`-role admin **cannot** see the payout-run control; a
`moderator` **cannot** see the refund tool. Switch `fa``en` → every label (incl. the Persian legal
terms) translates and the desktop sidebar mirrors. Switch worklist filters / page the lists → React
Query Devtools shows **separate cache entries per filter/page** and **no refetch** of loaded data.
11. **Gate:** `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs to update:** `client/CLAUDE.md` *Project Structure* — add `services/admin`,
`services/partnerCenter`, the admin endpoint additions to the existing
`verification`/`refunds`/`payouts`/`reviews`/`tickets` domains, the `/admin/*` route segments + the
`/partner/*` scope, the new shared admin/partner components, and a one-line note on the
`useAdminCapabilities()` role-gating selector and the signed-URL on-demand-fetch pattern so they're
reused. If you discover/decide a business rule the `product/` docs don't capture (e.g. an admin-only
payout-preview field, a partner-center onboarding sub-state, a config validation bound), record it in
the relevant `product/**.md`**don't invent rules**; record decisions and flag uncertain ones in your
report.
- **Contracts to consume:** the primary
[`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md)
(b15 — admin tickets, support-alert worklist, partner centers, RBAC) **plus** the per-domain admin
endpoints in [`verification.md`](../../contracts/domains/verification.md) (b6),
[`refunds.md`](../../contracts/domains/refunds.md) (b11),
[`payouts.md`](../../contracts/domains/payouts.md) (b13),
[`reviews.md`](../../contracts/domains/reviews.md) (b14), and
[`config-reference.md`](../../contracts/domains/config-reference.md) (b1). Derive **all** types from
these — **never** guess shapes. Any missing field/filter/endpoint (e.g. the payout **preview**
eligibility breakdown, the refund decomposition preview, the config `data_type`, the partner settlement
rows, the admin `is_internal` message flag, the RBAC role endpoints) → append a `REQ-NNN` to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the seam meanwhile.
- **Handoff & report:** append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`../../shared-working-context/reports/frontend-phase-15-report.md`](../../shared-working-context/reports/frontend-phase-15-report.md)
(operating-rules §7) — what was built, **what is now testable and exactly how** (the §7 steps), which
client-side mocks sit behind the `services/admin` / `services/partnerCenter` / extended-domain seams and
how each swaps to the real endpoint, the contracts consumed, the `REQ` gaps filed, and — since this is
the **final frontend phase** — a short **"MVP complete" closeout** noting any deferred-if-missing screen
(e.g. RBAC admin) and the modeled-but-inactive tables with no UI. Update the mock registry
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for every client-side mock.
- **Memory:** save a `project` memory note (with a `MEMORY.md` pointer) for the non-obvious decisions this
phase locks in — the admin-shell route-gating + `useAdminCapabilities()` role selector, the separate
partner-center authz scope and its tenancy, the admin-vs-user ticket type split (`isInternal` admin-only),
the merchant-of-record-gated settlement view + commission-only/VAT-on-commission invoice rule, the
signed-URL on-demand-fetch pattern for documents/PDFs, the idempotency-keyed payout-run/retry, and the
"server is the only authority" boundary (no client-side `is_verified` flip / eligibility / decomposition /
aggregate / holiday-shift). Don't record what the code/docs already make obvious.
+335
View File
@@ -0,0 +1,335 @@
# Frontend Phase 2 — Onboarding & profiles (customer, patient, nurse, bank)
> **Mission:** with auth and roles in place, turn a freshly-logged-in user into a *usable account*.
> A family completes the "who is care for?" onboarding and registers their first **patient** (the
> care recipient, who is not the payer); a nurse bootstraps a public profile and adds the payout
> **bank account** that the verification pipeline and payouts later depend on. This phase implements
> the wireframe onboarding screens (A3, A4, E1) plus the customer profile and the nurse profile /
> bank settings, and stands up the `services/profiles`, `services/patients`, and `services/nurse`
> domains following the f0 data pattern. It is the gate that makes addresses (f3) and booking (f7)
> possible — a booking needs a patient with a known gender, and a payout needs a verified bank account.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-1-b2.md`](./frontend-phase-1-b2.md) (auth/OTP/roles, `AuthContext`) + the **backend-phase-3** contract ([`identity-profiles.md`](../../contracts/domains/identity-profiles.md)) · **Unlocks:** [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md) (addresses & geo), [`frontend-phase-7-b8.md`](./frontend-phase-7-b8.md) (booking request — needs a patient)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
The user can now log in by phone-OTP and the app knows their role (f1-b2). What they *can't* yet do is
say **who they are caring for**. Balinyaar's whole model rests on the **customer ≠ patient** split: the
payer (an adult child, a spouse) is almost never the care recipient (an elderly parent, an infant, a
post-surgical adult). This phase captures that split for the customer side and, for the nurse side, the
two pieces of profile state that everything downstream hangs off — a public-facing profile and a payout
destination. No search, no booking, no money yet — just the identity/profile surface those later phases
read from.
**What already exists (do not rebuild):**
- **From [`frontend-phase-0.md`](./frontend-phase-0.md):** the three actor app shells + role-scoped
route groups under `[locale]`; the **customer 5-tab bottom nav** (خانه/Home · رزروها/Bookings ·
بیماران/Patients · کیف‌پول/Wallet · پروفایل/Profile); the `services/{domain}` reference pattern
(`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`) modelled on
`src/services/auth/*`; the TanStack-Query caching rules (per-domain `keys` factory, deliberate
`staleTime`/`gcTime`, **invalidate on mutation**); the money/format util in `src/utils/`; the
contracts→types pattern; the i18n namespace plan; and the shared composite components — the
**stepper / progress header**, the **status chip** (verified/pending/…), and the **phone-number
field** — which you **reuse here, not re-create**.
- **From [`frontend-phase-1-b2.md`](./frontend-phase-1-b2.md):** phone login (A1), OTP (A2), the
customer↔nurse login switch, the role router, and roles surfaced in `AuthContext` (read the current
user + role from there; don't re-fetch `/me` ad-hoc). The OTP-input and phone-field composites are
already wired — reuse them.
- **From the backend ([`identity-profiles.md`](../../contracts/domains/identity-profiles.md), b3):** the
live endpoints for customer profile, patients CRUD, nurse profile bootstrap, and nurse bank accounts
(with the IBAN-ownership inquiry). **Consume the contract; do not guess shapes.** If a shape you need
is missing or unclear, append it to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the `services/{domain}` seam meanwhile (operating-rules §6).
> **Scope fence:** this phase builds the **B7 *profile* part only** — avatar + short bio. The nurse
> **services-and-prices builder** on B7 (the "+ افزودن خدمت" list) is **(DEFERRED)** to
> [`frontend-phase-4-b5.md`](./frontend-phase-4-b5.md) (catalog & service builder). The nurse
> **available-days picker** on B7 is **(DEFERRED)** with catalog/availability. **Addresses** (the A4/E1
> sibling "address book") are **(DEFERRED)** to [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md).
> The whole **nurse verification pipeline** (B3B6, identity/Shahkar/license) is
> **(DEFERRED)** to [`frontend-phase-5-b6.md`](./frontend-phase-5-b6.md) — here the nurse only gets an
> *unverified* profile and a bank account; surfacing the "not bookable until verified" banner is part of
> f5, not this phase (a simple placeholder is acceptable).
## 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 RSC/client boundary, layouts, i18n, theme,
cookies, the `services/{domain}` fetch pattern, anti-patterns. Re-confirm the f0 *Project Structure*
additions so you place new folders correctly.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(teal `#1d4a40` / terracotta `#d98c6a` palette, `tokens.css`, typography, the `App*` library, the
layout shells, the hard UI rules). The onboarding stepper, gender toggle, condition chips, patient
cards, empty states, and the bank-status panel are all visual deliverables and **must** go through it.
- **Product — the business rules (the source of truth, not the code):**
- [`../../../product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— the customer/patient split, role-staged KYC, and what is MVP vs DEFERRED (customer national-ID
KYC is deferred; do **not** add it to any form).
- [`../../../product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md)
— the exact tables and columns behind these screens (`customer_profiles`, `patients`,
`nurse_profiles`, `nurse_bank_accounts`) and their constraints (single-primary bank account;
`iban_hash` uniqueness; `matched_national_id`; the guarded `is_verified`).
- **Product — the visual baseline:**
[`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — read screens **A3**
(برای چه کسی؟ — 2-step progress bar, single-select), **A4** (ثبت بیمار — name, age, gender toggle مرد/زن,
condition chips), **A5** (Home — the "complete patient record" nudge you land on), **E1** (لیست بیماران —
patient cards + "+ افزودن بیمار", Patients tab active), and the **B7** profile header (photo + short
bio). Match the RTL Persian layout, the brand colours, and the status legend (green = verified, amber =
pending, grey = manual/later).
- **Contracts:** [`../../contracts/domains/identity-profiles.md`](../../contracts/domains/identity-profiles.md)
(the b3 contract you consume — endpoints, request/response shapes, enums, masking, failure cases),
plus the cross-cutting conventions you already follow:
[`api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case routes,
pagination, status codes) and [`money-and-types.md`](../../contracts/conventions/money-and-types.md)
(enums-as-codes, **`gender` = `male`/`female` is load-bearing**, masked PII like last-4 of an IBAN).
- **Code to mirror:** `src/services/auth/*` (the exact service skeleton every new domain copies) and the
three shared composites from f0 (`stepper/progress header`, `status chip`, `phone-number field`) — read
their props before reusing them.
- **The handoff you're handed:**
[`../../shared-working-context/backend/handoff/after-backend-phase-3.md`](../../shared-working-context/backend/handoff/after-backend-phase-3.md)
(what b3 shipped, which endpoints are live, what's mocked behind a seam — e.g. the IBAN-ownership
inquiry).
## 3. Scope — build this
Three new domain services, the customer onboarding flow, patient CRUD, the customer profile, and the
nurse profile + bank settings. Every user-visible string is an i18n key in **both** `en.json` and
`fa.json` (RTL-first); every list is cached and invalidated per the f0 pattern; every screen is built
through the **frontend-designer** skill.
### 3.1 The domain services (copy the f0 pattern)
Three services under `src/services/`, each with `types.ts` (from the b3 contract — never guessed),
`keys.ts` (a query-key factory), `apis/clientApi.ts` (wrapping `clientFetch`; `serverApi.ts` only if an
RSC needs prefetch), `hooks/use*.ts` (one hook per file), and `index.ts`:
- **`services/profiles`** — the customer & nurse *profile* domain.
- `useCustomerProfile()` (`useQuery`) → `GET …/me/customer_profile` (or the b3 route).
- `useUpsertCustomerProfile()` (`useMutation``PUT …/me/customer_profile`) — name, contact,
`default_emergency_contact_name`/`default_emergency_contact_phone`; **invalidates** the customer-profile
query and patches `AuthContext`'s cached `/me` if profile-completion changes.
- `useNurseProfile()` (`useQuery`) → the nurse's own profile.
- `useCreateNurseProfile()` / `useUpdateNurseProfile()` (`useMutation``POST/PUT …/me/nurse_profile`)
— bootstrap (`is_verified` stays `false`, server-owned — never sent by the client), then edit
`bio`/`avatar_url`/`years_experience`. Invalidate the nurse-profile query on success.
- **`services/patients`** — the care-recipient domain (customer-scoped).
- `usePatients()` (`useQuery`, paginated per `api-conventions.md`) → `GET …/patients``{ items, total }`.
- `useCreatePatient()` (`POST …/patients`), `useUpdatePatient()` (`PUT …/patients/{id}`),
`useArchivePatient()` (the archive/soft-delete route — sets `is_active=false`, **not** a hard delete).
- All three mutations **invalidate `patientKeys.list()`** (or `setQueryData` to splice the row) so the
E1 list never refetches needlessly; archive optimistically removes/greys the card then reconciles.
- **`services/nurse`** — the nurse payout **bank account** sub-domain (kept separate from the profile
because verification/payouts read it independently).
- `useNurseBankAccounts()` (`useQuery`) → list (usually one primary).
- `useAddNurseBankAccount()` (`POST …/me/nurse_bank_accounts`) — submit IBAN (Sheba) + account-holder
name; the server kicks off the ownership inquiry (mocked behind `IBankAccountOwnershipVerifier` in
b3) and returns the account in a **pending** state.
- `useSetPrimaryBankAccount()` (where the contract exposes it) — single-primary enforcement is
server-side; reflect it in cache.
- All mutations invalidate the bank-accounts query so the pending→verified/mismatch transition shows
on the next read (poll/refetch — see §3.5).
> Where a b3 endpoint isn't live when you build, ship a **mock `clientApi`** behind the same seam (a
> fixed in-memory patient list; a bank account that flips pending→verified after one refetch; a
> mismatch account for a known test IBAN) and record it in your report + the mock registry, so it swaps
> cleanly once the real endpoint lands.
### 3.2 Customer onboarding — A3 → A4 (the "who is care for" flow)
A two-step wizard, mounted in the customer route group, run once after first login (and re-enterable
from the patient list). **Reuse the f0 stepper/progress header** for the 2-step bar.
- **A3 · برای چه کسی؟ (Who is care for?)** — a single-select radio list of relations: **پدر/مادر**
(parent), **همسر** (spouse), **فرزند** (child), **خودم** (self). Selecting a relation carries forward
to pre-shape A4 (e.g. "خودم" pre-fills the patient as the customer). Primary CTA **ادامه** advances the
stepper; back is allowed. The relation is a stable enum code (`parent`/`spouse`/`child`/`self`), an
i18n-labelled chip — never a hardcoded Persian string in logic.
- **A4 · ثبت بیمار (Add patient)** — the patient form: **full name**, **age** (or birth date per the
contract — map to `birth_date`), **gender toggle (مرد/زن → `male`/`female`)**, and **condition chips**
(multi-select: سالمند/elderly, پس از جراحی/post-surgery, دیابت/diabetes, + بیشتر). On submit it calls
`useCreatePatient()`; the chosen relation is stored with the patient. CTA **ذخیره و ادامه** creates the
patient, invalidates the list, and **routes to Home (A5)** where the "complete patient record" nudge is
already shown.
Validation: name required; **gender required** (it drives same-gender matching downstream — see §5);
age/birth-date validated; conditions optional. Surface 400 field errors from the envelope inline.
### 3.3 Patient list & CRUD — E1
The **Patients** bottom-nav tab. Reuse the f0 cards/empty-state primitives where they exist.
- **E1 · لیست بیماران (Patients list)** — patient cards showing relation + name, age/gender, and condition
chips, with a prominent **+ افزودن بیمار** CTA. States to build: **loading skeleton**, **empty state**
(no patients → the A4 add flow as the prominent CTA), and the populated list. Patients tab active in the
bottom nav.
- **Add / Edit** — the same A4 form, reused for create and edit (`useCreatePatient` / `useUpdatePatient`).
Edit pre-fills from the cached row.
- **Archive** — a confirm dialog ("آرشیو بیمار؟") then `useArchivePatient()`; the card is removed/greyed.
**Never a hard delete** — archive only (`is_active=false`); a patient referenced by a past booking must
survive.
> The full **patient record viewer** (E2 — medications/routine/history/tasks tabs) and **nurse visit
> notes** (E3) are **(DEFERRED)** to [`frontend-phase-13-b14.md`](./frontend-phase-13-b14.md). E1 here is
> the list/CRUD shell only.
### 3.4 Customer profile + emergency contact
A profile screen under the customer **پروفایل/Profile** tab: editable `first_name`/`last_name`,
`preferred_language`, optional `avatar_url`, and the **emergency contact** (`default_emergency_contact_name`
+ `_phone`, the phone via the **reused f0 phone-field**). Saves through `useUpsertCustomerProfile()`,
invalidates the profile query, and reflects profile-completion back into the Home nudge. Do **not** add a
national-ID field — customer KYC is **(DEFERRED)** and the column stays unused at launch.
### 3.5 Nurse profile bootstrap + bank settings (the B7 *profile* part)
Mounted in the nurse route group.
- **Nurse profile bootstrap (B7 header)****avatar/profile photo** (upload via the contract's
avatar/object-storage route; if not live, mock behind the seam) + **short bio** (+ `years_experience`
if the contract carries it). `useCreateNurseProfile()` on first entry, then `useUpdateNurseProfile()`.
The nurse profile is created **unverified** (`is_verified=false`, server-owned) and **not bookable**
show a neutral "تکمیل احراز هویت برای فعال‌سازی" placeholder pointing at verification (the real banner is
f5). The **services-and-prices builder** and **available-days picker** on B7 are **(DEFERRED)** to f4.
- **Nurse bank-account settings (payout IBAN)** — an **IBAN (شبا) entry** form (Sheba format validation
client-side: `IR` + 24 digits) + account-holder name, submitted via `useAddNurseBankAccount()`. Render
the three ownership-inquiry states off the contract's status field, each a distinct UI state built with
the **reused f0 status chip**:
- **pending** (`matched_national_id` null / inquiry in flight) — amber "در حال استعلام مالکیت حساب" panel;
poll/refetch (`refetchInterval` while pending, then stop) so the transition appears without a manual
reload.
- **verified** (`matched_national_id=true`, `is_verified=true`) — green "حساب تاییدشد" + the **masked**
IBAN (last 4) per the money-and-types masking rule.
- **mismatch** (`matched_national_id=false`) — a clear, **non-accusatory** error state: "حساب باید به نام
خودتان باشد" with a re-enter CTA. Ownership mismatch is gated server-side; surface it as a friendly
domain error, never a raw 4xx toast.
## 4. Mocks & seams in this phase
This is a **frontend** phase; it owns no backend seam. It **consumes** the b3 contract and, where an
endpoint isn't live yet, mocks **behind the `services/{domain}` seam** (a mock `clientApi`) per
operating-rules §6 — the same pattern f0 established. Specifically you may need to mock:
- **Patients CRUD** — an in-memory list seeded with one patient, supporting create/edit/archive.
- **The IBAN-ownership inquiry result** — the real check is the backend's
`IBankAccountOwnershipVerifier` seam (introduced in **b3**, recorded in the
[mock registry](../../shared-working-context/reports/mocks-registry.md)); on the client just drive the
**pending → verified** transition (and a **mismatch** for a known test IBAN) so the three UI states are
demonstrable end-to-end.
- **Avatar/photo upload** — if the contract's storage route isn't live, accept the file and echo a fake
URL behind the seam.
Record every client-side mock in your phase report and in the mock registry with **how f-later (or the
b3 merge) swaps it out** — the swap must be implementation-only, no call-site changes.
## 5. Critical rules you must not get wrong
- **Customer ≠ patient — never collapse them.** The payer and the care recipient are distinct rows; a
patient is created under the signed-in customer and is **tenancy-scoped server-side**. Don't assume the
logged-in user is the patient (except the explicit "خودم" relation, which still creates a patient row).
- **Patient `gender` is REQUIRED.** It is load-bearing for **same-gender caregiver matching** (a near-hard
requirement) used by search (f6) and booking (f7). The gender toggle (`male`/`female`) must be a required
field — never default it, never let the form submit without it.
- **Tenancy is enforced server-side — surface friendly errors.** A `403`/`404` from acting on someone
else's patient/profile is the fetch layer's concern (it already toasts auth errors); your hooks add only
the **domain-specific** message ("این بیمار در دسترس شما نیست"). Never try to enforce tenancy on the
client or expose another customer's data.
- **No customer national-ID KYC.** It is DEFERRED; the column is unused at launch. Do not add a national-ID
field to the customer profile or gate browsing/booking on it.
- **`is_verified` is server-owned and guarded.** The client **never** sends or sets it; a freshly
bootstrapped nurse profile is unverified and not bookable. Reflect that read-only state; the flip happens
only inside the backend verification transaction (f5).
- **Bank account: three states, money-safe.** Render **pending-verification**, **ownership-mismatch**, and
**verified** distinctly; the IBAN is **masked** (last 4) once stored; one primary account per nurse is
server-enforced. **First payout is gated on `matched_national_id=true`** — never present a mismatched or
pending account as ready to pay. The mismatch copy must be **non-accusatory**.
- **Archive, don't delete.** Patient removal is soft (`is_active=false`) so historical bookings stay intact.
- **Caching is a feature.** Patient/profile/bank queries use deliberate `queryKey`/`staleTime`, and every
create/edit/archive **invalidates** (or `setQueryData`) — never re-fetch data already in cache. Keep the
bank `refetchInterval` only while pending; stop it once resolved. Minimise re-renders (colocate form
state, stable callbacks).
- **RSC/client boundary, RTL, both locales, tokens.** Forms and lists are client components (no
`next-intl/server`/`next/headers` in them); `fa` is default and **RTL** — design RTL-first and verify the
gender toggle, chips, and stepper mirror correctly; every string in **both** `en.json`/`fa.json`; colours
from `tokens.css`; MUI v9 API + the pre-built themes only. **MUI primitives stay MUI**; the stepper /
status-chip / phone-field are the **f0 shared composites — reuse, don't re-implement.** Any genuinely new
shareable composite (e.g. a `PatientCard`, a `GenderToggle`, a `ConditionChips`, a `BankStatusPanel`)
lives at the shared `src/components/…` level with a co-located `*.test.tsx`.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus phase-specific:
- [ ] **A3 → A4** runs end-to-end: a new customer picks a relation, fills the patient form (name, age,
**required** gender, optional conditions), and **lands on Home (A5)** with one patient created.
- [ ] **E1** patient list works: empty state with add CTA; create/edit reuse the A4 form; archive
(soft) with confirm; the list is cached and invalidated on every mutation (no needless refetch).
- [ ] **Customer profile + emergency contact** saves and reflects profile-completion; no national-ID field.
- [ ] **Nurse profile bootstrap** (avatar + bio) creates an unverified, not-bookable profile; the
services builder + availability picker are correctly **deferred** (not stubbed as working).
- [ ] **Nurse bank account** submits an IBAN and shows all three states — **pending → verified** (mock
transition) and **ownership-mismatch** — with a masked IBAN on verify and non-accusatory mismatch copy.
- [ ] `services/profiles`, `services/patients`, `services/nurse` follow the f0 pattern (keys factory,
one-hook-per-file, invalidation); types derive from the b3 contract (or a gap is filed in
`requests/for-backend.md` and mocked behind the seam).
- [ ] New shared composites each have a co-located test; the **f0 stepper/status-chip/phone-field are
reused** (not duplicated).
- [ ] `npm run check` green; `npm run test:ci` green for the shared components added; `en.json`/`fa.json`
in sync; `client/CLAUDE.md` *Project Structure* updated for the new services/route folders.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and the b3 server, or the seam mock).
- **Customer onboarding:** log in as a customer → land on **A3**, the 2-step bar shows step 1; pick a
relation → **A4**; try to submit without gender → blocked with a required-field error; fill it and
submit → you land on **Home (A5)** and the "complete patient record" nudge is present. *Expected:* one
patient exists and the flow doesn't re-trigger on next login.
- **Patient CRUD (E1):** open the **Patients** tab → see the patient as a card (relation, name,
age/gender, condition chips). Add a second patient → it appears without a full reload (cache spliced/
invalidated). Edit it → changes persist. Archive it (confirm) → the card disappears; it is **not**
hard-deleted. Open Patients on a fresh account → the **empty state** with the add CTA. Inspect React
Query Devtools: the list query is cached and mutations invalidate it.
- **Customer profile:** edit name + emergency contact → save → the Home nudge reflects completion. Confirm
there is **no** national-ID field.
- **Nurse profile + bank:** log in as a nurse → bootstrap the profile (set avatar + a short bio) → it
saves and shows an **unverified / not-bookable** state. Open bank settings → enter an IBAN → see the
**pending** "در حال استعلام" panel, then (after the mock resolves / a refetch) the **verified** green
state with a **masked** IBAN. Enter the known **mismatch** test IBAN → see the **ownership-mismatch**
error with re-enter CTA. *Expected:* the three states are visually distinct and the verified account
shows last-4 only.
- **i18n / RTL:** switch locale → strings flip `fa``en` and `dir` flips; the gender toggle, chips, and
stepper mirror correctly. `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/profiles`, `services/patients`, `services/nurse` domains, the new shared composites
(`PatientCard`, `GenderToggle`, `ConditionChips`, `BankStatusPanel`), and any new route segments under
the customer/nurse groups. If you discover/confirm a business rule the product docs don't capture
(e.g. a relation-enum decision), record it in
[`../../../product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md)
— don't invent rules. Note any reusable pattern in `client/CLAUDE.md`.
- **Contract:** **consume** [`../../contracts/domains/identity-profiles.md`](../../contracts/domains/identity-profiles.md)
(b3) as the type source — do not guess shapes. Any gap (a missing field, an unclear enum, the
bank-status field, the avatar route) goes to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(append only — never edit backend files); mock behind the `services/{domain}` seam until b3 delivers it.
- **Handoff & report:** append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`../../shared-working-context/reports/frontend-phase-2-report.md`](../../shared-working-context/reports/README.md)
covering what was built, **what is now testable and exactly how** (the A3→A4→Home flow, patient CRUD,
the bank state transitions), what is **mocked client-side** (patients list, IBAN-inquiry transition,
avatar upload) and exactly how each swaps to the real b3 endpoint, and follow-ups for f3 (addresses
reuse this profile shell), f4 (the nurse services builder slots onto the B7 profile), and f5
(verification banner replaces the placeholder). Add/extend rows in
[`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
for every client-side mock.
- **Memory:** save a `project` memory note for the non-obvious decisions this phase fixes (the
relation-enum + customer/patient split on the client, the three bank-account UI states and the
`matched_national_id` gating, the patients caching/invalidation strategy), with a one-line pointer in
`MEMORY.md`. Don't record what the code/contract already make obvious.
+292
View File
@@ -0,0 +1,292 @@
# Frontend Phase 3 — Addresses, map picker & nurse coverage areas
> **Mission:** give both actors their *place* on the map. Customers build an **address book** — add an
> address by dropping a **map pin** and choosing **province → city → district** from cascading
> dropdowns, with one address marked **primary** — so a later booking knows where the nurse goes.
> Nurses build a **coverage-area editor** — a list of cities (whole city) or city+district areas they
> will travel to — so search can fan them out geographically. This is pure geography: no money, no
> clinical data. It consumes the `geography-addresses` contract from **backend-phase-4** and unlocks the
> booking request flow (**f7**), which needs a chosen address and a matched coverage area.
>
> **Track:** frontend · **Depends on:** [frontend-phase-2-b3](./frontend-phase-2-b3.md) (profiles,
> patients, nurse bank account) + the **backend-phase-4** contract · **Unlocks:**
> [frontend-phase-7-b8](./frontend-phase-7-b8.md) (booking request flow)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
Balinyaar is a trust-first home-nursing marketplace: families book vetted nurses who travel to the
patient's home. A booking can't happen until the platform knows **two geographic facts** — *where the
patient is* (a customer address) and *which areas a nurse will travel to* (their service areas). This
phase builds the UI for both, on top of the geography hierarchy the backend seeds. Geography here is
**named regions, not GPS radii**: the dropdowns are `provinces``cities``districts`, and a map pin
only adds precise coordinates for later EVV distance checks — it does **not** replace the region choice.
**What already exists (do not rebuild) — built by prior frontend phases:**
- **f0 foundations** — the three actor app shells (customer mobile + 5-tab bottom nav, nurse, admin), the
`services/{domain}` + TanStack Query caching pattern (the `auth` service is the canonical template:
`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use{Action}.ts` / `index.ts`), the
contracts→types pattern, the money/format utils, the shared composite components (OTP input, phone
field, **stepper/progress header**, **status chip**), and the i18n namespace baseline in both
`messages/en.json` and `messages/fa.json`. See
[frontend-phase-0.md](./frontend-phase-0.md) and `reports/frontend-phase-0-report.md`.
- **f1-b2 auth** — phone-OTP login, the role router, roles in `AuthContext` (`customer` / `nurse` /
`admin`). You read the current role to decide which editor to mount. See
[frontend-phase-1-b2.md](./frontend-phase-1-b2.md).
- **f2-b3 profiles** — the customer profile + **patient** CRUD (the address book lives alongside patients
in the customer area), and the nurse profile + bank-account settings (the coverage-area editor lives
alongside them in the nurse area). The `services/patients` and `services/profiles` domains and their
settings screens are the layout siblings you slot next to. See
[frontend-phase-2-b3.md](./frontend-phase-2-b3.md) and `reports/frontend-phase-2-report.md`.
> **You build the geography UI only.** The geo *hierarchy* (provinces/cities/districts) is reference data
> the backend seeds and serves; you cache it and render dropdowns. You never seed or mutate it.
## 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) — how you
work, the gate, the handoff, and the tick-list (RSC boundary, `clientFetch`/services, Query caching,
minimal re-renders, MUI primitives, i18n both locales, tokens, RTL).
- **The contract you consume:** [`../../contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md)
(published by backend-phase-4) — the **source of truth** for the geo lookup endpoints
(`ListProvinces` / `ListCities` / `ListDistricts`), the `customer_addresses` CRUD + set-primary
endpoints, and the `nurse_service_areas` add/remove endpoints, with their exact routes, payload shapes,
enums, status codes, and which fields are masked (the encrypted street address). Plus the conventions it
assumes: [`api-conventions.md`](../../contracts/conventions/api-conventions.md) (envelope, snake_case
routes, pagination, localisation header, `409` on conflict) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (`name_fa`/`name_en` reference
data, UTC timestamps, **coordinates are not money** — but treat lat/lng as the contract declares them).
> If the published contract is missing a shape you need (e.g. a `geocode/reverse` helper, a
> `set_primary` endpoint, or the coordinate field names), **do not guess** — append the request to
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
> and mock it behind the `services/{domain}` seam meanwhile (operating-rules §6).
- **Product docs (the business truth — read before designing the forms):**
- [`../../../product/data-model/02-geography.md`](../../../product/data-model/02-geography.md) — the
hierarchy (`provinces` 1:N `cities` 1:N `districts`), why it's tables not static lists
(`sort_order`/`is_active` drive ordered, toggleable dropdowns), and why `nurse_service_areas` is a
named-district join (a `district_id = NULL` row = the **entire city**), `UNIQUE(nurse_id, city_id,
district_id)`.
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
— how coverage areas feed search (city required, district optional; a city-level row means whole city),
the white-space second-tier cities (Mashhad, Isfahan, Shiraz, Tabriz, Ahvaz, Qom) the area model must
serve, and why districts are optional. (The same-gender filter lives in **f6**, not here — don't
build it.)
- The `customer_addresses` notes in
[`../../../product/data-model/01-identity-and-access.md`](../../../product/data-model/01-identity-and-access.md):
encrypted address + coordinates, **filtered `UNIQUE(customer_id) WHERE is_primary = 1`** (exactly one
primary).
- **Invoke the `frontend-designer` skill** — this is mandatory for every screen, form, dropdown, chip,
map panel, and empty/loading/error state you build. It is the design/brand contract (teal/terracotta
palette, tokens, typography, the `App*` library, the mobile RTL shell, the bottom-nav placement). All
visual work goes through it; do not hand-roll colours or spacing.
- **Code to mirror:** `client/src/services/auth/*` (the service template you copy for `geography` and
`addresses`), `client/src/services/patients/*` and the address-book/patient screens from **f2-b3** (the
same list → add/edit → set-default interaction pattern), and the shared composite components from **f0**
(`StatusChip`, the stepper/progress header) you reuse.
## 3. Scope — build this
Two services and three screen areas. Real names below — build them exactly.
### 3.1 `services/geography` — cached reference-data lookups (cascading dropdowns)
The geo hierarchy is **reference data that almost never changes**, so it is cached aggressively and shared
by *every* consumer (this phase's two editors, plus search in f6). Build it once, here.
- `services/geography/types.ts``Province`, `City`, `District` (each `{ id, name_fa, name_en, sort_order,
is_active }`; `City` carries `province_id`, `District` carries `city_id`) — derived from the contract.
- `services/geography/keys.ts` — a query-key factory: `geographyKeys.provinces()`,
`geographyKeys.cities(provinceId)`, `geographyKeys.districts(cityId)`.
- `services/geography/apis/clientApi.ts` — wraps `clientFetch` over the contract's `ListProvinces` /
`ListCities(province_id)` / `ListDistricts(city_id)` endpoints. **Active-only, `sort_order`-ordered**
(the server already filters/orders; don't re-sort client-side beyond what the contract guarantees).
- `services/geography/hooks/` — one hook per file: `useProvinces.ts`, `useCities.ts` (enabled only when a
province is selected), `useDistricts.ts` (enabled only when a city is selected). **Long `staleTime`**
(e.g. `Infinity` or hours) and a generous `gcTime` so a province/city's children are fetched **once**
per session and served from cache on every revisit and across both editors.
- A reusable `<CascadingRegionSelect>` composite (shared, `src/components/geography/`) that renders the
three dependent MUI `Select`s (province → city → district), driving the child queries, with per-level
**loading**, **disabled inactive regions**, and the **"whole city" affordance** when a city has no
districts (or the user leaves district empty). It exposes `{ provinceId, cityId, districtId }` and an
`onChange` — both the address form and the coverage editor reuse it. **City is required; district is
optional** — leaving district empty is a real choice, never an error.
### 3.2 Customer address book + map-pin add/edit (the customer area)
Mounted in the customer area next to patients (f2-b3). Build:
- `services/addresses` — full domain service mirroring `auth`/`patients`: `types.ts` (`CustomerAddress`
`{ id, title, city_id, district_id|null, address_line (masked/full per contract), latitude, longitude,
is_primary }`), `keys.ts` (`addressKeys.list()`, `addressKeys.detail(id)`), `apis/clientApi.ts`
(list / create / update / delete / **set-primary**), and hooks: `useAddresses.ts` (list),
`useCreateAddress.ts`, `useUpdateAddress.ts`, `useDeleteAddress.ts`, `useSetPrimaryAddress.ts`.
**Every mutation invalidates `addressKeys.list()`** (set-primary also flips the old primary, so
invalidate, don't hand-patch) so the list reflects reality without an over-fetch elsewhere.
- **Address book screen** — list of the customer's addresses as cards: title, city/district label
(`name_fa`/`name_en` by locale), a **primary badge** (reuse the f0 `StatusChip`), and per-card
edit / delete / "set as primary" actions. **Empty state** ("no addresses yet → add your first") and a
loading skeleton. Deleting/setting-primary confirms inline.
- **Add / Edit address form** (a dialog or routed sub-screen — match the f2 patient-edit pattern):
- The `<CascadingRegionSelect>` from §3.1 (province → city → district).
- A **map pin picker** `<AddressMapPicker>` (shared, `src/components/geography/`): a map component
(or the lightweight stand-in described in §4) where the user drags/taps to drop a pin; it emits
`{ latitude, longitude }`. Center it on the chosen city when possible. The picked coordinates are
sent with the create/update request.
- A `title` (e.g. "خانه"/"محل کار") and a free-text `address_line` (the street detail — note it is the
**encrypted** field per the contract; render the masked value the contract returns on read, full only
where the contract allows on the owner's own edit).
- A **"set as primary"** toggle. Validation: **city required**, a **pin required** (surface the
"map-pin-required" error inline if missing), district optional.
- **Single-primary rule on the client:** the UI presents primary as a single-select; the server enforces
the filtered unique index, but the client must never show two primaries — after a `setPrimary`,
invalidate the list so exactly one card shows the badge.
### 3.3 Nurse coverage-area editor (the nurse area)
Mounted in the nurse area next to the profile/bank settings (f2-b3). Build:
- `services/serviceAreas` — domain service mirroring the template: `types.ts` (`NurseServiceArea`
`{ id, city_id, district_id|null }`), `keys.ts` (`serviceAreaKeys.list()`), `apis/clientApi.ts`
(list / add / remove), hooks `useServiceAreas.ts`, `useAddServiceArea.ts`, `useRemoveServiceArea.ts`.
Add/remove **invalidate `serviceAreaKeys.list()`**.
- **Coverage-area editor screen** — a chip/list of the nurse's areas, each chip labelled by city, and by
city + district when a district is set ("whole city" shown explicitly when `district_id` is null). An
**add-area control** built from the §3.1 `<CascadingRegionSelect>` plus a **"whole city" vs "specific
districts" toggle**: choosing "whole city" sends `district_id: null`; choosing "specific districts"
requires picking a district (and lets the nurse add several district rows under the same city).
- **Duplicate prevented inline:** before calling the add mutation, check the in-cache list — if the
`(city_id, district_id)` pair already exists (treating `null` district as a real value), show an inline
"you already cover this area" message and don't fire the request. The server also returns `409` on the
`UNIQUE(nurse_id, city_id, district_id)` violation — handle that `409` as the same inline message
(belt-and-braces; the client check is the fast path, the server is the source of truth).
- **Empty state** — no areas yet → a warning that the nurse **won't appear in search** until they add at
least one coverage area (per the search business doc). Remove-area confirms inline.
### 3.4 i18n + tokens
Every user-visible string (titles, field labels, "whole city", "specific districts", "set as primary",
the empty/error/duplicate messages, the map "drop a pin" helper) is a key in **both** `en.json` and
`fa.json`, in sync, under sensible namespaces (e.g. `address`, `coverage`, `geo`, reusing `common`). `fa`
is default and RTL — verify the dropdowns, chips, and map controls mirror correctly. Colours come from
`tokens.css`; no hardcoded hex in `sx`.
### (DEFERRED) — out of scope, do not build
- **Same-gender filter / search filters** — built in [frontend-phase-6-b7](./frontend-phase-6-b7.md).
- **Map-based *discovery*** (browsing nurses on a map) — DEFERRED per the search doc; this phase's map is
only a pin-picker for one address.
- **Reverse-geocoding "find my city from the pin"** — only if the b4 contract ships a `geocode/reverse`
helper; otherwise the region is chosen by dropdown and the pin only refines coordinates. If you want it
and the shape is missing, request it (don't invent it).
- **Admin geo management** (CRUD of provinces/cities/districts) — admin console, f15.
## 4. Mocks & seams in this phase
This is a **frontend** phase: the only seam you own is the client-side `services/{domain}` boundary.
- **Geocoding / maps** is mocked **server-side** behind the **`IGeocoder`** seam introduced in
backend-phase-4 — you do **not** introduce or own it. The client just sends the picked
`{ latitude, longitude }`; the server (mock today) does any address↔coordinate work. **Reuse it via the
contract**, don't re-create it.
- **The map component itself:** use a real map widget if one is already in the client; otherwise build a
**lightweight stand-in** `<AddressMapPicker>` — a static map image / simple draggable-marker panel that
still emits real `{ latitude, longitude }` — behind a small component boundary so a real Neshan/Google
map drops in later without touching the form. Record this stand-in in your frontend report so it's
swapped cleanly.
- **If backend-phase-4 isn't merged when you start:** build all three services (`geography`, `addresses`,
`serviceAreas`) against a **mock `clientApi`** behind the same `services/{domain}` seam (canned
provinces/cities/districts incl. Tehran's 22 districts and a couple of white-space cities; in-memory
address & area lists honouring single-primary and the duplicate rule), append any contract gap to
`for-backend.md`, and add a row to the **mock registry**
([`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md))
so the swap to the real endpoints is a one-file change per service.
## 5. Critical rules you must not get wrong
- **District is optional, "whole city" is a real choice — not missing data.** A `district_id = null` area
or address means *the entire city*; it is a deliberate selection, never an empty/invalid field. The
duplicate check and the chip label must treat `null` as a real value.
- **City is required.** No address and no coverage area may be saved without a city; surface the error
inline. (And a pin is required on an address — surface "map-pin-required" inline.)
- **Inactive regions disappear.** Only `is_active` provinces/cities/districts appear in dropdowns, ordered
by `sort_order`. Don't render toggled-off regions (the server filters; don't reintroduce them).
- **Exactly one primary address per customer.** The UI presents primary as single-select and invalidates
the list after `setPrimary` so exactly one badge shows; never display two primaries. The server's
filtered unique index is the source of truth — surface its outcome, don't fight it.
- **Duplicate coverage areas are blocked.** Enforce `(city_id, district_id)` uniqueness inline before
firing the add, and map the server's `409` to the same inline message.
- **Cache the geo hierarchy aggressively.** Provinces/cities/districts use a long `staleTime` so dropdowns
are served from cache on revisit and shared across both editors (and later search) — refetching
reference data on every dropdown open is a defect this phase exists to prevent. Address/area lists, by
contrast, invalidate on every mutation.
- **Named regions, not radii.** Don't model coverage as a GPS radius; the pin is *only* extra precision on
an address for later EVV — the bookable geography is the city/district choice.
- **RSC/client boundary & re-renders.** The forms, map, and dropdowns are client components; keep state
colocated low (the form owns its `{provinceId, cityId, districtId, lat, lng}`), use stable `onChange`
refs, and let TanStack Query own server state — no needless re-renders as the user clicks through the
cascade.
- **MUI primitives stay MUI; composites stay shared.** `Select`, `TextField`, `Chip`, `Dialog`, `Button`
are MUI/`App*`; `<CascadingRegionSelect>` and `<AddressMapPicker>` are shared composites in
`src/components/geography/` (both reused by ≥1 screen), each with a co-located `*.test.tsx`.
- **i18n both locales, RTL-first, tokens for colour.** Every string in `en.json` **and** `fa.json`;
verify RTL mirroring of the cascade and chips; colours from `tokens.css`.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/geography`, `services/addresses`, `services/serviceAreas` exist following the `auth`
service template (types from the contract, `keys.ts`, `apis/clientApi.ts`, one hook per file),
with the documented caching: long `staleTime` for geo, mutation-invalidation for addresses & areas.
- [ ] The customer **address book** lists addresses, adds/edits one via the **cascading dropdowns + map
pin**, and **sets a primary** (exactly one badge), with empty/loading/error states.
- [ ] The nurse **coverage-area editor** adds whole-city and city+district areas, shows them as chips,
**blocks a duplicate inline** (and on `409`), and warns when empty that the nurse won't appear in
search.
- [ ] `<CascadingRegionSelect>` and `<AddressMapPicker>` are shared composites with co-located tests;
MUI primitives are reused, not re-implemented.
- [ ] `npm run check` is green; `npm run test:ci` is green (shared components added); `en.json` and
`fa.json` are in sync and RTL-correct.
- [ ] Any contract gap is in `for-backend.md`; the map stand-in and any pre-merge mock `clientApi` are in
the **mock registry**; `client/CLAUDE.md` *Project Structure* is updated for the new
`services/{geography,addresses,serviceAreas}` and `src/components/geography/` folders.
## 7. How to test (what a human can verify after this phase)
Run the client: `cd client && npm run dev`, sign in (f1-b2 OTP), and:
1. **Cascading dropdowns + caching.** As a customer, open *Add address*. Select a province → the city
dropdown loads its cities; select a city → the district dropdown loads (or shows "whole city" when the
city has none). Open *Add address* again — the province/city/district lists come **from cache** (no
refetch; confirm in React Query Devtools). Inactive regions never appear.
2. **Add an address with a map pin + set primary.** Pick city + district, **drop a pin** on the map (the
form sends real lat/lng), enter a title + street, toggle "set as primary", save. It appears in the
address book with a **primary badge**. Add a second address, set *it* primary → exactly one badge moves;
the first is no longer primary. Try saving without a city or without a pin → inline errors.
3. **Nurse coverage areas + duplicate block.** Switch to the nurse area → *Coverage*. With no areas, see
the "won't appear in search" warning. Add a **whole-city** area (district empty) → a chip appears. Add a
**city + district** area → another chip. Try adding the **same** `(city, district)` again → an inline
"already covered" message, no request fired; if you force it past the client (or the server is hit),
the `409` shows the same message. Remove an area → it disappears.
4. **i18n / RTL.** Flip locale to `en` and back to `fa`: every label/empty/error/duplicate string
translates, the cascade and chips mirror correctly in RTL, and colours match the brand tokens.
5. `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/geography`, `services/addresses`, `services/serviceAreas` domains and the
`src/components/geography/` composites; note the **aggressively-cached geo reference-data pattern**
(long `staleTime`, shared key factory) as a reusable convention so f6 search reuses it, not reinvents
it. Fix any doc drift you touch.
- **Contract:** **consume** [`../../contracts/domains/geography-addresses.md`](../../contracts/domains/geography-addresses.md)
— derive every type from it; do **not** guess shapes. Any missing/ambiguous shape (coordinate field
names, masking of `address_line`, a `set_primary` route, a `geocode/reverse` helper) is appended to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
— you never edit backend files.
- **Handoff & report:** append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`../../shared-working-context/reports/frontend-phase-3-report.md`](../../shared-working-context/reports/frontend-phase-3-report.md)
— what shipped (the two editors + three services + two composites), **what is now testable and exactly
how** (the steps in §7), what is mocked client-side (the map stand-in; the pre-merge mock `clientApi`s if
used) and how f-next swaps each, the contract consumed, and follow-ups (the address chosen here feeds the
**f7** booking request; coverage areas feed **f6** search). Update the **mock registry**
([`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md))
for the map stand-in and any mocked client API.
- **Memory:** save a `project` memory note for the non-obvious decisions — the **geo cache strategy**
(long `staleTime`, shared `geographyKeys` factory reused by addresses, coverage, and search), the
**`<CascadingRegionSelect>` / `<AddressMapPicker>` composites**, and the **"`district_id = null` = whole
city"** rule the booking and search phases must honour — with a one-line pointer in `MEMORY.md`.
+331
View File
@@ -0,0 +1,331 @@
# Frontend Phase 4 — Catalog browse & nurse service builder
> **Mission:** light up the two faces of the configurable service catalog. For the **family/customer**
> this is the **Home (A5)** screen — greeting, the search bar, the four-tile service-category grid
> (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی), and the complete-patient-record nudge —
> the front door of the whole app. For the **nurse** this is the **"add a service" builder (B7 services)**:
> a stepper that walks pick-category → answer required/optional option groups → set price + price unit,
> producing a priced **variant** (the atomic bookable unit), plus the list/edit/deactivate surface for a
> nurse's own offerings. This is what makes nurses *appear* in search (f6) and what a customer browses, so
> it must be exactly right about money, required-option validation, and reference-data caching.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md) (addresses & geo, cascading province/city/district, nurse coverage editor) + backend **phase b5** contract (`catalog`) · **Unlocks:** search & discovery ([`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the hinge between *identity/geo* (f1f3, done) and *discovery/booking* (f6+). A nurse cannot
be found until she has at least one **active, priced variant**; a customer has nowhere to start until the
**Home category grid** exists. This phase builds both, against the backend's **Catalog & pricing** domain
(b5): admin-seeded `service_categories``service_option_groups``service_option_values`, and the nurse
layers `nurse_service_variants``nurse_service_variant_options`. The bookable unit is the **variant**, never
the nurse and never the category — search, booking, and pricing all operate on variants downstream.
The product framing: transparent, **nurse-set** pricing per variant is a deliberate differentiator versus the
opaque "توافقی/negotiable" incumbents — so the price the nurse enters and the way we *display* it (price +
unit, with session count) is brand-load-bearing, not a detail.
**What already exists (do not rebuild) — built by prior frontend phases:**
- **f0** ([`frontend-phase-0.md`](./frontend-phase-0.md)) — the three actor app **shells** and route groups
(customer mobile shell with the **5-tab bottom nav** خانه/رزروها/بیماران/کیف‌پول/پروفایل · nurse shell ·
admin shell); the **`services/{domain}` + TanStack Query** reference pattern (`types.ts` / `keys.ts` /
`apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`); the **types-from-contract** convention; the
**shared composite components** including the **stepper/progress header**, **status chip**, OTP input,
phone field; the **money/format util** (`formatIrrToToman`, integer-safe IRR parse, Shamsi date display)
in `src/utils/`; the i18n namespace conventions in both `messages/en.json` and `messages/fa.json`; the
RTL baseline and `tokens.css` brand colours. **Reuse all of it — do not re-create the shell, the stepper,
the money util, or the services pattern.**
- **f1-b2** — phone-OTP login, the role router, and roles in `AuthContext`. You read the current role to
decide *customer Home* vs *nurse builder* chrome; you do **not** touch auth.
- **f2-b3** — onboarding, patient CRUD, customer & nurse profiles, nurse bank-account settings. The
**patient list/record state** is what the Home "complete patient record" nudge points at; the **nurse
profile** is the parent the variant builder hangs off (B7 = "تکمیل پروفایل و خدمات"; this phase owns the
**services part**, the profile-bio/photo part is already in f2).
- **f3-b4** — address book + map picker + cascading **province → city → district** dropdowns, and the **nurse
coverage-area editor** (`nurse_service_areas`). You do **not** rebuild geo; the *service areas* a nurse
declares there are what fan the variant into search (f6) — out of scope here, just don't regress it.
> The variant builder produces *pricing*; the coverage editor (f3) produces *geography*; **search (f6)**
> joins them. This phase ships neither search nor the index — wiring the Home search bar to results is f6.
## 2. Required reading (do this first)
**Product / domain (business truth — read before designing any screen):**
- [`../../../product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
— the catalog model in plain language: admin defines categories + configurable option groups/values; each
**nurse defines variants** (category + chosen option values + own `price` + `price_unit`); the five price
units (`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`); `display_name` auto-generates from
option labels but is nurse-editable; **deactivate not delete**; catalog snapshotted onto bookings. This is
the *why* behind every validation rule below.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline.
Study **A5 (خانه / Home)**: greeting + avatar, search bar (`جستجوی خدمت یا پرستار…`), the **service-category
grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی), the **complete-patient-record nudge card**,
and the **bottom tab nav** (Home active). And **B7 (تکمیل پروفایل و خدمات)**: the services-and-prices list
(`مراقبت سالمند — ساعتی ۲۸۰٬۰۰۰ تومان/ساعت`, `+ افزودن خدمت`) — this phase builds the *services* half of B7.
Note the legend (green=verified, amber=pending) and the deep-green brand / cream surface / Vazirmatn font.
**Contract to consume (the source of truth for shapes — do not guess):**
- [`../../contracts/domains/catalog.md`](../../contracts/domains/catalog.md) — written by **backend-phase-5**.
This is where the real routes, request/response payloads, enums, and pagination live. Read it end-to-end
before writing a single type. If it is not yet published or a needed shape is missing, follow the seam
procedure in §4 (mock behind `services/catalog` and append a request for backend).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
envelope (`OperationResult`/`ApiResult`, already unwrapped by `clientFetch`), `snake_case` routes/JSON,
status codes, **mandatory pagination** (`page`/`page_size``items`+`total`) on the variant list,
`name_fa`/`name_en` reference-data localisation.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
**money is IRR Rials as an integer string on the wire**, parsed integer-safe and rendered via the f0
money util; **Toman is display-only**; enums cross as stable string codes (`per_hour`/`per_session`/
`per_half_day`/`per_day`/`per_24h`) mirrored as string-literal unions with **i18n labels, never a label
hardcoded off the code**.
**Engineering & design rules:**
- [`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) and
[`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, `services/{domain}` +
Query caching + invalidate-on-mutation, one-hook-per-file, minimal re-renders, MUI v9 primitives reused,
both locales in sync, tokens-based colours, RTL.
- **Invoke the `frontend-designer` skill** for *all* visual work here (Home, the category grid/tiles, the
builder stepper UI, the variant list/cards, every empty/loading/error/success state). It is the brand/
design contract — palette, tokens, typography, the `App*` library, layout shells, the hard UI rules. Do
not hand-craft visuals outside it.
- The existing **f0 `services/auth/*`** and the **f3 `services/geo` + `services/address`** services — copy
their exact structure for the new `services/catalog`. Reuse the f0 **stepper** and **status chip**; do not
fork them.
## 3. Scope — build this
Two surfaces (customer Home, nurse builder) + the supporting `services/catalog` domain. Build every state
(loading / empty / error / validation / success) the digest's "Notes for UI" calls for.
### 3.1 `services/catalog` domain (the data layer the screens consume)
Create `client/src/services/catalog/` mirroring the f0 pattern:
- `types.ts` — string-literal unions + DTOs derived from [`catalog.md`](../../contracts/domains/catalog.md):
`PriceUnit = 'per_hour' | 'per_session' | 'per_half_day' | 'per_day' | 'per_24h'`; `ServiceCategory`
(`id`, `name_fa`, `name_en`, `sort_order`, optional `icon`/`slug`); `ServiceOptionGroup` (`id`,
`service_category_id` nullable = cross-category, `name_fa`/`name_en`, **`is_required`**, `sort_order`);
`ServiceOptionValue` (`id`, `option_group_id`, `name_fa`/`name_en`, `sort_order`); `NurseServiceVariant`
(`id`, `service_category_id`, `price` as **IRR digit-string**, `price_unit`, `session_count` nullable,
`display_name`, `is_active`, the chosen `options: { option_group_id, option_value_id }[]`). Mirror the
exact casing/nullability from the published swagger — do not invent.
- `keys.ts` — a query-key factory: `catalogKeys.categories()`, `catalogKeys.categoryOptionGroups(categoryId)`,
`catalogKeys.myVariants()` (nurse), `catalogKeys.variant(id)`.
- `apis/clientApi.ts` — wrappers over `clientFetch` for each route in the contract (see §3.4). Add
`apis/serverApi.ts` (`serverFetch`) **only** if the Home category grid is prefetched in an RSC (see 3.2).
- `hooks/` (one hook per file): `useServiceCategories.ts`, `useCategoryOptionGroups.ts` (query),
`useMyVariants.ts` (paginated nurse list), `useCreateVariant.ts`, `useUpdateVariant.ts`,
`useDeactivateVariant.ts` (mutations). **Reference data (categories, option groups/values) is cached with
a long `staleTime`/`gcTime`** — it changes rarely; do not refetch it on every screen. Variant mutations
**invalidate `catalogKeys.myVariants()`** (and `setQueryData` the edited row where it avoids a refetch).
- `index.ts` — barrel export.
### 3.2 Customer **Home (A5)**`app/[locale]/(private-routes)/<customer>/home`
The primary landing screen, inside the customer shell with the **bottom tab nav (Home active)**:
- **Greeting + avatar**`سلام، {firstName}` from `AuthContext`/`useCurrentUser` (f1); avatar from the
customer profile (f2). RSC where it cleanly removes a round-trip; no `next/headers` in client components.
- **Search bar**`جستجوی خدمت یا پرستار…`. **Render the input here, but search execution is f6** — tapping
it navigates toward the (future) search route / sets the query; do **not** implement results, the index,
or filters in this phase. Tag results/filters **(DEFERRED → [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))**.
- **Service-category grid** — a tile per `service_category` from `useServiceCategories()`, ordered by
`sort_order`, label by locale (`name_fa`/`name_en`), seed-matching the wireframe's four
(مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) but **data-driven** (never hardcode the category
list — EAV configurability is load-bearing). Tapping a tile carries the chosen `service_category_id` into
the (future) search flow. Build **loading skeleton tiles**, **empty** (no categories seeded → friendly
message) and **error/retry** states.
- **Complete-patient-record nudge card** — shown when the signed-in customer has no patient *or* an incomplete
record (derive from the f2 patient state already in cache — do **not** add a new fetch if f2's query
already holds it); CTA routes to add/complete a patient (f2 screens). Hide when the record is complete.
- A small reusable **`CategoryTile`** and **`HomeNudgeCard`** composite live at the shared level if reused;
page-only composition stays in the page.
### 3.3 Nurse **"add a service" builder + offerings list** — `app/[locale]/(private-routes)/<nurse>/services`
The services half of **B7**, inside the nurse shell:
**(a) Offerings list (`ListMyVariants`)** — `useMyVariants()` (paginated):
- Each row/card shows `display_name`, the **price rendered via the f0 money util** as
`{formatIrrToToman(price)} تومان {unitLabel}` (unit label is an i18n key off `price_unit`), and an
**active vs deactivated** visual distinction (reuse the f0 **status chip**) with a "deactivated can't be
booked" hint on inactive rows. Row actions: **Edit**, **Deactivate** (with a confirm).
- States: **loading** (skeleton rows), **empty** (no offerings yet → prominent `+ افزودن خدمت` CTA), populated.
**(b) Create/Edit variant builder (`CreateVariant` / `UpdateVariant`)** — a **stepper** (reuse the f0
stepper/progress header), launched by `+ افزودن خدمت` or Edit:
1. **Step 1 — category.** Single-select from `useServiceCategories()`. On select, fetch that category's
option groups via `useCategoryOptionGroups(categoryId)` (cached). (On *edit*, category is fixed — changing
it would change identity; lock it and explain.)
2. **Step 2 — options.** Render each `service_option_group` for the category (plus any cross-category group
where `service_category_id` is null) as a single-select of its `service_option_value`s. **Mark required
groups** (`is_required`) and **block advancing until every required group is answered** (one value per
group — `UNIQUE(variant_id, option_group_id)` is enforced server-side; the UI enforces single-select).
Optional groups may be left unanswered.
3. **Step 3 — price + unit (+ duration).** A **price** field (Toman input → store/submit as IRR digit-string
via the f0 integer-safe parse; **never a float**), a **`price_unit`** select (the five units, i18n
labels), and an optional **`session_count`/duration**. Show a **live estimated total** computed from
**price + unit + session_count together** — e.g. for `per_hour` with a duration, surface the
`formatIrrToToman(price × hours)` estimate — **do not compute or display a total from price alone**. Show
the auto-generated **`display_name`** (from the chosen option labels) as an editable field.
- **Submit:** `useCreateVariant()` / `useUpdateVariant()`. On the **duplicate-listing conflict** (server `409`
on `(nurse_id, category, option-set)`), show a **friendly inline warning** ("شما قبلاً خدمتی با همین مشخصات
دارید") and let the nurse adjust — don't silently fail or generic-toast it. Success → invalidate
`myVariants`, route back to the list with the new/edited row visible.
- States to build: loading (fetching catalog), per-step validation errors (missing required option, missing/
invalid price), the duplicate warning, success.
**(c) Deactivate (`DeactivateVariant`)** — `useDeactivateVariant()`; a confirm dialog explaining the variant
becomes **unbookable and drops out of search**; **soft only — never a hard delete**. On success, flip the
row to the deactivated visual state (`setQueryData`/invalidate).
### 3.4 Catalog browse (categories) — the read surface
The category-browse query the Home grid and the (future) search filters both consume: `ListCategories` and
`GetCategoryOptionGroups` via `services/catalog`. Build the **catalog-browse view** as the data-driven grid
in 3.2 (Home) reusing `CategoryTile`; a standalone "all categories" browse screen is optional and may be
**(DEFERRED → f6)** if the Home grid + search cover it — your call, but if you build it, reuse the same
hooks/components.
**Endpoints consumed (final names from [`catalog.md`](../../contracts/domains/catalog.md) — these mirror the
b5 capabilities; use the contract's exact `snake_case` routes):**
- `GET api/v1/catalog/categories` → categories (reference data, cached).
- `GET api/v1/catalog/categories/{id}/option_groups` (with values) → the skeleton the builder renders.
- `POST api/v1/nurse/variants` → CreateVariant.
- `PUT api/v1/nurse/variants/{id}` → UpdateVariant / EditDisplayName.
- `POST api/v1/nurse/variants/{id}/deactivate` → Deactivate (soft).
- `GET api/v1/nurse/variants` (paginated) → ListMyVariants (active + inactive).
**Out of scope (tag explicitly):**
- Search results / filters / the nurse-result cards / the search index — **(DEFERRED → [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md))**.
- The **admin catalog manager** (category/option-group/value CRUD) — **(DEFERRED → admin console [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md))**; the admin seeds the catalog server-side for now.
- Nurse **availability** slots/calendar — **(DEFERRED**, soft-constraint, not on this path).
- The **public nurse profile** services rows a customer sees — **(DEFERRED → f6 C3)**.
- Holiday/surge pricing, companionship tier, per-category commission — **(DEFERRED** per product doc).
## 4. Mocks & seams in this phase
This is a **frontend** phase: the only seam is the data seam behind `services/catalog`.
- **Reuse the `services/{domain}` seam pattern from f0.** Every catalog call goes through
`services/catalog/apis/clientApi.ts` (over `clientFetch`) — never a raw `fetch()`.
- **If the b5 `catalog` contract is published and merged**, derive `types.ts` from it and call the real
endpoints — no mock.
- **If a needed shape is missing or the contract isn't live yet**, build a **mock `clientApi`** behind the
same `services/catalog` seam (returning realistically-shaped data: a few seeded categories, an option
group with `is_required` true/false, a couple of variants across price units, and a `409` path to exercise
the duplicate warning), **and**:
- append the gap to [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(per operating-rules §6 — you request, backend delivers; never edit backend files), and
- record the mock in [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
+ your phase report (per operating-rules §7) with exactly how f-next swaps it for the real endpoint.
- No new external-service seam is introduced here (Elasticsearch / `INurseSearch` belongs to **search**,
f6/b7 — do not pull it forward).
## 5. Critical rules you must not get wrong
- **Money correctness — IRR is integer, never a float.** Money is **IRR Rials as an integer string on the
wire**; parse it integer-safe and render it **only** through the f0 money util (`formatIrrToToman`). Toman
is **display-only**; convert Toman input → IRR digit-string at the field boundary. **No floating-point**
anywhere on the price path (input, state, or submit) — float coercion is a defect.
- **price + unit + session_count drive the displayed total — never compute the total from price alone.** The
estimated total = `price` interpreted by `price_unit` combined with `session_count`/duration. A bare
`price` is a unit rate, not an engagement total. Render the unit label from an **i18n key off the
`price_unit` code**, never a label hardcoded in the component.
- **Reference data is cached.** Categories and option groups/values are admin-seeded reference data — fetch
once with a long `staleTime`/`gcTime` and reuse from cache; do not refetch them per screen or per step.
Variant mutations invalidate `myVariants` (and `setQueryData` the edited row) so you never needlessly
refetch the list.
- **Validate every required option group.** The builder must not submit until **every `is_required` group has
exactly one value chosen**; optional groups may be empty; one value per group (single-select mirrors the
server's `UNIQUE(variant_id, option_group_id)`).
- **Deactivate is soft — never hard-delete.** A deactivated variant must read as unbookable and is understood
to drop out of search; there is no delete affordance.
- **Duplicate-listing is a friendly conflict, not a crash.** Surface the server `409` on
`(nurse_id, category, option-set)` as inline, actionable copy.
- **Data-driven catalog (no hardcoded enums).** Categories, option groups, and option values come from the
API and render by `sort_order` + locale label — **never** hardcode the category/option list as constants.
The **only** closed enum is `price_unit` (the five units).
- **RTL-first, both locales.** `fa` is default and RTL; every user-visible string is a key in **both**
`en.json` and `fa.json`, in sync. Persian unit labels (ساعتی / روزانه / شبانه‌روزی, …) must read correctly.
- **RSC/client boundary & re-renders.** No `next/headers`/`next-intl/server` in client components; keep
builder step state colocated (low) so typing in the price field doesn't re-render the whole stepper;
stable references where it pays.
- **MUI primitives stay MUI; reuse the shared stepper & status chip** from f0 — do not fork a new root
primitive or a second stepper.
- **Tenancy is server-enforced, but don't leak it in UI:** the nurse only ever sees/edits *her own* variants
(`GET api/v1/nurse/variants` is self-scoped) — never build a UI that lists or edits another nurse's
offerings.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus these phase specifics:
- [ ] `services/catalog` exists (types/keys/apis/hooks/index) mirroring the f0 pattern; reference data
cached with deliberate `staleTime`/`gcTime`; variant mutations invalidate/`setQueryData` `myVariants`.
- [ ] **Home (A5)** renders inside the customer shell with the bottom nav: greeting + avatar, the search bar
(navigates toward f6, results not built here), the **data-driven category grid** (loading/empty/error
states), and the complete-patient-record nudge (derived from cached f2 state, hidden when complete).
- [ ] The **nurse builder** (stepper category → required/optional options → price+unit+duration) enforces all
required groups, shows the live unit-aware estimated total, auto-generates an editable `display_name`,
submits as IRR digit-string, and handles the **duplicate `409`** with friendly inline copy.
- [ ] The **offerings list** shows active vs deactivated distinctly, supports **edit** and **soft
deactivate** (confirm dialog), with empty/loading states.
- [ ] All money rendered via the f0 money util; the **`price_unit`** labels are i18n keys in **both** locales,
RTL-correct; `en.json`/`fa.json` in sync.
- [ ] Types derive from [`catalog.md`](../../contracts/domains/catalog.md); any gap is logged in
`for-backend.md` and mocked behind the `services/catalog` seam (recorded in the mock registry).
- [ ] `npm run check` green; `npm run test:ci` green for any shared component added (e.g. `CategoryTile`,
a price-unit display, the variant card) with co-located `*.test.tsx`.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route segments (customer `home`, nurse
`services`) and the `services/catalog` domain + any new shared component.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and ensure the b5 endpoints are reachable, or the `services/catalog` mock is active).
1. **Home renders.** Sign in as a customer → the Home screen shows the greeting, avatar, search bar, and the
**category grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) ordered by `sort_order`. With
no patient on file, the **complete-patient-record nudge** is visible; after completing a patient (f2) it
disappears (no extra refetch — verify in React Query Devtools that the patient query is reused).
2. **Locale + RTL.** Switch `fa``en` → labels translate, `dir` flips, the grid and tiles mirror correctly;
Persian unit labels read right.
3. **Build a variant.** Sign in as a nurse → `+ افزودن خدمت` → step through: pick a category; in the options
step, try to advance **without** answering a required group → **blocked** with a clear message; answer it →
advance; set a **price (in Toman)** and a **unit** (e.g. ساعتی) with a duration → the **estimated total**
updates from price × duration (not from price alone); edit the auto-generated `display_name`; submit →
the new variant appears in the offerings list, price shown as `… تومان ساعتی`.
4. **Duplicate warning.** Create a second variant with the **same category + same option set** → the builder
shows the friendly duplicate-listing warning (server `409`) and lets you change it, without a crash or a
generic error toast.
5. **Edit + deactivate.** Edit a variant's price/`display_name` → list reflects it without a full refetch
(Devtools: `setQueryData`/single invalidation). Deactivate a variant → it flips to the deactivated visual
state with the "can't be booked" hint; there is **no delete** option.
6. **Caching.** In React Query Devtools confirm `catalogKeys.categories()` / `categoryOptionGroups` are
served from cache across Home and the builder (no repeated network calls per step); a variant mutation
invalidates only `myVariants`.
7. **Gate.** `npm run check` and `npm run test:ci` pass.
## 8. Hand off & document (close the phase)
- **Docs to update (same change):**
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) *Project Structure* — add the customer `home` and nurse
`services` route segments, the `services/catalog` domain, and any new shared component (`CategoryTile`,
price-unit display, variant card). Note the reference-data caching convention if it's the first long-lived
cached domain.
- If you discover or decide a catalog/pricing rule the product docs don't capture (e.g. how the estimated
total is presented for each unit), record it in
[`../../../product/business/03-service-catalog-and-pricing.md`](../../../product/business/03-service-catalog-and-pricing.md)
— don't invent rules; record decisions, and flag any open question in your report.
- **Contract to consume:** [`../../contracts/domains/catalog.md`](../../contracts/domains/catalog.md) (b5) —
types/services derive from it; do not guess shapes. Any missing/ambiguous shape → append to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(you request; backend delivers — never edit backend files). The frontend produces no contract.
- **Handoff & report (per operating-rules §6–§7):**
- Append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md).
- Write `dev/shared-working-context/reports/frontend-phase-4-report.md`: what shipped (Home A5, the nurse
variant builder + offerings list, `services/catalog`), **what is now testable and exactly how** (the §7
steps), what is mocked vs live behind the catalog seam and **how f6 swaps it**, the contract consumed,
and the follow-ups handed to **f6** (the Home search bar now hands a `service_category_id` to search; the
variant builder is what populates the index f6 reads).
- Update [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
if you mocked any catalog endpoint (seam, what's faked, config, how to make it real).
- **Memory (per operating-rules §8):** save a `project`-type memory note for the non-obvious decisions —
the price/unit/session_count display rule (total never from price alone), the IRR-string-in/Toman-display
money handling on the builder, and the reference-data caching choice — with a one-line `MEMORY.md` pointer.
Don't record what the code/docs already make obvious.
+339
View File
@@ -0,0 +1,339 @@
# Frontend Phase 5 — Nurse verification flow (mocked vendors)
> **Mission:** build the trust engine's front end — the staged, platform-owned verification flow a nurse
> walks through before any of their services can go live. A nurse lands on a **status checklist** (B3),
> submits **identity** (B4: national-ID + card image + liveness selfie), submits **professional
> credentials** (B5: نظام پرستاری number + license + education + specialties), and waits on the
> **under-review** screen (B6) until an admin decides. Documents upload to the object-storage-backed
> endpoint with type/size validation and progress; each step shows its own status (and its rejection
> reason); and a **trust badge** (verified / unverified / expired) renders on the nurse profile. Verified
> trust is the entire brand — a nurse is **not bookable and cannot publish until verified**, and the UI
> must say so honestly and never advertise a check the platform doesn't actually perform.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-4-b5.md`](./frontend-phase-4-b5.md) (catalog browse + nurse service builder) and the **b6** verification contract · **Unlocks:** a bookable verified nurse + the search **trust badge** consumed in [`frontend-phase-6-b7.md`](./frontend-phase-6-b7.md)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
This is the fifth feature slice of the customer/nurse front end and the **nurse side's gating step**.
By f4 a nurse can build service variants, but those variants are **inert** — they cannot surface in
search or be booked until `nurse_profiles.is_verified` is true. This phase builds the screens that flip
that switch: the data-driven verification pipeline (six step types, all vendor calls **mocked** server-side
behind DI seams in b6) rendered as a nurse-facing checklist, the two submission forms, the waiting state,
the document uploader, and the public trust badge. When this lands, a nurse can go from "registered" to
"verified and publishable", which is the prerequisite for everything downstream — search (f6), booking
(f7), and ultimately payout (f12, gated on the bank-account verification step).
**What already exists (do not rebuild) — built by prior phases:**
- **f0 foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the three actor app shells +
route groups, the **nurse shell** ("نمای پرستار"), the `services/{domain}` + TanStack Query caching
pattern (`keys.ts` factory, `apis/clientApi.ts`, one-hook-per-file), the contracts→`types.ts` pattern,
the money/format utils, and the **shared composite components** — most importantly the **stepper/
progress header** and the **status chip** (verified/pending/…). **Reuse both here; do not re-implement
a stepper or a status chip.** The `verification` i18n namespace was reserved in f0 — fill it.
- **f1-b2 auth** ([`frontend-phase-1-b2.md`](./frontend-phase-1-b2.md)): phone-OTP login, the role router,
roles in `AuthContext`. The nurse arrives here already authenticated with the `nurse` role. **Step 1 of
the checklist (شماره موبایل — mobile verified) is already satisfied at login** — render it as `passed`,
don't ask for it again.
- **f2-b3 onboarding** ([`frontend-phase-2-b3.md`](./frontend-phase-2-b3.md)): the nurse profile bootstrap
(`CreateNurseProfileCommand` → an unverified `nurse_profiles` row, `is_verified=false`) and the nurse
**bank-account settings** screen (IBAN entry → ownership inquiry). The verification pipeline's
`bank_account_verification` step couples to that bank account — **link to the existing bank-account
screen for that step; do not rebuild the IBAN form here.**
- **f3-b4 addresses/geo** ([`frontend-phase-3-b4.md`](./frontend-phase-3-b4.md)): the nurse coverage-area
editor and the cascading geo dropdowns (not used here, but the nurse shell nav points to them).
- **f4-b5 catalog** ([`frontend-phase-4-b5.md`](./frontend-phase-4-b5.md)): the nurse "add a service"
builder (B7) and `services/catalog`. **The "انتشار پروفایل / go live" action that f4 stubs must be
gated on verification by this phase** — wire the blocked-until-verified state into the publish CTA.
> **Honesty constraint (load-bearing, from the product doc and GTM notes):** vetting is platform-owned and
> performed at the authoritative source. **Never word the UI to advertise a check that isn't performed.**
> The MoH/INO license and criminal-record steps are *manual admin review of an uploaded document* at
> launch — copy must say "در حال بررسی" (under review), not "تاییدشده توسط نظام پرستاری" until an admin
> actually passes it. The civil-registry/Shahkar/liveness checks are real (vendor-mocked) — those may say
> "استعلام خودکار".
## 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) — how you
work, the gate, the contract/handoff lanes, the mock-then-swap rule (§6).
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary,
layouts, the `services/{domain}` shape, i18n, theme, cookies, the fetch services). Non-negotiable.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(palette, tokens, typography, the `App*` library, layout shells, the hard UI rules). Every screen,
chip, uploader, and badge in this phase goes through it. The wireframe's status legend is **green =
automatic/verified, amber = pending, grey = manual/next, terracotta = financial** — encode those as
token-driven chip variants.
- **Product — the source of truth for the rules:**
- [`../../../product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md) —
the six steps, what each verifies, why it's manual vs automated, the structured-credential-registry
rationale, continuous re-verification, and **the "never advertise a check you don't perform" rule**.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **Section B,
screens B3B6** are the visual baseline you implement: B3 status meter "۲ از ۵" + stepped checklist
with status badges; B4 identity (کد ملی field, upload national-ID card, liveness selfie, "استعلام
خودکار از ثبت احوال" note); B5 professional credentials (شماره نظام پرستاری, license upload, education
cert shown uploaded ✓, specialty chips سالمندان/ICU/+افزودن); B6 "در حال بررسی" (2448h, mini-checklist).
Also note B7's "انتشار پروفایل" — the publish gate this phase enforces, and C2/C3's "✓ تاییدشده" badge
this phase's trust-badge component feeds.
- **The contract you consume (the authoritative server shapes):**
- [`../../contracts/domains/verification.md`](../../contracts/domains/verification.md) — written by
**backend-phase-6**. The exact request/response shapes, routes, status enums, and the per-step status
codes. **Do not guess shapes** — derive `types.ts` from this doc + the published `swagger.json`. If a
shape you need is missing, append a request to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
and mock behind the `services/verification` seam meanwhile (operating-rules §6).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) — the envelope (`OperationResult`
`ApiResult`, already unwrapped by `clientFetch`), `snake_case` routes/properties, pagination, **enums
as stable string codes** (mirror them as string-literal unions; labels are i18n keys, never derived
from the code), UTC timestamps with **Shamsi display client-side** (credential issue/expiry dates).
- **Code to mirror:**
- The `services/catalog` and `services/onboarding` domains from f4/f2 — the exact `types.ts`/`keys.ts`/
`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts` layout your `services/verification` copies.
- The shared **stepper/progress header** and **status chip** from f0 (`src/components/…`) — extend, don't
fork. The nurse-profile bank-account screen from f2 — you deep-link into it for the bank step.
- The existing document/image handling, if any, in `App*` (`AppImage`); the `clientFetch` multipart path.
## 3. Scope — build this
A new **`services/verification`** domain, the **nurse verification route subtree** under the nurse shell,
a reusable **document-upload** component, a **trust-badge** component, and the publish-gate wiring. Build
RTL-first, both locales, query-cached, minimal re-renders.
### 3.1 `services/verification` domain (the data layer)
Copy the f0/f4 service shape exactly. Types come from
[`../../contracts/domains/verification.md`](../../contracts/domains/verification.md) — do not invent.
- **`types.ts`** — string-literal unions mirroring the contract enums: the **aggregate status**
(`not_started` | `pending` | `in_review` | `approved` | `rejected` | `suspended`), the **per-step
status** (`not_started` | `pending` | `in_review` | `passed` | `rejected`), the **step `code`s**
(`identity_kyc` · `shahkar_match` · `moh_competency_license` · `ino_membership` · `criminal_record` ·
`bank_account_verification`), `verification_method` (`manual` | `portal` | `api`), and the
badge state (`verified` | `unverified` | `expired`). Plus the DTOs the contract returns:
`VerificationStatus` (aggregate + ordered `steps[]` each with `code`, `status`, `is_automated`,
`rejection_reason?`, `expires_at?`), `VerificationStep`, `VerificationDocument` (storage key, file name,
uploaded-at — **metadata only, never bytes**), `NurseCredential` (type, masked number, holder name,
issuing authority, `issued_at`/`expires_at`).
- **`keys.ts`** — a query-key factory: `verificationKeys.status()`, `.documents(stepCode)`,
`.badge(nurseId)`. Deliberate `staleTime` (status is moderately fresh — e.g. 30s — because submitting a
step changes it; the badge is longer-lived).
- **`apis/clientApi.ts`** wrapping `clientFetch` — one function per contract endpoint (see §3.2). The
document upload uses the multipart path against the `IObjectStorage`-backed endpoint.
- **`hooks/` — one hook per file:**
`useVerificationStatus` (query), `useStartVerification`, `useSubmitIdentity`, `useSubmitCredentials`
(or per-credential submit if the contract splits MoH/INO/criminal), `useUploadVerificationDocument`
(mutation with progress), `useNurseTrustBadge` (query). **Every mutation invalidates
`verificationKeys.status()`** (and the badge where relevant) so the checklist re-renders from cache
without a manual refetch. Don't toast 401/403/5xx (the fetch layer does) — only domain 4xx (e.g.
"ownership mismatch", "shared-SIM", "national-ID format").
### 3.2 Endpoints consumed (per the b6 contract — confirm exact routes/shapes there)
Wire each via `clientApi`; the names below are the expected commands/queries from the digest — bind to
whatever the contract publishes:
- `GET .../nurse/verification``GetVerificationStatusQuery` — the aggregate + per-step list driving B3/B6.
- `POST .../nurse/verification/start``StartNurseVerificationCommand` — seeds the steps (call from the
B3 "start / continue" CTA when status is `not_started`).
- `POST .../nurse/verification/identity``RunIdentityKycCommand` — national-ID + card image + liveness
selfie; drives B4. (Server also runs `shahkar_match` off the bound national-ID — surface both steps.)
- `POST .../nurse/verification/moh-license` (+ `.../ino`, `.../criminal-record` if split) →
the credential-submit commands behind B5.
- `POST .../nurse/verification/document` (or a presigned-URL flow) → the `IObjectStorage`-backed upload
used by every document step.
- The **bank step** (`bank_account_verification`) is submitted on the **existing f2 bank-account screen**
this phase only renders its status in the checklist and **deep-links** to that screen.
- `GET .../nurse/{id}/badge` (public) → `GetVerifiedBadgeQuery` — feeds the trust badge.
### 3.3 Screens (under the nurse shell `(private-routes)` nurse subtree)
Invoke `frontend-designer` for each. RTL-first; the screens live under the nurse route group from f0.
- **B3 · وضعیت احراز هویت — status checklist** (the hub).
- Overall progress meter **"X از Y"** (X = passed required steps, Y = total required) — reuse the f0
stepper/progress header.
- An **ordered, stepped checklist**, one row per step from `steps[]`, each with a **status chip** (reuse
the f0 status chip) in the five per-step states: `not_started`/locked-next (grey "بعدی"), `pending`
(amber "در انتظار"), `in_review` (amber "در حال بررسی"), `passed` (green "تاییدشده"), `rejected`
(red "رد شد" + the reason). Render **step 1 (mobile) as `passed`** from auth state.
- A **"what's blocking go-live" summary** + a single **continue CTA** ("ادامه مرحله N") that routes to
the next actionable step. When `not_started`, the CTA calls `useStartVerification` first.
- States: loading skeleton, error, and the terminal **`approved` state** (all passed → "احراز هویت
تکمیل شد، می‌توانید پروفایل را منتشر کنید" with a link to publish).
- **B4 · تایید هویت — identity submit.**
- **کد ملی (national-ID)** field with format validation (10-digit, checksum); **upload national-ID card
image** (camera/gallery via the document uploader §3.4); **liveness selfie** capture (camera; handle
permission-denied / retry / vendor-timeout states honestly).
- The honest auto note "استعلام خودکار از ثبت احوال" (auto civil-registry query) — this check *is*
performed (vendor-mocked), so the copy is allowed.
- Submit → `useSubmitIdentity`; on success the `identity_kyc` (and server-run `shahkar_match`) steps move
to `pending`/`in_review`, the cache invalidates, and the user returns to B3. Surface the **shared-SIM**
Shahkar failure as a clear, **non-accusatory** message, and the national-ID-mismatch failure on its step.
- **B5 · مدارک حرفه‌ای — professional credentials.**
- **شماره نظام پرستاری (INO number)** field; **upload پروانه/کارت نظام پرستاری** (the MoH competency
license — the single most important credential) via the uploader; **education certificate** upload
(shown with the uploaded-✓ state when present); **specialty chips** (multi-select: سالمندان, ICU, +
افزودن — an add-your-own chip input).
- Structured fields the registry needs where the contract asks for them (license number, issuing
authority, holder name as printed, issue/expiry date — **Shamsi date picker, stored UTC**).
- Submit → `useSubmitCredentials`; the credential steps move to `in_review` (manual admin review). Copy
must reflect manual review, not an automated authority confirmation.
- **B6 · در حال بررسی — under review.**
- The waiting state: "مدارک شما در حال بررسی است" + the **2448h** expectation + a **mini-checklist**
summarizing which steps are passed vs in-review (identity verified / professional docs in review /
bank account pending). Reuses the same per-step chips as B3, condensed. CTA "مشاهده وضعیت" → B3.
- This is the post-submission resting screen; B3 is the canonical source — B6 is a focused view of the
same `VerificationStatus`, not a second fetch with different state.
- **Trust badge on the nurse profile.** Render the **verified / unverified / expired** badge (the
"✓ تاییدشده" mark) on the **nurse's own profile** (f2) and export a small `<TrustBadge status=…/>`
shared component so **search results (C2) and the public nurse profile (C3) in f6 reuse it**. Source it
from `GetVerifiedBadgeQuery`. `expired` (a required credential lapsed) shows distinctly from `unverified`.
- **Publish gate.** Wire the f4 "انتشار پروفایل / go-live" action: when the aggregate status is not
`approved`, the publish CTA is **disabled with a blocked-until-verified explanation** ("برای انتشار،
احراز هویت را کامل کنید") that links to B3. This is the front-end enforcement of "not bookable until
verified" — the server enforces it too; the UI must not let a nurse *believe* they're live when they're not.
### 3.4 Document uploader (shared composite component)
Build a reusable **`<DocumentUpload>`** at the shared level (`src/components/…`, co-located `*.test.tsx`)
composed from MUI/`App*` primitives — used by every document step (national-ID card, license, education,
criminal record):
- **Client-side validation** before upload: file **type** (jpg/png/pdf per the contract) and **size cap**;
reject with a clear field error.
- **Upload states:** idle → selecting → **uploading (progress %)** → processing/hashing → success
(thumbnail/✓ + file name) → **error (retry)**. Wire progress from the upload mutation.
- **Re-upload on reject:** when a step is `rejected`, the uploader shows the prior file's status and a
re-upload affordance.
- Returns the server's stored **document metadata** (storage key, name) — never holds or displays raw
bytes beyond a local preview.
### 3.5 i18n namespace `verification` (both locales)
Fill the f0-reserved `verification` namespace in **both** `messages/en.json` and `messages/fa.json`, in
sync: step labels and per-step status labels (one key per `code` and per status — **never** derive a label
from the enum string), the B3/B4/B5/B6 screen copy, the uploader states, the honesty-sensitive copy
(manual-review vs auto-query), the blocked-until-verified message, the trust-badge labels, and the
shared-SIM / mismatch / format error messages. `fa` is default and RTL.
### Deferred (do not build here)
- The **admin verification review queue** (pass/reject a manual step, the doc viewer, credential-entry
form) → **(DEFERRED to [`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md))**, the admin backoffice.
This phase only triggers the mock approval to *observe* the state change (see §7); it does not build the
admin UI.
- **Credential renewal/expiry prompts** beyond rendering the `expired` badge state → the nurse renewal
prompt UI is **(DEFERRED)**; the server's `CredentialExpiryScannerJob` (b6) raises the alert.
- **Video interview** (B6 mentions it) — not a built step at MVP; reflect it only as a grey "next" row if
the contract returns it; otherwise omit. Do not invent an interview-scheduling flow **(DEFERRED)**.
- Customer national-ID KYC — out of scope (the product gates only nurses) **(DEFERRED)**.
## 4. Mocks & seams in this phase
This phase **introduces no new front-end seam of its own** beyond the standard `services/{domain}` data
seam — the verification *vendors* (identity KYC, Shahkar, MoH/INO license, criminal record, IBAN ownership,
object storage) are **mocked server-side** behind DI seams owned by **backend-phase-6** (`IIdentityKycProvider`,
`IShahkarVerifier`, `ICredentialVerifier`, `IBankAccountOwnershipVerifier`, `IObjectStorage`) — the front
end consumes them only through the b6 contract, exactly as if they were real. **Reuse the `services/{domain}`
mock-`clientApi` pattern from f0:** if the b6 contract or a specific shape isn't published when you start,
build a **mock `clientApi`** behind the `services/verification` seam that returns realistic, contract-shaped
`VerificationStatus` (e.g. identity → `in_review`, then flippable to `passed`), record it in your phase
report + the [mock registry](../../shared-working-context/reports/mocks-registry.md), and **append the gap
to [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)**. The swap to the real
endpoint must be implementation-only (same hook signatures, same `queryKey`s) — no call-site changes.
## 5. Critical rules you must not get wrong
- **A nurse is NOT bookable and cannot publish until verified.** When the aggregate status is not
`approved`, the publish/go-live CTA is disabled with the blocked-until-complete explanation, and every
service variant stays inert. This front-end gate mirrors the server's guarded `is_verified` flip — never
let the UI imply a nurse is live before verification completes.
- **Honest copy — never advertise a check that isn't performed.** Manual steps (MoH/INO license, criminal
record) say "در حال بررسی / آپلود شد"; only the genuinely-performed automated checks (identity liveness,
civil-registry, Shahkar) may say "استعلام خودکار". A step is "تاییدشده" only when its status is `passed`.
This is a product constraint, not a style preference.
- **Per-step rejected-with-reason.** A `rejected` step renders its `rejection_reason` and a clear
re-submit/re-upload path — never a dead end. The shared-SIM Shahkar failure is an explicit, handled,
**non-accusatory** state, not a generic error.
- **The data is data-driven — render `steps[]` from the contract, don't hardcode the six steps.** The
pipeline is rows server-side; the UI iterates the returned ordered list and maps each `code`/`status` to
a label + chip. A new step type appearing in the response must render without a code change.
- **Reuse the shared stepper and status chip from f0** — do not re-implement them. Concrete MUI primitives
stay MUI; the new shared pieces (`<DocumentUpload>`, `<TrustBadge>`) live at the shared level with tests.
- **Caching is a feature.** B3 and B6 read the *same* `VerificationStatus` query — one cached source, two
views; every submit/upload mutation **invalidates** the status (and badge) so the checklist updates from
cache, never an extra manual refetch. Minimise re-renders (stable refs, `select` for slices).
- **RSC/client boundary + RTL + both locales + tokens.** No `next/headers`/`next-intl/server` in client
components; design RTL-first (`fa` default); every string a key in both locale files; colours from
`tokens.css` (the chip variants map to the wireframe's green/amber/grey/red legend); MUI v9 API only.
- **Document bytes are never shown from the API** — uploads return metadata only; a local preview before
upload is fine, but the rendered "uploaded" state is driven by server metadata, not retained bytes. The
national-ID and credential numbers are sensitive — show **masked** values where the contract masks them.
- **Dates:** credential issue/expiry are UTC on the wire, **Shamsi for display** (use the f0 date util).
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/verification` exists with the f0/f4 shape; types derived from
[`verification.md`](../../contracts/domains/verification.md) (not guessed); mutations invalidate the
status/badge query; one hook per file; no raw `fetch()`.
- [ ] B3 (status checklist), B4 (identity), B5 (credentials), B6 (under review) render under the nurse
shell, RTL, both locales in sync, reusing the f0 stepper + status chip.
- [ ] `<DocumentUpload>` and `<TrustBadge>` are shared components with co-located `*.test.tsx`; uploader
enforces type/size, shows progress, and supports re-upload on reject.
- [ ] The publish/go-live CTA from f4 is gated: disabled + blocked-until-verified message until `approved`.
- [ ] Honesty copy verified: manual steps never claim an automated authority check; a step shows
"تاییدشده" only when `passed`.
- [ ] Rejected steps show their reason and a re-submit path; shared-SIM is a clear non-accusatory state.
- [ ] `npm run check` green; `npm run test:ci` green (shared components added/touched); `en.json`/`fa.json`
in sync.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route subtree + `services/verification`
+ the new shared components.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (and the b6 server, or the mock `clientApi` if b6 isn't merged). Sign in as a `nurse`.
- **Checklist hub:** open the nurse verification screen → **B3** shows the "X از Y" meter, step 1 (mobile)
already `passed`/green, the rest `not_started`. The continue CTA routes to the next step (calling
`start` first when `not_started`).
- **Submit identity:** **B4** → enter a valid کد ملی, upload a national-ID card image (watch the
uploader's progress → success), capture a liveness selfie, submit → the `identity_kyc` and
`shahkar_match` steps move to **`pending`/`in_review`** on B3 **without a manual refresh** (cache
invalidation). Try an invalid national-ID → field error; trigger the mock shared-SIM number → a clear
non-accusatory Shahkar message.
- **Submit credentials:** **B5** → enter the نظام پرستاری number, upload the license + education cert, add
specialty chips, submit → the credential steps move to **`in_review`**; the UI lands on **B6** showing
"در حال بررسی", the 2448h note, and the mini-checklist.
- **Approval flips verified:** trigger the b6 mock admin approval (the mock provider/test endpoint, or set
the mock `clientApi` to return `approved`) → re-open B3: aggregate is **`approved`**, all required steps
`passed`, the **trust badge** shows **verified** on the nurse profile, and the **publish CTA is now
enabled** (was blocked-until-verified before).
- **Rejected step:** make a step return `rejected` (mock) → its row shows **the rejection reason** and a
working re-upload/re-submit path; the publish CTA stays blocked.
- **Quality:** `npm run check` and `npm run test:ci` pass; toggle locale → RTL/strings flip correctly;
dark mode renders; React Query Devtools shows one `verification.status` query feeding both B3 and B6 and
invalidating on each mutation.
## 8. Hand off & document (close the phase)
- **Docs:** update the *Project Structure* tree in [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md)
for the nurse verification route subtree, `services/verification`, and the new shared components
(`<DocumentUpload>`, `<TrustBadge>`); add a one-line note that the trust badge is the reusable component
f6 consumes. If you discover a verification rule the product docs don't capture, record it in
[`../../../product/business/02-nurse-verification.md`](../../../product/business/02-nurse-verification.md)
(don't invent — record decisions).
- **Contracts:** **consume** [`../../contracts/domains/verification.md`](../../contracts/domains/verification.md)
(frontend produces none). Any missing/ambiguous shape (e.g. whether credential submit is one endpoint or
split, the exact document-upload flow, the badge payload) → append to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md);
do **not** edit backend files.
- **Handoff & report:** append your phase summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`../../shared-working-context/reports/frontend-phase-5-report.md`](../../shared-working-context/reports/frontend-phase-5-report.md)
— what was built, **what is now testable and exactly how** (the §7 steps), what is mocked client-side
(the `services/verification` mock `clientApi`, if used) and how it swaps to the real b6 endpoint, the
contract consumed + gaps filed, and the follow-up that f6 reuses the `<TrustBadge>`. Update the
[mock registry](../../shared-working-context/reports/mocks-registry.md) for any client-side mock.
- **Memory:** save a `project` memory note for the non-obvious decisions — the data-driven step rendering,
the single-status-query-two-views (B3/B6) caching choice, the reusable `<TrustBadge>`/`<DocumentUpload>`
seams, and the publish-gate wiring — with a one-line pointer in `MEMORY.md`.
+310
View File
@@ -0,0 +1,310 @@
# Frontend Phase 6 — Search & discovery (find a verified, same-gender nurse)
> **Mission:** build the family-facing **discovery** experience — the heart of the marketplace. A
> customer picks a care category, narrows by city / gender / price, and gets a rating-sorted list of
> **only verified, accepting** nurses; opening one shows their trust badges, attribute chips, priced
> services, and latest review, ending in the **"درخواست رزرو"** call-to-action that hands off to the
> booking flow. This phase implements wireframe screens **C1 (search & filter)**, **C2 (results)**,
> and **C3 (nurse profile)** against the `search` domain (backend phase b7), and establishes the shared
> **nurse-result card** + **price-row** components that the booking flow will reuse.
>
> **Track:** frontend · **Depends on:** [frontend-phase-4-b5](./frontend-phase-4-b5.md) (catalog browse
> & service builder) · [frontend-phase-5-b6](./frontend-phase-5-b6.md) (verified-nurse / trust badge) ·
> backend **b7** contract ([`dev/contracts/domains/search.md`](../../contracts/domains/search.md)) ·
> **Unlocks:** [frontend-phase-7-b8](./frontend-phase-7-b8.md) (booking request flow)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the pivot of the customer journey: everything before this phase let a family *enter* the app
(auth f1), describe *who needs care* (onboarding f2), say *where* (addresses f3), and *browse the catalog*
(f4). This phase is the first time a family sees **real nurses** and chooses one. It is the screen the
product calls the trust funnel: discovery surfaces **only platform-vetted, same-gender-filterable**
caregivers, which is Balinyaar's entire differentiation versus opaque incumbents. The output of this
phase — a selected nurse + the carried filter intent (especially `required_caregiver_gender`) — is the
input to the booking request (f7).
**What already exists (do not rebuild) — link, extend, never re-create:**
- **f0 foundations** ([frontend-phase-0](./frontend-phase-0.md)): the customer mobile shell with the
5-tab bottom nav (خانه/رزروها/بیماران/کیف‌پول/پروفایل), the `services/{domain}` + TanStack Query
caching pattern (`keys.ts` factory, `apis/clientApi.ts`, one-hook-per-file, mutation invalidation),
the **money/format util** (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display) in
`src/utils/`, the i18n namespace conventions (incl. the `search` namespace), the RTL baseline, and the
shared composite primitives (status chip, stepper, etc.). **Copy the `auth` service shape — do not
invent a new data pattern.**
- **f4 catalog** ([frontend-phase-4-b5](./frontend-phase-4-b5.md)): the `catalog` service + the
**category grid** (مراقبت سالمند / پرستار کودک / تزریقات و سرم / فیزیوتراپی) and category cards used on
the customer Home (A5). C1 **reuses that category grid component** and the catalog query — do not
rebuild category fetching here.
- **f5 verified-nurses** ([frontend-phase-5-b6](./frontend-phase-5-b6.md)): the **trust badge**
component(s) — ✓ تاییدشده (verified) and نظام پرستاری (INO membership) — and the verification-status
vocabulary. C2/C3 **reuse those badges**; do not re-implement the verified mark.
- **Money/format:** prices render through the f0 `formatIrrToToman` util (IRR Rials string → Toman
display). Never format money inline.
> **Data note:** b7's `GET /search/nurses` reads the denormalized `nurse_search_index`, which by
> invariant contains a row **only** when the nurse is `is_verified` AND not suspended AND
> `is_accepting_bookings` AND the variant `is_active`. So *every* result you receive is already
> bookable — the UI must not need to re-filter for verification. (See §5.)
## 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, the `[locale]` layout rule, i18n, theme/tokens, `clientFetch`/`serverFetch`, the
`services/{domain}` layout, TanStack Query setup, the cookie manager). Don't break the boundary.
- **Invoke the `frontend-designer` skill** before any visual work — it is the design/brand contract
(palette: teal `#1d4a40`, terracotta `#d98c6a`, cream; tokens; typography; the `App*` library; the
mobile shell; the hard UI rules). C1/C2/C3 must come out branded, RTL, dark-mode-ready. **All visual
work goes through it.**
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — Section **C**
(screens **C1**, **C2**, **C3**): the exact layout, copy, and controls you implement. C1 = selected
category + filter pills (تهران/location, تاریخ/date, جنسیت/gender) + category grid + "مشاهده ۲۴ پرستار"
results CTA; C2 = result count + "مرتب‌سازی: امتیاز" sort + nurse cards (photo, name, ✓ تاییدشده,
rating/review count, distance km, "from X تومان/ساعت"); C3 = avatar, name, rating, verified + نظام
پرستاری badges, attribute chips, services-and-prices rows, latest review snippet, "درخواست رزرو".
- [`../../../product/business/04-search-and-matching.md`](../../../product/business/04-search-and-matching.md)
— the business rules: category + city/(optional district) geo search, rating sort, and the
**same-gender caregiver** near-hard requirement (`nurse_gender` filter + the requested
`required_caregiver_gender` carried *before* booking, not after).
- **The contract** [`../../contracts/domains/search.md`](../../contracts/domains/search.md) (b7) +
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) and
[`api-conventions.md`](../../contracts/conventions/api-conventions.md) — the envelope, the
IRR-as-string money rule, `gender` = `male`/`female`/`any`, `price_unit` =
`per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`, enums-as-codes (labels are i18n keys,
never derived from the code), pagination params, and the exact request/response shapes for
`GET /search/nurses` and the nurse-profile/variant payloads. **Types come from this doc — do not guess
server shapes** (if a shape is missing, follow §4 + operating-rules §6).
- The backend handoff [`../../shared-working-context/backend/handoff/after-backend-phase-7.md`](../../shared-working-context/backend/handoff/after-backend-phase-7.md)
— which endpoints are live, what's mocked, what to consume.
- The existing **`src/services/auth/*`** (the template) and the **f4 `catalog`** + **f5 trust-badge**
code — the patterns you copy and the components you reuse.
## 3. Scope — build this
A vertical slice: `services/search` (types/keys/apis/hooks) → the three screens (C1, C2, C3) → the two
shared components (nurse-result card, price-row), all RTL/i18n/cache-correct.
### 3.1 The `search` domain service (`src/services/search/`)
Copy the f0/`auth` service shape exactly:
- **`types.ts`** — mirror the b7 contract as string-literal unions / interfaces (don't guess):
- `NurseSearchFilters``serviceCategoryId: number`, `cityId: number`, `districtId?: number`,
`nurseGender?: 'male' | 'female'` (omit = any), `priceMin?: string`, `priceMax?: string` (IRR
strings), `sort: 'rating'` (the only MVP sort), `page`, `pageSize`.
- `NurseSearchResult` — the C2 card row: `nurseId`, `variantId`, `displayName`/`nurseName`,
`avatarUrl`, `isVerified` (always true by invariant), `averageRating`, `totalReviews`,
`distanceKm?`, `priceFromIrr` (string), `priceUnit` (the `per_*` union), `nurseGender`.
- `NurseProfile` — the C3 payload: identity (`nurseName`, `avatarUrl`, `bio`, `yearsExperience`),
`averageRating`/`totalReviews`/`totalCompletedBookings`, `isVerified`, `inoMembership` (نظام پرستاری
badge flag), `attributeChips` (specialties/تخصص‌ها labels), `services: NurseProfileServiceRow[]`
(each = `variantId`, `displayName`, `priceIrr` string, `priceUnit`, optional `sessionCount`), and a
`latestReview?` snippet (`rating`, `body`, `authorMasked`, `createdAt`).
- `Paged<T>` — reuse the f0 paginated envelope type.
- **`keys.ts`** — the query-key factory: `searchKeys.results(filters)` keyed on the **full filter
object** (this is the cache contract — see §5), `searchKeys.profile(nurseId)`.
- **`apis/clientApi.ts`** — wraps `clientFetch` (never raw `fetch`):
- `searchNurses(filters): Promise<Paged<NurseSearchResult>>``GET /search/nurses` (filters as query
params; omit absent optional filters so the key/URL stay stable).
- `getNurseProfile(nurseId): Promise<NurseProfile>` → the b7 nurse-profile endpoint (consume the exact
route from the contract).
- Add a **`serverApi.ts`** only if you prefetch C2/C3 from an RSC to remove a client round-trip
(optional, see §5).
- **`hooks/` (one hook per file):**
- `useNurseSearch.ts``useQuery({ queryKey: searchKeys.results(filters), queryFn })` with a
deliberate `staleTime` (results are read-heavy and change slowly) and `keepPreviousData` so the list
doesn't flash empty while a new filter loads.
- `useNurseProfile.ts``useQuery` on `searchKeys.profile(nurseId)`; `enabled` only when an id is
present.
- `useDebouncedSearchFilters.ts` (or fold the debounce into the C1 controller) — **debounce the
free-text/quick-filter input** so keystrokes don't fan out one request per character.
- **`index.ts`** — barrel.
### 3.2 C1 — Search & filter screen (`جستجو و فیلتر`)
The entry screen (reachable from Home/A5 search bar and category tap). Build:
- **Selected-category** header + the **category grid** (reuse the f4 catalog category grid;
selecting a category sets `serviceCategoryId`). Categories shown per wireframe: مراقبت سالمند، پرستار
کودک، تزریقات و سرم، مراقبت زخم (driven by the live catalog, not hardcoded labels).
- **Filter pills** (the C1 row): **city** (تهران ▾ — required; the cascading province→city→district
picker reused from f3 geo — district optional, "leaving district empty searches the whole city" helper
copy), **date** (تاریخ ▾ — capture intent only; availability is **soft** at MVP and is *not* a hard
search filter — pass it through to booking, don't filter results on it), **gender** (جنسیت ▾ — خانم /
آقا / فرقی ندارد → `male`/`female`/omit). Make **gender a prominent, early, first-class control** with
one line of copy on why same-gender matters for bodily care.
- An optional **price-range** control (min/max → IRR strings) and the **results CTA** that mirrors the
wireframe's "مشاهده ۲۴ پرستار" — i.e. show the live result **count** and navigate to C2. (Wire the
count off a lightweight `useNurseSearch` head/`totalCount`, or navigate and show the count on C2 — your
call, but the number must be real, not hardcoded.)
- Hold filter state in a small **colocated controller** (a `useSearchFilters` hook or local reducer) —
*not* in a high context provider (it changes fast). The filter object is what becomes the query key.
### 3.3 C2 — Results screen (`نتایج جستجو`)
- **Header:** result **count** ("۲۴ پرستار") + **sort control** "مرتب‌سازی: امتیاز ▾" (rating is the only
MVP sort — render it as a control but it has one option; tag other sorts **(DEFERRED)**).
- **List** of **`NurseResultCard`** (§3.5) rendered from `useNurseSearch(filters)`, paginated (infinite
scroll or a "load more" — reuse the f0 paginated pattern). The customer **bottom tab nav** stays
visible (this is a shell screen).
- **States (all required):** **loading** = skeleton cards (not a spinner); **empty** = the "no nurses
match → relax your filters" state with concrete suggestions (remove the gender filter / widen to whole
city by clearing district / try a nearby city — lean on the white-space cities Mashhad/Isfahan/Shiraz);
**error** = retry; **populated** = rating-sorted cards. Tapping a card → C3.
- Changing a filter on C1 and returning re-queries with the new key; **reverting to a prior filter set
reuses the cached result** (no refetch) — this is an acceptance criterion (§5, §7).
### 3.4 C3 — Nurse profile screen (`پروفایل پرستار`)
From `useNurseProfile(nurseId)`:
- **Header:** avatar, name, **rating** (+ review count), and **badges** — ✓ تاییدشده (reuse f5 verified
badge) and **نظام پرستاری** (INO membership; render only when `inoMembership` is true).
- **Attribute chips:** specialties / experience (سالمندان، تزریقات، ۸ سال سابقه) from `attributeChips` /
`yearsExperience`.
- **Services & prices:** a list of **`ServicePriceRow`** (§3.5) — one row per offered variant
(`displayName` + price rendered via `formatIrrToToman` + the localized `price_unit` label, e.g.
"۲۸۰٬۰۰۰ تومان/ساعت", and a 12h night-shift variant). These are the bookable units.
- **Latest review snippet** (`latestReview`) — rating + masked author + truncated body; "no reviews yet"
empty state. (The full reviews tab is **(DEFERRED)** → [frontend-phase-13-b14](./frontend-phase-13-b14.md).)
- **Primary CTA: "درخواست رزرو"** — navigates to the booking request form (f7), **carrying the selected
nurse + variant + the filter intent** (especially `required_caregiver_gender` derived from the C1
gender filter, and the city/category). f7 owns the form; this phase only hands off the intent — pass it
via route params / a small handoff, do **not** build the request form here. Tag the form itself
**(DEFERRED → f7)**.
- States: loading skeleton, not-found (404 → "this nurse is no longer available"), error/retry.
### 3.5 Shared components (built once, reused by f7+)
At the shared level (`src/components/…`), composed from MUI/`App*` primitives (never re-implement a root
primitive), each with a **co-located `*.test.tsx`** and i18n in both locales:
- **`NurseResultCard`** — the C2 card: `Avatar` (photo), name, the reused **verified badge**, rating +
review count, distance chip (km, only when `distanceKm` present), and the **"from X تومان/ساعت"**
price line (via `formatIrrToToman` + localized unit). Pure/presentational, memoized, stable props so a
list of N cards doesn't re-render on unrelated state.
- **`ServicePriceRow`** — the C3 service line and a reusable price row: localized service name + the
money formatting + the `price_unit` label. Reused on C3 now and by the booking summary later.
> These two are the load-bearing reusable pieces. The category grid (f4), trust badges (f5), geo picker
> (f3), and status chip (f0) are **reused**, not rebuilt.
### 3.6 i18n
Fill the **`search`** namespace (seeded in f0) in **both** `messages/en.json` and `messages/fa.json`,
in sync, RTL-first: filter labels (شهر/تاریخ/جنسیت/خانم/آقا/فرقی ندارد), sort label, result-count
pluralization, every empty/error/loading copy, badge labels (تاییدشده/نظام پرستاری), price-unit labels
(ساعتی/per_hour … per_24h), and the "درخواست رزرو" CTA. **No display label is derived from an enum code**
— each `price_unit`/`gender`/sort code maps to an i18n key.
## 4. Mocks & seams in this phase
This is a **frontend** phase — it introduces **no backend seam**; it **consumes** the b7 contract.
- **Reuse the `services/{domain}` seam pattern from f0.** All data goes through `clientFetch` inside
`services/search/apis/`.
- **If b7 is not yet merged (or a needed shape is missing):** build a **mock `clientApi`** behind the
*same* `services/search` seam (real-shaped fixtures: a handful of verified nurses with ratings,
distances, prices, badges; one profile with services + a review) so C1/C2/C3 are fully demoable, **and**
(a) append the exact missing/mismatched shape to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
(operating-rules §6 — you never edit backend files), and (b) record the mock in your frontend report so
it's swapped out cleanly when the real endpoint lands. Selection between mock and real `clientApi` is by
the seam (one import swap), never an `if (mock)` scattered through components.
No new entry is needed in the backend `mocks-registry.md` (that registry is for backend DI seams); the
client-side mock is recorded in your **frontend report** instead.
## 5. Critical rules you must not get wrong
- **Only verified + accepting nurses appear — and the UI must not have to enforce it.** The b7
`nurse_search_index` invariant guarantees every returned row is verified, not suspended, accepting, and
on an active variant. **Never** add client logic that *re-includes* hidden nurses, and never display an
unverified/paused nurse. If a result somehow lacks the verified flag, treat it as a data defect and file
it via `for-backend.md` — do not paper over it.
- **The filter object IS the query key — identical filters reuse cache, never refetch.** `queryKey =
searchKeys.results(filters)` must be a *stable, canonical* serialization (sorted keys, omitted optional
filters rather than `undefined`, IRR as strings). Changing a filter and **reverting** to a previous set
must hit the React Query cache with **zero** network calls (verify in Devtools — §7). Use
`keepPreviousData` so the list doesn't flash. This is the whole point of the phase's caching design.
- **Debounce input.** Free-text / quick-filter typing must **not** fire one request per keystroke —
debounce before it becomes part of the query key.
- **Same-gender is first-class and carried *before* booking.** The gender filter
(`male`/`female`/`any`) is a prominent, early control with explanatory copy; the chosen value is carried
into the booking handoff as `required_caregiver_gender` (f7) — surfaced *before* booking, never
discovered after. Never default or silently drop gender.
- **Geography semantics:** city is required; **district is optional and "empty district = whole city"**
the picker copy must say so; don't send a bogus district. (Backend matches city-only + district rows;
the client just leaves `districtId` absent.)
- **Availability is soft at MVP — never a hard search filter.** The date pill captures intent for the
booking flow; it must not remove nurses from results. Tag availability-window filtering **(DEFERRED)**.
- **Money renders through the f0 util only — IRR Rials are integer strings, no floats, Toman is
display-only.** Format with `formatIrrToToman`; never parse IRR into a JS number for math, never compute
a "from" price client-side beyond picking the min the server sent.
- **Enums are codes; labels are i18n keys.** `price_unit`, `gender`, and sort never render their raw
code; each maps to a localized label in both `en.json`/`fa.json`.
- **RSC/client boundary + caching discipline:** prefetch C2/C3 from an RSC with `initialData` only if it
removes a round-trip; otherwise client-fetch. No `next/headers`/`next-intl/server` in client components.
- **Minimal re-renders:** `NurseResultCard` is presentational/memoized; keep fast-changing filter state
colocated (not in a high provider); use `select` to subscribe to slices where it helps.
- **MUI primitives stay MUI; the two new composites live shared** — not inline in a page.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/search` exists (`types`/`keys`/`apis`/`hooks`/`index`), copying the f0 pattern; types
derive from the b7 contract (or a mock behind the seam + a `for-backend.md` request if b7 isn't
ready).
- [ ] **C1, C2, C3** are built per the wireframe, RTL, with category grid (reused), city/gender/(optional
district)/price filters, rating sort, and the prominent same-gender control.
- [ ] C2 has **all four states** (loading skeletons / empty "relax filters" / error-retry / populated);
C3 has loading / not-found / error states.
- [ ] **Caching proven:** the filter object is the query key; reverting a filter reuses cache with no
network call; input is debounced; `keepPreviousData` set. (Demonstrable in Devtools — §7.)
- [ ] `NurseResultCard` + `ServicePriceRow` are **shared** components with co-located tests; the verified
badge (f5), category grid (f4), geo picker (f3) are reused, not rebuilt.
- [ ] Prices render via the f0 money util; every string is an i18n key in **both** locales, in sync; no
label derived from an enum code.
- [ ] "درخواست رزرو" hands off the selected nurse + variant + `required_caregiver_gender` + city/category
to the f7 route (form itself deferred to f7).
- [ ] `npm run check` green; `npm run test:ci` green (the two new shared components are covered);
`client/CLAUDE.md` *Project Structure* updated for the new `services/search`, the two shared
components, and the C1/C2/C3 routes.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (point `NEXT_PUBLIC_API_URL` at a b7-enabled server, or use the seam mock if b7 isn't
merged):
- **End-to-end discovery:** from Home, open **C1** → pick a category (e.g. مراقبت سالمند), set city
(تهران), set gender (خانم) → the results CTA shows a real count → tap it → **C2** lists **only
verified** nurses, rating-sorted, each card showing photo, name, ✓ تاییدشده badge, rating + review
count, distance, and "from X تومان/ساعت". Confirm no unverified/paused nurse ever appears.
- **Profile:** tap a card → **C3** shows avatar, rating, ✓ تاییدشده + نظام پرستاری badges, attribute
chips, the services-and-prices rows (correct Toman formatting + Persian unit labels), and the latest
review snippet; **"درخواست رزرو"** navigates to the f7 route carrying nurse + variant + gender intent.
- **Empty state:** search a white-space city/category/gender combo with no matches → the "no nurses match
→ relax your filters" state with concrete suggestions (clear district / drop gender / try Mashhad).
- **Caching (the headline check):** open React Query Devtools → apply filter set A (cache entry A) →
change to set B (entry B, one fetch) → **revert to A** → the list shows instantly with **zero new
network requests** (cache hit on key A). Type quickly in the search input → confirm **one** debounced
request, not one per keystroke.
- **i18n/RTL:** flip locale fa↔en → all C1/C2/C3 labels, badges, unit labels, and empty/error copy
translate and mirror correctly; dark mode still renders.
- **Gate:** `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/search`, the C1/C2/C3 routes/segments, and the two new shared components
(`NurseResultCard`, `ServicePriceRow`); note the "filter-object-as-query-key" caching pattern as a
reusable convention. Fix any doc drift you touch.
- **Contracts:** **consume** [`../../contracts/domains/search.md`](../../contracts/domains/search.md)
(b7) — derive `services/search/types.ts` from it; **do not** edit it. Any missing/ambiguous shape (e.g.
the nurse-profile services array, the `latestReview` shape, distance units, or the count head) goes to
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
as an append — the backend delivers it in a later change; you never edit backend files.
- **Handoff & report:** append your summary to
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
write [`../../shared-working-context/reports/frontend-phase-6-report.md`](../../shared-working-context/reports/frontend-phase-6-report.md)
— what was built (C1/C2/C3 + `services/search` + the two shared components), **what is now testable and
exactly how** (the steps in §7), what (if anything) is **mocked behind the `services/search` seam** and
how f-next swaps it for the real b7 endpoint, the contract consumed + any `for-backend` requests filed,
and the follow-ups (the f7 booking handoff contract for the carried intent; the C3 reviews tab deferred
to f13).
- **Memory:** save a `project`-type memory note for the non-obvious decisions this phase locks in — the
**filter-object-as-query-key** caching contract, the verified-only invariant the UI relies on (so a
future agent doesn't add a client-side verification re-filter), and the **`required_caregiver_gender`
carried-before-booking** handoff to f7 — with a one-line pointer added to `MEMORY.md`.
+337
View File
@@ -0,0 +1,337 @@
# Frontend Phase 7 — Booking request flow (customer request + nurse inbox)
> **Mission:** turn a nurse profile into a *sent request* and close the request loop on both sides. The
> customer fills the **request form** (C4) — patient, service variant, address, date/time, and the
> first-class **caregiver-gender preference** — and lands on the **awaiting-acceptance** screen (C5) with
> a live countdown to the nurse's response deadline and a 3-step status tracker. The nurse opens an
> **incoming-requests inbox**, sees a request showing *only* the customer's notes (two-stage clinical
> disclosure), and accepts or rejects it. On accept, the customer flips to a 30-minute **payment-deadline**
> countdown that hands off to checkout (f9). This is the money-free request phase — no payment, no booking
> row yet — and it is where the platform's trust contract (same-gender match, deadlines, terminal states)
> becomes visible to both actors.
>
> **Track:** frontend · **Depends on:** [frontend-phase-6-b7](./frontend-phase-6-b7.md) (discovery: search/results/nurse profile) · [frontend-phase-3-b4](./frontend-phase-3-b4.md) (addresses & map picker) · backend **b8** contract ([booking-requests.md](../../contracts/domains/booking-requests.md)) · **Unlocks:** [frontend-phase-8-b9](./frontend-phase-8-b9.md) (booking detail · sessions · EVV)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the hinge of the customer journey: discovery is done (f6), the customer is looking at a nurse
profile (C3) and taps **درخواست رزرو**. This phase builds the *request phase* of the booking lifecycle —
the deliberately money-free intent that lives in `booking_requests` and **only** becomes a `bookings` row
later, after the nurse accepts *and* payment captures (f9 / b9). Nothing here touches money or creates a
booking. The product framing: a family requests a specific nurse for a specific patient at a specific
address and time, the nurse retains accept/reject autonomy (a deliberate worker-classification stance),
and both sides see frozen deadlines so the engagement can't hang forever.
**What already exists (do not rebuild):**
- **f0 foundations** ([frontend-phase-0](./frontend-phase-0.md)): the three actor shells (customer
mobile + 5-tab bottom nav, nurse shell, admin), the `services/{domain}` + TanStack Query caching
pattern (template = `src/services/auth/*`: `types.ts` / `keys.ts` / `apis/clientApi.ts` /
`hooks/use*.ts` / `index.ts`), `clientFetch`/`serverFetch` + `ApiError` (`@/lib/api`), the
contracts→types convention, the money/Shamsi format utils in `src/utils/`, and the shared composites
(stepper/progress header, status chip, OTP/phone inputs). **Reuse these — do not re-create the
pattern.**
- **f3 addresses** ([frontend-phase-3-b4](./frontend-phase-3-b4.md)): the customer **address book**,
the **map picker**, and the cascading province/city/district selectors, all behind
`services/addresses` (or the f3 domain name). The request form **reuses** the address picker/list — it
does not build a new one. Patient `customer_addresses` already carry coordinates from f3's geocode.
- **f2 onboarding** ([frontend-phase-2-b3](./frontend-phase-2-b3.md)): the **patient** list/CRUD behind
`services/patients`. The request form's patient selector **reads** that list; it does not add a new
patient-creation path (link out to the f2 "add patient" flow for the empty case).
- **f4 catalog** ([frontend-phase-4-b5](./frontend-phase-4-b5.md)): a nurse's **service variants**
(`nurse_service_variants` — name, price unit `per_hour`/`per_session`/`per_half_day`/`per_day`/`per_24h`,
price). The service-type selector **reads** the chosen nurse's published variants.
- **f6 discovery** ([frontend-phase-6-b7](./frontend-phase-6-b7.md)): search (C1), results (C2), and the
**nurse profile (C3)** with its **درخواست رزرو** CTA. This phase is the destination of that CTA — wire
the navigation from C3 into the request form, passing the `nurse_id` (and optionally a pre-selected
variant).
> **Money/booking note:** there is **no payment and no `bookings` row** in this phase. The "pay & confirm"
> step (C6 summary, escrow notice, card/BNPL) is **(DEFERRED → [f9](./frontend-phase-9-b10.md))**. Booking
> detail, sessions, and EVV are **(DEFERRED → [f8](./frontend-phase-8-b9.md))**. Build only up to the two
> countdowns (response deadline, then payment deadline) and the hand-off CTA into checkout.
## 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) in full — the RSC/client boundary, layouts (never above
`[locale]`), i18n, theme/tokens, cookies, the `services/{domain}` fetch pattern, anti-patterns. Mirror
the **`auth`** service exactly when you create `services/bookingRequests`.
- **Invoke the `frontend-designer` skill** — mandatory for all visual work in this phase (C4 form, C5
awaiting screen + tracker, the nurse inbox list + detail). It is the brand/design contract: palette
(teal `#1d4a40`, terracotta `#d98c6a`, cream), tokens, typography, the `App*` library, the layout
shells, and the hard RTL/dark-mode rules. Do not hand-pick colours in `sx`.
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline. Study
**C3 → C4 → C5** and the nurse "نمای پرستار" framing. The screens this phase implements:
- **C4 · فرم درخواست** — patient selector (dropdown), service-type selector, address (map block, منزل),
date + time pickers, nurse-gender preference (**خانم / آقا / فرقی ندارد**). CTA: **ارسال درخواست**.
- **C5 · در انتظار تایید پرستار** — ⏳ status, "درخواست برای پرستار ارسال شد"; a summary card (nurse +
time); the 3-step tracker **درخواست ثبت شد → در انتظار تایید پرستار → پرداخت و تایید نهایی**; a
countdown to `nurse_response_deadline_at`. No CTA in the waiting state; on accept it shows the
payment-deadline countdown + a "continue to payment" CTA.
- **Nurse request inbox** — there is no dedicated wireframe panel, so design it consistently with the
nurse shell: a list of pending requests each with a per-request countdown, and a request-detail
showing **only** `customer_notes`, with accept / reject (reason) actions.
- [`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
— the request→accept→pay→confirm lifecycle, the frozen deadlines, the two-table split, same-gender
matching, and the two-stage clinical-disclosure rule. **These are decisions, not guesses — read them.**
- **The contract you consume:** [`../../contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md)
(from **backend-phase-8**) — the exact request/response shapes, routes, status codes, and enums.
Plus the shared conventions [`api-conventions.md`](../../contracts/conventions/api-conventions.md) and
[`money-and-types.md`](../../contracts/conventions/money-and-types.md) (envelope, snake_case routes,
pagination, enums-as-codes, UTC timestamps + Shamsi display, IRR-as-string, gender as load-bearing).
- The latest backend handoff `dev/shared-working-context/backend/handoff/after-backend-phase-8.md` — what
b8 shipped, which endpoints are live, and what (if anything) is still mocked server-side.
- The f6/f3 frontend reports in `dev/shared-working-context/reports/` — to reuse the patient/address/
variant query keys and the discovery navigation rather than re-fetching or re-deriving them.
## 3. Scope — build this
### 3.1 The `services/bookingRequests` domain (consume b8)
Create `src/services/bookingRequests/` by copying the `auth` template structure exactly:
- **`types.ts`** — string-literal union types mirroring the **booking-requests.md** contract (do **not**
guess shapes). At minimum:
- `RequiredCaregiverGender = 'male' | 'female' | 'any'` (the wire codes behind خانم/آقا/فرقی ندارد).
- `BookingRequestStatus = 'pending_nurse_response' | 'accepted_awaiting_payment' | 'rejected_by_nurse'
| 'expired_no_response' | 'payment_deadline_expired' | 'converted'`.
- `BookingRequestDto` (id, `nurse_id`, `patient_id`, `nurse_service_variant_id`, `customer_address_id`,
`scheduled_start_at` (UTC), `required_caregiver_gender`, `customer_notes`, `status`,
`nurse_response_deadline_at` (UTC), `payment_deadline_at` (UTC, nullable until accept),
`nurse_rejection_reason` (nullable), plus the display fields the contract returns — nurse name/avatar,
variant name + price-unit, patient name, address label). Money values (variant price) are **IRR digit
strings**, parsed via the f0 money util — never floats.
- `CreateBookingRequestPayload`, `RejectBookingRequestPayload` (reason).
- **`keys.ts`** — a query-key factory: `bookingRequestKeys.lists(role, statusFilter)`,
`bookingRequestKeys.detail(id)`, and the **nurse inbox** list key `bookingRequestKeys.nurseInbox(filter)`.
- **`apis/clientApi.ts`** — wrap `clientFetch` for each endpoint the contract defines (names from
**booking-requests.md** — expected, snake_cased per api-conventions):
- `POST .../booking_requests/create_booking_request` → create (customer).
- `GET .../booking_requests/list_booking_requests` → customer's requests, paginated, `status` filter.
- `GET .../booking_requests/get_booking_request` → one request (polled on C5).
- `GET .../booking_requests/list_nurse_requests` (or the contract's nurse-inbox route) → nurse's
incoming requests, paginated, default `status=pending_nurse_response`.
- `POST .../booking_requests/accept_booking_request` → nurse accept.
- `POST .../booking_requests/reject_booking_request` → nurse reject (with `nurse_rejection_reason`).
- **`hooks/` — one hook per file:**
- `useCreateBookingRequest.ts` (`useMutation`) — on success, navigate to C5 with the new id and
`setQueryData`/invalidate the customer list.
- `useBookingRequest.ts` (`useQuery`) — the C5 detail; **polls** while status is non-terminal
(`refetchInterval` ~ every 1530s while `pending_nurse_response` / `accepted_awaiting_payment`, and
**stops** on a terminal/`converted` status via a `select`/enabled guard) so the customer sees the
accept/reject/expire transition without a refresh.
- `useNurseRequestInbox.ts` (`useQuery`) — the nurse list, with light polling for new requests.
- `useAcceptBookingRequest.ts` / `useRejectBookingRequest.ts` (`useMutation`) — **invalidate the nurse
inbox list and the request detail** on success so the request leaves the pending list immediately.
- **`index.ts`** — barrel.
> If any shape the screens need is **missing** from booking-requests.md (e.g. the contract doesn't return
> the nurse's display name on the request DTO, or omits the price-unit), **append the gap** to
> `dev/shared-working-context/frontend/requests/for-backend.md` and **mock that field behind the
> `services/bookingRequests` clientApi seam** meanwhile (operating-rules §6). Record the mock in your
> report so it swaps out cleanly when b8 fills the gap. Never edit backend files.
### 3.2 C4 — the request form (customer)
A page under the customer shell (e.g. `(private-routes)/<customer-segment>/booking-requests/new`,
reachable from the C3 **درخواست رزرو** CTA with the `nurse_id`). RTL-first, mobile. Fields:
- **Patient selector** — a dropdown reading `services/patients` (f2). Empty state → a CTA linking to the
f2 "add patient" flow (don't inline patient creation here). The selected `patient_id` is sent.
- **Service-type selector** — reads the chosen nurse's `nurse_service_variants` (f4). Each option shows
the variant name + formatted price + price-unit label (i18n key off the `per_*` code, **not** a
hardcoded label). Sends `nurse_service_variant_id`.
- **Address block****reuse the f3 address picker / map block** (منزل/home), selecting a
`customer_address_id` from the address book (with the map preview). Do not rebuild the picker.
- **Date + time pickers** — produce a single UTC `scheduled_start_at` on the wire; **display** Shamsi via
the f0 date util. (Recurring/multi-session scheduling UI is **(DEFERRED → later)** — one start time here;
`session_count` is a server/booking concern.)
- **Nurse-gender preference** — a 3-option segmented control: **خانم (female) / آقا (male) / فرقی ندارد
(any)** → `required_caregiver_gender`. This is a **first-class field**, never a hidden default; if the
nurse's profile already fixes a gender, still send the explicit code the customer chose.
- **Request-stage notes** — a free-text field mapped to `customer_notes`. Copy must make clear this is the
*only* thing the nurse sees before accepting (it is **not** the clinical care record, which is
post-confirmation and **(DEFERRED → [f8](./frontend-phase-8-b9.md))**).
- **CTA: ارسال درخواست** — fires `useCreateBookingRequest`; loading state while the server computes/freezes
the deadline; on success navigate to C5. Surface domain `400`s (e.g. tenancy: patient/address not owned;
same-gender mismatch; variant not bookable) as field/form errors — but do **not** toast `401/403/5xx`
(the fetch layer already does).
Validate client-side at the boundary (all required fields chosen, future date) before enabling the CTA;
the server re-validates and is authoritative.
### 3.3 C5 — awaiting nurse acceptance + status tracker (customer)
A page keyed by the request id (e.g. `.../booking-requests/[id]`). Uses `useBookingRequest` (polling).
- **Header:** ⏳ "درخواست برای پرستار ارسال شد".
- **Summary card** — nurse (avatar + name), patient, service variant + price, address label, requested
time (Shamsi). Compose this as a **shared composite** (`src/components/...`) so the booking-detail screen
in f8 can reuse it (a "BookingRequestSummaryCard"); co-locate a `*.test.tsx`.
- **3-step status tracker** — reuse the **f0 stepper/progress header** composite:
1. **درخواست ثبت شد** (done as soon as the request exists),
2. **در انتظار تایید پرستار** (active while `pending_nurse_response`),
3. **پرداخت و تایید نهایی** (future; becomes active on `accepted_awaiting_payment`).
- **Countdown** — a `CountdownTimer` shared composite (`src/components/...`, co-located test) ticking down
to `nurse_response_deadline_at` (computed from the **server-supplied UTC instant** vs `Date.now()` — the
client never computes the deadline, only renders it). It is a pure presentational countdown; when it hits
zero, the UI shows "in expectation of server confirmation" and the poll resolves the real terminal
status. Use a single interval, cleaned up on unmount; do **not** re-render the whole page each tick
(isolate the ticking state in the timer component).
- **State transitions (driven by polled `status`):**
- `accepted_awaiting_payment` → swap step 2 to done, step 3 active; show **"✓ پرستار تایید کرد"**, a
prominent **30-minute payment countdown** to `payment_deadline_at`, and a CTA **"ادامه پرداخت ←"** that
navigates to checkout (**the checkout screen itself is [f9](./frontend-phase-9-b10.md) — wire the
route, stub the destination if f9 isn't merged**).
- `rejected_by_nurse` → terminal state card with the `nurse_rejection_reason` and a "request another
nurse" CTA back into discovery (f6).
- `expired_no_response` → terminal "no response in time" card + re-request CTA.
- `payment_deadline_expired` → terminal "payment window lapsed" card + re-request CTA.
- `converted` → the request became a booking → route to booking detail (**[f8](./frontend-phase-8-b9.md)**;
stub if not merged).
### 3.4 Nurse request inbox + detail (nurse)
Under the **nurse shell** (the wireframe's "نمای پرستار"), e.g. `(private-routes)/<nurse-segment>/requests`.
- **Inbox list** (`useNurseRequestInbox`) — pending requests, each row a card: patient first name/age,
service variant, requested time (Shamsi), the **required-caregiver-gender** chip, and a **per-request
countdown** to that request's `nurse_response_deadline_at`. Empty state: "درخواست جدیدی ندارید". Paginated
(page/page_size per api-conventions). Light polling so new requests appear.
- **Request detail** — shows the request summary **and only `customer_notes`** as the clinical context.
**It must never render `booking_care_instructions` or any encrypted clinical field** — those don't exist
pre-accept and are out of this contract; rendering them would break two-stage disclosure. Actions:
- **Accept** (`useAcceptBookingRequest`) — on success the request moves to `accepted_awaiting_payment`,
a `payment_deadline_at` is set server-side, and the customer's C5 (via its poll) starts the 30-min
payment countdown. Invalidate the inbox list + this detail.
- **Reject** (`useRejectBookingRequest`) — a small reason dialog capturing `nurse_rejection_reason`;
on success the request leaves the inbox. Invalidate the inbox list + detail.
- Both actions are disabled / show a terminal banner if the request already expired (the server returns
`409`/`400` for a stale accept/reject — surface it gracefully, then refetch).
### 3.5 i18n + tokens
Add a **`booking`** (and/or `bookingRequests`) namespace to **both** `messages/en.json` and
`messages/fa.json`, in sync, RTL-first. Every visible string is a key — the gender labels (خانم/آقا/فرقی
ندارد), the three tracker steps, the price-unit labels (off the `per_*` codes), all terminal-state copy,
countdown labels, and the empty states. Colours from `tokens.css` only; financial/terracotta accent (e.g.
the payment-deadline countdown) uses the brand terracotta token, not a literal.
## 4. Mocks & seams in this phase
This phase **introduces no new external seam** — booking requests carry **no money** and call no third
party. It only consumes the b8 HTTP contract.
- **Backend-not-ready / contract-gap fallback:** if `after-backend-phase-8.md` shows b8 isn't merged, or
booking-requests.md is missing a shape, build a **mock `clientApi`** behind the `services/bookingRequests`
seam (same function signatures the real one will have), driving a small in-memory state machine so the
whole flow is demoable: create → (timer or manual) accept/reject/expire. Record it in
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) and your report; swapping
to the real `clientApi` must be a one-file change. File any contract gap in
`dev/shared-working-context/frontend/requests/for-backend.md` (never edit backend files).
- **Reused seams:** the patient list (`services/patients`, f2), the address picker (`services/addresses`,
f3), and the nurse variants (`services/catalog` or f4's name) — **reuse**, do not redefine.
## 5. Critical rules you must not get wrong
- **Two-stage clinical disclosure.** The nurse sees **only** `customer_notes` before accepting — never any
encrypted `booking_care_instructions` or other clinical detail. That data isn't in this contract and must
not appear anywhere in the inbox/detail UI. Full care instructions are post-confirmation and belong to
[f8](./frontend-phase-8-b9.md).
- **`required_caregiver_gender` is a first-class field.** Always sent explicitly (`male`/`female`/`any`),
never defaulted or dropped — it drives same-gender bodily-care matching. The server re-validates; surface
a mismatch `400` clearly.
- **No money, no booking row here.** This is the request phase. Do not render a price-breakdown/escrow/pay
step (that's C6 / [f9](./frontend-phase-9-b10.md)) and do not assume a booking exists until `converted`.
- **Deadlines come from the server, frozen.** Render countdowns from the server-supplied UTC instants
(`nurse_response_deadline_at`, `payment_deadline_at`) against `Date.now()`; the client **never computes
or recomputes** a deadline. Show the **response** countdown pre-accept and the **30-minute payment**
countdown post-accept; show the correct **terminal** state (`rejected_by_nurse` / `expired_no_response`
/ `payment_deadline_expired`) when the poll resolves it.
- **Invalidate on accept/reject.** A nurse action must invalidate the inbox list + the request detail so
the request leaves the pending list immediately and the customer's polled C5 reflects it — never leave
stale cache. Equally, don't over-poll: stop polling once a terminal/`converted` status is reached.
- **Minimal re-renders.** The ticking countdown state is isolated in the timer component (not lifted to a
page that would re-render the form/summary every second). Stable query keys, `select` for slices.
- **RTL + both locales + tokens + MUI primitives.** `fa` default & RTL; every string in `en.json` and
`fa.json` in sync; colours from `tokens.css`; MUI v9 primitives/`App*` reused, shared composites
(summary card, countdown) at the shared level with co-located tests — never re-implement a root primitive
and never bury a reusable composite in a page.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] `services/bookingRequests` exists (types/keys/apis/hooks/index), typed **from** booking-requests.md
(gaps filed + mocked behind the seam, not guessed).
- [ ] **C4 request form** submits a valid request (patient + variant + address + date/time + gender +
notes) and navigates to C5; client-side validation gates the CTA; domain `400`s surface as
form/field errors.
- [ ] **C5 awaiting screen** shows the summary card, the 3-step tracker, and a live countdown to
`nurse_response_deadline_at`; it transitions (via poll) through accept (→ 30-min payment countdown +
checkout CTA), reject, and both expiry terminal states.
- [ ] **Nurse inbox** lists pending requests (with per-request countdown + gender chip), the detail shows
**only `customer_notes`**, and accept/reject work and **invalidate the inbox + detail**.
- [ ] Polling stops on terminal/`converted` status; no needless refetch; the ticking countdown doesn't
re-render the whole page.
- [ ] `booking`/`bookingRequests` i18n keys added to **both** locales in sync; colours from tokens; RTL
verified.
- [ ] `npm run check` green; `npm run test:ci` green for the new shared composites (summary card,
countdown timer) and any touched shared component.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new route segments + the `services/bookingRequests`
domain and any new shared components.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (point `NEXT_PUBLIC_API_URL` at a b8 server, or use the mock `clientApi` seam if b8
isn't merged). Then:
1. **Submit a request.** From a nurse profile (C3) tap **درخواست رزرو** → on C4 pick a patient, a service
variant, an address (map block), a future date/time, and a gender preference (خانم/آقا/فرقی ندارد),
add a note → **ارسال درخواست**. *Expected:* land on **C5** showing the summary card, the 3-step tracker
(step 2 active), and a countdown ticking down to the nurse's response deadline.
2. **Nurse sees it.** In the nurse shell open **requests** (inbox). *Expected:* the new request appears
with the patient/variant/time, the **required-gender chip**, and a per-request countdown; opening the
detail shows **only the `customer_notes`** — no clinical/care fields anywhere.
3. **Nurse accepts.** Tap accept. *Expected:* the request leaves the pending inbox immediately
(cache invalidated); on the customer's **C5** (without a manual refresh, via the poll) step 2 flips to
done, step 3 activates, a **✓ پرستار تایید کرد** badge appears, the **30-minute payment countdown**
starts, and the **ادامه پرداخت ←** CTA appears (routing toward checkout/f9).
4. **Reject path.** On a different request, nurse rejects with a reason. *Expected:* customer's C5 shows the
terminal **rejected** card with the reason + a re-request CTA back into discovery.
5. **Expiry paths.** Let (or simulate via the mock) the response deadline lapse → C5 shows
**expired_no_response**; let the payment window lapse after accept → C5 shows **payment_deadline_expired**.
Both are terminal with a re-request CTA.
6. **Quality:** locale switch flips `dir` + strings on every screen; dark mode holds; `npm run check` and
`npm run test:ci` pass; React Query Devtools shows the inbox/detail invalidating on accept/reject and the
poll stopping at a terminal status.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* (the new customer + nurse route segments, the
`services/bookingRequests` domain, the new shared composites — `BookingRequestSummaryCard`,
`CountdownTimer`). Fix any doc drift you touch. If you discover/decide a request-flow rule the
`product/` docs don't capture (e.g. the exact tracker wording, the re-request UX), note it in
[`product/business/05-booking-and-scheduling.md`](../../../product/business/05-booking-and-scheduling.md)
(don't invent rules — record decisions) and regenerate the HTML view per `product/CLAUDE.md` if you
edited Markdown.
- **Contracts:** this phase **consumes** [`../../contracts/domains/booking-requests.md`](../../contracts/domains/booking-requests.md)
— derive `services/bookingRequests/types.ts` from it; produce no contract. Append any missing/ambiguous
shape to `dev/shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append your phase summary to
`dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-7-report.md` covering *what was built* (C4/C5/nurse
inbox + the domain service), *what is now testable and exactly how* (the steps in §7), *what is mocked*
(any contract-gap field behind the `services/bookingRequests` seam + how it swaps to real), *contracts
consumed* (booking-requests.md) and any gaps filed, and *follow-ups* for f8/f9 (the `converted` → booking
detail handoff, the payment CTA → checkout handoff). Update
[`mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) only if you added a
client-side mock seam.
- **Memory:** save a `project` memory note for the non-obvious decisions here — the dual-countdown design
(server-frozen deadlines, client renders only), the polling-until-terminal pattern for request status,
and the two-stage-disclosure boundary in the nurse inbox — with a one-line pointer in `MEMORY.md`.
+343
View File
@@ -0,0 +1,343 @@
# Frontend Phase 8 — Booking detail, sessions & nurse EVV
> **Mission:** turn an accepted-and-paid request into a living engagement on screen. Build the
> **booking-detail** view both actors share — a server-truth **status timeline**
> (`pending_payment → confirmed → in_progress → completed → disputed/closed/cancelled`) and the
> per-visit **session schedule** — plus the nurse's day-one operational surface: **EVV check-in/out**
> (capture GPS, post it, show the "ورود ثبت شد · موقعیت تایید شد (EVV)" banner) and the **care-instructions**
> read that is unlocked **only post-confirmation to the assigned nurse**. This is the screen where the
> trust promise (proof of service, two-stage clinical disclosure, escrow-then-release) becomes visible,
> and it is the launch pad for checkout (f9) and reviews/records (f13).
>
> **Track:** frontend · **Depends on:** [`frontend-phase-7-b8.md`](./frontend-phase-7-b8.md) (request flow + booking lists/status tracker) and the **b9** contract ([bookings-evv.md](../../contracts/domains/bookings-evv.md)) · **Unlocks:** checkout & payment (`frontend-phase-9-b10.md`), reviews & patient records (`frontend-phase-13-b14.md`)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at **f8** of the frontend chain. The customer can now search, open a nurse, send a request, and
watch it move `pending_nurse_response → accepted_awaiting_payment` (built in f7); the nurse has an inbox
to accept/reject. The backend's **b9** phase has just landed the *booking* phase: a request that was
accepted **and** paid converts to a `bookings` row with `booking_sessions`, encrypted
`booking_care_instructions`, and per-session `visit_verifications` for EVV. This phase puts a face on all
of that. It is the hinge between "I asked for a nurse" and "a nurse is actually delivering care" — the
booking detail is where the customer watches the engagement progress and the nurse does their job.
Two product truths drive every decision here, and both are non-negotiable:
- **Two-stage clinical disclosure** (Principle 6): full care instructions are encrypted and revealed
**only after confirmation, only to the assigned nurse + admin**. The UI must *gate* this, never leak it.
- **EVV is proof of service** ([06-evv-and-service-delivery.md](../../../product/business/06-evv-and-service-delivery.md)):
the nurse clocks in/out **per session** with GPS; a location mismatch is **advisory** (a banner, never a
block); EVV check-out is what eventually makes a session payout-eligible (after the dispute window — that
gating lives server-side; we just render its result).
**What already exists (do not rebuild) — link and extend, never re-create:**
- **f0 foundations** ([`frontend-phase-0.md`](./frontend-phase-0.md)): the three actor app shells +
route groups, the `services/{domain}` + TanStack Query caching pattern (the `auth` service is the
template — `types.ts`/`keys.ts`/`apis/clientApi.ts`/`hooks/use*.ts`/`index.ts`), the money/format util
(`formatIrrToToman`, Shamsi date display), and shared composites (status chip, stepper/progress header,
cards). **Reuse the status-chip and the stepper/progress-header composites here** — do not build new ones.
- **f7 booking-request flow** ([`frontend-phase-7-b8.md`](./frontend-phase-7-b8.md)): the
`services/bookings` domain seam already exists with the **request** half (`CreateBookingRequest`,
request list/detail, the C5 awaiting-acceptance status tracker, the nurse request inbox). **This phase
extends the same `services/bookings` domain** with the booking/session/EVV half — same `keys.ts`
factory, same `clientApi.ts`, same hook conventions. Do **not** create a parallel domain.
- **The app chrome**: the customer mobile shell with the 5-tab bottom nav (the **رزروها/Bookings** tab is
the customer entry to booking detail), the nurse shell (where EVV and the day's schedule live), the
RSC root layout, themes/tokens, `clientFetch`/`serverFetch`, the cookie manager, `AuthContext` (roles),
the toast bridge. None of this is rebuilt.
> **Reminder:** backend phases own the contracts. If a shape you need (e.g. a session's `payout_eligible_at`,
> the care-instructions decrypt response, the GPS-match result) is missing from
> [bookings-evv.md](../../contracts/domains/bookings-evv.md), you **append a request** to
> `dev/shared-working-context/frontend/requests/for-backend.md` and **mock behind the `services/bookings`
> seam** meanwhile (operating-rules §6). You never edit backend files.
## 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) — how you
work and the tick-list (RSC boundary, `clientFetch`/services-only, Query caching + invalidation, minimal
re-renders, MUI primitives reused, i18n both locales, tokens, RTL).
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) in full — the engineering contract (layouts,
RSC/client boundary, i18n, theme, cookies, fetch services, anti-patterns). Trust the code over any stale
doc note (the f0 audit found small drift); fix the doc if you touch that area.
- **Invoke the `frontend-designer` skill** — this phase is heavy on visual surfaces (status timeline, a
session list with per-session state, the EVV check-in/out screen, the EVV success banner, the care
instructions card). **All visual work goes through the skill** (palette, tokens, typography, the `App*`
library, RTL rules). Brand: teal `#1d4a40`, terracotta `#d98c6a` for financial/EVV affordances — the
wireframe gives the nurse view a **terracotta border** ("نمای پرستار").
- [`../../../product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
— the EVV business rules: per-session GPS check-in/out, advisory address-match tolerance
(`evv_location_tolerance_meters`), no-show alerting (server-side), payout gated on EVV completion **and**
closed dispute window. **Read this before building the EVV screen** — the rules are decisions, not guesses.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — the visual baseline.
The screens this phase implements:
- **Booking detail + status timeline** — the both-roles view (C5 tracker grows into a full timeline;
states `pending_payment/confirmed/in_progress/completed/disputed/cancelled`), session list with
per-session schedule/status, money summary (gross/commission/tax labels).
- **E3 (top half)** — the **nurse EVV** surface: header "ویزیت امروز", the **check-in banner**
"ورود ثبت شد ۰۹:۰۲ · موقعیت تایید شد (EVV)", today's-task checklist awareness, and the **"ثبت خروج
(EVV)"** check-out action. *(The bottom half of E3 — the free-text visit-note authoring — and the full
E2 patient-record viewer are **(DEFERRED)** to [`frontend-phase-13-b14.md`](./frontend-phase-13-b14.md).)*
- **Care-instructions (post-confirm)** — the decrypted clinical/logistical context the assigned nurse
sees only after confirmation; in the customer flow this is the "Care details" surface authored after pay.
- [bookings-evv.md](../../contracts/domains/bookings-evv.md) — **the contract you consume.** It is the
source of truth for: the booking-detail response (status enum, the three money amounts, `session_count`,
`dispute_window_ends_at`), the session shape (`booking_session_id`, schedule, per-session `status`,
`visit_payout_amount`, `payout_eligible_at`), the EVV check-in/out request+response
(`check_in_address_match` advisory result, timestamps, status), and the gated care-instructions read.
**Do not guess these shapes** — derive `types.ts` from this doc; file any gap to the for-backend request
log and mock meanwhile.
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) +
[`api-conventions.md`](../../contracts/conventions/api-conventions.md) — the envelope, **IRR-as-string +
Toman display**, enums-as-codes, **UTC timestamps + Shamsi display**. The session schedule and EVV
timestamps are UTC; render them Shamsi. Money renders through the f0 `formatIrrToToman` util.
- The existing `services/auth/*` and the **f7 `services/bookings/*`** — copy their exact structure; you are
extending the latter, not starting fresh.
## 3. Scope — build this
Everything below lives under the **customer mobile shell** (booking detail, opened from the رزروها tab) and
the **nurse shell** (booking detail + the EVV/today surface). All server access goes through the
**`services/bookings`** domain (extended from f7). All money via `formatIrrToToman`; all timestamps Shamsi.
### 3.1 `services/bookings` — extend the domain (data layer)
Extend, do not replace, the f7 `services/bookings` files:
- **`types.ts`** — add, derived from [bookings-evv.md](../../contracts/domains/bookings-evv.md):
`BookingDetailDto` (id, status enum `pending_payment|confirmed|in_progress|completed|disputed|closed|cancelled`,
`gross_price_irr`/`balinyaar_commission_irr`/`nurse_payout_amount` as **IRR strings**, `session_count`,
`dispute_window_ends_at`, nurse/patient/address snapshot fields), `BookingSessionDto`
(`booking_session_id`, scheduled start/end, per-session `status` `scheduled|in_progress|completed|missed|cancelled`,
`visit_payout_amount`, `payout_eligible_at`), `VisitVerificationDto` (check-in/out timestamps,
`check_in_address_match` advisory boolean/score, `status`), `CareInstructionsDto` (decrypted conditions /
meds / allergies / instructions / emergency contact — **only present in the gated response**), and the
EVV command DTOs `CheckInVisitInput`/`CheckOutVisitInput` (`booking_session_id`, `latitude`, `longitude`,
client `captured_at`). Status enums are **codes**, mapped to i18n labels in the UI — never display the raw code.
- **`keys.ts`** — add to the existing factory: `bookingDetail(id)`, `bookingSessions(bookingId)`,
`sessionEvv(sessionId)`, `careInstructions(bookingId)`. Keep the f7 request keys.
- **`apis/clientApi.ts`** — add the calls (names follow the contract routes; b9 owns the exact paths):
`getBookingDetail(id)`, `listBookingSessions(bookingId)`, `getCareInstructions(bookingId)` (the gated
read), `checkInVisit(input)`, `checkOutVisit(input)`, `getSessionEvv(sessionId)`. Each wraps
`clientFetch`**never raw `fetch`**. Add a `serverApi.ts` `getBookingDetail` **only if** you prefetch
the detail in an RSC (recommended — see 3.5).
- **Hooks (one per file)** under `hooks/`:
- `useBookingDetail(id)``useQuery` on `bookingDetail(id)`; deliberate `staleTime` (detail changes on
status transitions — keep modest, e.g. 30s, and **invalidate on EVV mutations** so the timeline reflects
server truth immediately).
- `useBookingSessions(bookingId)``useQuery` on `bookingSessions(bookingId)`.
- `useCareInstructions(bookingId, { enabled })``useQuery`, **`enabled` gated** by `status === 'confirmed'
|| beyond` **and** the viewer being the assigned nurse or admin (see 3.4). When disabled it never fires.
- `useCheckInVisit()` / `useCheckOutVisit()``useMutation`; on success **invalidate** `bookingDetail`,
`bookingSessions`, and `sessionEvv` (so the timeline, the session row, and the EVV banner all reflect
the new server state — no manual optimistic money/status math). Surface only domain-specific 4xx
messages (e.g. "no open check-in" on check-out); let the fetch layer handle 401/403/5xx toasts.
### 3.2 Booking detail + status timeline (both roles) — `app` route + components
- **Route:** a booking-detail page under the existing `(private-routes)` structure, reachable from the
customer **رزروها/Bookings** list (built in f7) and from the nurse booking list. Keep it role-aware via
`AuthContext` — same page, role-conditioned sections (the EVV actions and care-instructions card render
for the **assigned nurse**; the customer sees the read-only timeline + sessions + money summary).
- **Status timeline** — a shared composite `BookingStatusTimeline` (in `src/components/booking/`,
co-located test) that renders the canonical order `pending_payment → confirmed → in_progress →
completed`, with terminal branches `disputed/closed/cancelled` shown distinctly. **It reflects server
truth** (`BookingDetailDto.status`) — never advance a step client-side. Reuse the f0 stepper/progress
header primitive underneath; this is the grown-up version of the f7 C5 3-step tracker.
- **Session schedule list** — a `SessionList` composite rendering `BookingSessionDto[]`, each row a
`SessionCard` showing Shamsi schedule, a per-session **status chip** (reuse the f0 status chip — map
`scheduled/in_progress/completed/missed/cancelled` to labelled, tokenised colours) and, for the assigned
nurse, the EVV CTA state (see 3.3). **A single-visit booking still renders exactly one session row**
do not special-case it away; the data always has ≥1 session.
- **Money summary** — a small `BookingMoneySummary` showing gross / Balinyaar commission (کارمزد) / and the
nurse-payout split, each via `formatIrrToToman`. (Tax/مالیات line + the escrow notice are the **checkout**
surface — **(DEFERRED)** to [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md); here just show the
confirmed split. If the detail response omits a line, label what you have and don't fabricate.)
- **States:** loading (skeleton), the per-status content (confirmed shows upcoming sessions; in_progress
highlights the active session; completed shows the dispute-window note from `dispute_window_ends_at`;
cancelled/disputed terminal copy), and an empty/not-found guard if the id isn't the viewer's booking.
### 3.3 Nurse EVV check-in / check-out (E3 top) — nurse shell
- **Today / session-aware surface** — within the nurse booking detail (and reachable from the nurse "today"
context), render the **per-session EVV control**. CTA state machine driven by session + EVV data:
`scheduled`**"ثبت ورود (EVV)"** (check-in); `in_progress` (checked-in, no check-out) → **"ثبت خروج
(EVV)"** (check-out); `completed` → done (show elapsed duration); `missed` → missed state. Today's-task
awareness: show the session's task context (the **task checklist body is read-only here** — authoring is
f13).
- **GPS capture** — a small client util `captureGeoPosition()` behind a DI-style seam
(`src/services/bookings/evv/locationProvider.ts` exposing `ILocationProvider`) wrapping the browser
Geolocation API. On check-in/out: acquire position (loading spinner "در حال دریافت موقعیت…"), then call
`useCheckInVisit`/`useCheckOutVisit` with `{ booking_session_id, latitude, longitude, captured_at }`.
**Permission-denied / unavailable is not a hard stop** — the product rule says mismatch is advisory, so
allow the nurse to proceed (submit without coords or with a flagged value per the contract) and show the
advisory banner; never block the visit on GPS.
- **The EVV check-in banner** — on a successful check-in (and whenever an open check-in exists), render the
wireframe banner verbatim in spirit: **"ورود ثبت شد {{time}} · موقعیت تایید شد (EVV)"** when
`check_in_address_match` is true, and the **advisory** variant **"ورود ثبت شد {{time}} · موقعیت خارج از
محدوده (در حال بررسی)"** when it is false — a tokenised warning banner, **not** an error, and it does
**not** block check-out. Build it as a shared composite `EvvStatusBanner` (co-located test) since both the
session card and the day surface use it. Time renders Shamsi/clock from the server `checked_in_at`.
- **Check-out** — requires an open check-in; if none, show the domain message (don't toast the fetch layer's
generic). On success the session row flips to `completed` (via the invalidations in 3.1) and shows elapsed
duration. **Do not compute or display payout-eligibility client-side**`payout_eligible_at` and the
dispute-window gate are server truth; render whatever the detail/session response gives you.
### 3.4 Care-instructions read (two-stage disclosure) — gated card
- A shared `CareInstructionsCard` (in `src/components/booking/`, co-located test) that renders the decrypted
`CareInstructionsDto` (conditions / medications / allergies / instructions / emergency contact).
- **Gate the UI, hard:** the card and its query (`useCareInstructions`) render/fire **only when** the booking
is `confirmed` or beyond **and** the current user is the **assigned nurse** (or admin). For anyone else —
the customer, an unassigned nurse, a pre-confirmation viewer — the card is **not rendered and the query
never fires**. This is the client mirror of the server's gated `GetCareInstructions`; the server is the
real boundary, but **the UI must not even request** instructions it has no right to (a 403 from the server
is a defect path, not the design). Show a neutral "visible to your assigned nurse and support only"
affordance to the customer instead.
- The customer-side authoring of care details (the post-confirmation "Care details" form,
`SubmitCareInstructions`) is the **write** path — if b9's contract exposes it, build a minimal read-back
here; the full encrypted authoring form is owned by the booking write flow and may be **(DEFERRED)** to
f13 if the contract doesn't surface it in b9. Read the contract; if absent, mock the read and log the gap.
### 3.5 RSC prefetch (remove a client round-trip)
Where the booking detail is the first paint, prefetch `getBookingDetail(id)` in the RSC and hand it to the
client tree via `initialData` / a hydrated query (the f0 pattern). Keep the EVV mutations + care-instructions
on the client. Respect the RSC/client boundary (no `@/lib/cookies/client` in an RSC; no
`next-intl/server`/`next/headers` in a client component).
### 3.6 i18n + tokens
- Add the user-visible strings under the existing **`booking`** namespace (and reuse `common`/`nav`) in
**both** `messages/en.json` and `messages/fa.json`, in sync — status labels, session statuses, the EVV
banner strings (in-range + advisory out-of-range), check-in/out CTAs, GPS-acquiring text, the
care-instructions section labels + the customer "visible to your nurse only" copy, the dispute-window note.
`fa` is default + RTL — design RTL-first and verify mirroring (the timeline and session rows must read
right-to-left correctly).
- All colours from `tokens.css` (`var(--bal-…)`); the EVV/financial terracotta affordance via the brand
token, the advisory banner via the warning token, status chips via the status-chip tokens. No hardcoded
colours in `sx`. MUI **v9** API only; reuse `APP_THEME_LTR/RTL`.
**Out of scope (DEFERRED — pointers, do not build here):**
- Full **patient-record viewer (E2)** and **nurse visit-NOTE authoring (E3 bottom)**
[`frontend-phase-13-b14.md`](./frontend-phase-13-b14.md).
- **Checkout / pay / escrow notice / invoice / tax line** → [`frontend-phase-9-b10.md`](./frontend-phase-9-b10.md).
- **Cancellation flow + refund/policy-fee disclosure** → [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md).
- **Admin EVV-review queue** (location-mismatch / no-show worklist) → admin console
[`frontend-phase-15-b15.md`](./frontend-phase-15-b15.md). This phase raises no alerts client-side; no-show
detection is a server job.
## 4. Mocks & seams in this phase
- **This phase introduces one client seam: `ILocationProvider`** (`src/services/bookings/evv/locationProvider.ts`)
— wraps the browser Geolocation API for EVV GPS capture. The real implementation calls
`navigator.geolocation.getCurrentPosition`; a **mock** returns canned coordinates so the EVV flow is
testable without a device and so a denied/unavailable path can be exercised. Record it in
`dev/shared-working-context/reports/mocks-registry.md` with the seam, what it fakes, the config key (e.g.
`NEXT_PUBLIC_EVV_MOCK_GPS`), and how to make it real. **Server-side** GPS/address-match math lives behind
the backend's geocoding/geo-distance seam — **reuse it from b9**, do not introduce a server seam here.
- **`services/bookings` data, if b9 isn't merged yet:** build the booking/session/EVV/care calls behind the
same `services/bookings` `clientApi` as a **mock `clientApi`** (real-shaped per the contract), select it
by config exactly as f0 established, and record it in your frontend report so it swaps cleanly when the
real endpoints land. The EVV mutations' mock should flip the mocked session/EVV state so the banner +
timeline transitions are demonstrable.
- **Everything else is reused:** `clientFetch`/`serverFetch`, the Query client, the cookie/auth manager, the
toast bridge, the f0 composites (status chip, stepper). Introduce no new cross-cutting seam beyond
`ILocationProvider`.
## 5. Critical rules you must not get wrong
- **Two-stage clinical disclosure is a UI gate, not just a server check.** Care instructions render and are
**queried only** when the booking is `confirmed`+ **and** the viewer is the assigned nurse or admin. An
unassigned user — including the customer and any other nurse — must **never** see the instructions and the
client must **never even fire the request**. Treat a 403 as a defect path, not the intended flow.
- **A GPS / address mismatch is advisory — a banner, never a block.** Out-of-range check-in still succeeds;
show the warning-tokened "موقعیت خارج از محدوده (در حال بررسی)" banner and let check-out proceed. Never
auto-cancel, never withhold the visit, never gate check-out on the match. Permission-denied/unavailable
GPS likewise must not block the nurse.
- **The status timeline reflects server truth.** `BookingDetailDto.status` is the single source — never
advance, infer, or optimistically jump a timeline step on the client. After an EVV mutation, **invalidate**
the detail/session queries and re-render from the server response.
- **Money is display-only here and never computed.** Render `gross_price_irr` / `balinyaar_commission_irr` /
`nurse_payout_amount` exactly as the server sends them (IRR **strings**, integer-safe), through
`formatIrrToToman`. Do not sum, derive, or re-split amounts on the client; the booking money identity
(`gross = commission + payout`) and per-session `visit_payout_amount` are computed and guaranteed
server-side. **Do not compute payout-eligibility**`payout_eligible_at` is gated by the dispute window
server-side; one payout per booking is enforced server-side; render, don't recompute.
- **A single-visit booking still has exactly one session.** Render the one `booking_sessions` row through the
same `SessionCard` path; no "0 sessions" or single-visit special case.
- **Caching is a feature.** Set deliberate `queryKey`/`staleTime`; **invalidate on every EVV mutation** so
the timeline, session row, and EVV banner update without a full refetch storm. Don't refetch data already
in cache; prefer the RSC prefetch for first paint.
- **Boundaries & primitives:** respect the RSC/client boundary; fetch only through `services/bookings`
`clientFetch`/`serverFetch`; MUI primitives stay MUI; the shared composites
(`BookingStatusTimeline`/`SessionCard`/`EvvStatusBanner`/`CareInstructionsCard`) live at the shared level
with co-located tests, not buried in a page. Minimise re-renders (stable refs, `select`, colocated state).
- **i18n + RTL + tokens:** every string in both locale files, in sync; `fa` default & RTL-correct; colours
from `tokens.css`; MUI v9 only. Invoke the **frontend-designer** skill for all visual work.
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] Booking detail renders for both roles from `useBookingDetail`, with a server-truth
`BookingStatusTimeline` covering all seven statuses and a `SessionList` (≥1 session, single-visit
included), money summary via `formatIrrToToman`.
- [ ] Nurse EVV: check-in captures GPS through `ILocationProvider`, posts via `useCheckInVisit`, and shows
the in-range **"ورود ثبت شد … موقعیت تایید شد (EVV)"** banner or the advisory out-of-range variant;
check-out via `useCheckOutVisit` requires an open check-in and flips the session to `completed`
(server-driven, via invalidation). GPS denial does not block.
- [ ] Care-instructions card + query are **gated**: visible/fired only for the assigned nurse (or admin) on a
`confirmed`+ booking; never rendered/requested for the customer or an unassigned user (verified by test).
- [ ] All new strings in `en.json` **and** `fa.json`, in sync; RTL verified; colours from tokens; MUI v9.
- [ ] `services/bookings` extended (not duplicated); hooks invalidate on EVV mutation; types derived from
[bookings-evv.md](../../contracts/domains/bookings-evv.md) (gaps logged to for-backend + mocked).
- [ ] The shared composites (`BookingStatusTimeline`, `SessionCard`, `EvvStatusBanner`, `CareInstructionsCard`)
each have a co-located `*.test.tsx`; `npm run check` green; `npm run test:ci` green.
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `src/components/booking/` folder, the
`services/bookings` extension, and the `ILocationProvider` evv seam.
## 7. How to test (what a human can verify after this phase)
Run `npm run dev` (with the b9 endpoints live, or the mock `clientApi` selected by config). Walk these:
1. **Open a confirmed booking** from the customer رزروها/Bookings tab → the page shows the **status timeline**
sitting at `confirmed`, the **session schedule** (single-visit shows exactly one session; multi-day shows
N), and the **money summary** in Toman. Switch locale → strings + `dir` flip correctly, timeline reads RTL.
2. **As the assigned nurse**, open the same booking → the **care-instructions card** is visible (conditions/
meds/allergies/instructions/emergency contact). **As the customer or an unassigned nurse**, the card is
absent and the network tab shows the care-instructions request **was never made**.
3. **Nurse check-in** on today's session → "در حال دریافت موقعیت…" spinner, then the **EVV banner** "ورود ثبت
شد ۰۹:۰۲ · موقعیت تایید شد (EVV)"; session chip → `in_progress`; timeline → `in_progress`. With the mock
GPS forced out-of-range, the banner shows the **advisory** out-of-range variant and check-in **still
succeeds**. Deny GPS permission → the nurse can still proceed.
4. **Nurse check-out** → session chip → `completed` with elapsed duration; the booking timeline advances to
`completed` from the server response (no client-side step jump). Check-out before any check-in → the
domain "no open check-in" message, no generic toast.
5. **Caching:** in React Query Devtools, the EVV mutation **invalidates** `bookingDetail`/`bookingSessions`/
`sessionEvv` and the UI re-renders from the refetch; revisiting the page within `staleTime` does not
refetch.
6. `npm run check` and `npm run test:ci` pass; the gating test proves the customer/unassigned path never
renders or requests care instructions.
## 8. Hand off & document (close the phase)
- **Docs:** update the **Project Structure** tree in [`client/CLAUDE.md`](../../../client/CLAUDE.md) for
`src/components/booking/`, the extended `services/bookings`, and the `services/bookings/evv` location
seam; add a short note that the booking-detail/EVV pattern (timeline + sessions + gated care + EVV banner)
is the template f9/f13 extend. Fix any doc drift you touch. If you discover/confirm a business rule the
`product/` docs don't capture (e.g. the exact advisory-banner copy or the post-confirm disclosure timing),
record it in [`product/business/06-evv-and-service-delivery.md`](../../../product/business/06-evv-and-service-delivery.md)
(don't invent rules) and regenerate the HTML per `product/CLAUDE.md`.
- **Contract (consume):** types/services derive from
[`dev/contracts/domains/bookings-evv.md`](../../contracts/domains/bookings-evv.md) (b9). Any missing or
ambiguous shape — care-instructions decrypt response, `check_in_address_match` representation,
`payout_eligible_at` exposure, the care-details write-back — is **appended** to
`dev/shared-working-context/frontend/requests/for-backend.md` (you never edit backend files), and mocked
behind `services/bookings` meanwhile.
- **Handoff & report:** append your phase summary to
`dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-8-report.md` — what was built (booking detail/timeline,
sessions, nurse EVV, gated care), **what is now testable and exactly how** (the §7 steps), what is mocked
(`ILocationProvider`, any mocked `services/bookings` calls) and how to make it real, the contract consumed
+ gaps filed, follow-ups for f9/f13. Update
`dev/shared-working-context/reports/mocks-registry.md` for the `ILocationProvider` seam and any mocked
endpoint.
- **Memory:** save a `project` memory note for the non-obvious decisions — the two-stage care-instructions
**UI gate** (don't-even-fetch), the **advisory-not-blocking** EVV mismatch handling, the
`ILocationProvider` GPS seam, and that the status timeline is strictly server-truth — with a one-line
pointer added to `MEMORY.md`.
+342
View File
@@ -0,0 +1,342 @@
# Frontend Phase 9 — Checkout, card payment & invoice
> **Mission:** turn an accepted booking request into paid, confirmed money on the rails. Build the
> **C6 خلاصه و پرداخت** summary screen (the "✓ پرستار تایید کرد" badge, the reconciling
> service-cost / commission / tax / total breakdown, and the load-bearing escrow trust notice), then
> the **card payment** flow — initiate → mock gateway redirect → return → pending callback →
> succeeded → booking flips to **confirmed** — followed by the **confirmation** screen and a
> downloadable **invoice** with the VAT-on-commission line. This is the family's first real money
> moment in Balinyaar; the breakdown must reconcile to the rial and the escrow copy must build trust,
> because the whole anti-disintermediation thesis rests on payment happening *on-platform*.
>
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9.md`](./frontend-phase-8-b9.md) (booking
> detail / sessions / EVV) + backend phase **b10** (Payments core — the contract you consume) ·
> **Unlocks:** [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md) (refunds & cancellation),
> [`frontend-phase-11-b12.md`](./frontend-phase-11-b12.md) (BNPL checkout)
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
---
## 1. Context — where this sits
We are at the payment seam of the customer journey. By the end of **f7-b8** the family can send a
booking request and a nurse can accept it; by the end of **f8-b9** there is a booking-detail screen,
session list, and EVV. What's missing is the bridge the wireframe calls **C6**: once the nurse has
accepted (`accepted_awaiting_payment`), the family sees the price, pays by card, and the
`booking_request` converts into a money-bearing `booking` that reaches **confirmed**. Backend **b10**
just shipped the money core (ledger, transactions, webhook idempotency, card capture → confirm →
convert) behind the `IPaymentProvider`/`IWebhookVerifier` seams; this phase is its frontend
counterpart. After this, **f10** can cancel/refund and **f11** can offer BNPL as an alternative to
the full-card path you build here.
**What already exists (do not rebuild) — from prior phases:**
- **The foundation (f0)** — the three actor shells + route groups, the `services/{domain}` + TanStack
Query caching pattern (copy the `auth` service shape), the contracts→types pattern, the **money/format
util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date display) that
**every** price in this phase renders through, the shared composite **price-breakdown** and
**status-chip** components, the i18n namespaces (including `payment`), and the RTL baseline. See
[`frontend-phase-0.md`](./frontend-phase-0.md).
- **Auth & role routing (f1-b2)**`AuthContext` with roles; the customer app shell + 5-tab bottom nav.
- **Booking request flow (f7-b8)** — the request form (C4) and **awaiting-acceptance (C5)** status
tracker. C6 is the *next* node after that tracker's "در انتظار تایید پرستار" step resolves to
accepted; reuse C5's status-tracker component and the `booking`/`booking_requests` service shapes.
- **Booking detail & sessions (f8-b9)** — the `services/booking` domain (detail/list queries, status
timeline, the three-amount split already present on the booking payload), the booking status chip,
and the booking-detail route this phase links *back to* after confirmation. **Reuse this service**;
do not create a parallel booking service.
> **Out of scope here (DEFERRED):** the **BNPL** method/plan/eligibility/contract screens (D1D5) — that
> is [`frontend-phase-11-b12.md`](./frontend-phase-11-b12.md); this phase builds **only** the full-card
> path and must leave a clean "یا پرداخت اقساطی" seam on C6/D1 for f11 to attach to. **Cancellation &
> refund** (policy fee disclosure, refund status, BNPL ETA) is
> [`frontend-phase-10-b11.md`](./frontend-phase-10-b11.md). The admin-side refund console is **f15**. The
> مودیان (e-invoicing) registration state is a backend concern — surface it read-only if the contract
> exposes it, never drive it.
## 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) — how you
work, the gate, the contract/handoff lanes.
- [`../../../client/CLAUDE.md`](../../../client/CLAUDE.md) — the engineering contract (RSC/client boundary,
`services/{domain}` + Query caching, the toast contract: do **not** toast 401/403/5xx in hooks, only
domain 4xx; cookies/constants rules; MUI v9 only). Non-negotiable.
- **Invoke the `frontend-designer` skill** — the design/brand contract (palette, the **terracotta**
financial accent vs **teal** brand, tokens, typography, the `App*` library, the mobile RTL shell, the
hard UI rules). **All visual work on C6, the redirect/pending/confirmation states, and the invoice goes
through it.** Do not hand-style money screens off-token.
- **The contract you consume:** [`../../contracts/domains/payments.md`](../../contracts/domains/payments.md)
(produced by **b10**) — the request/response shapes, routes, status codes, and the **payment status
enum** for initiate / verify / confirm / get-transaction. If the **invoice** shape lives in a separate
doc, also read `../../contracts/domains/refunds-invoices.md` (from **b11**) — if that file is not yet
published, mock the invoice behind the `services/payment` seam and file the gap (see §4 and §8).
Always cross-check against the published `../../contracts/openapi/README.md` / `swagger.json` snapshot
for exact casing — **derive types from the contract, never guess shapes.**
- [`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) — **money
is IRR rials as a string of digits on the wire**; parse with the f0 integer-safe helper, format to Toman
for display, never do float math; `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
enums are stable string codes (map to i18n labels, never hardcode a display label off the code).
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) — the
`OperationResult`/`ApiResult` envelope (`clientFetch` already unwraps it), `snake_case` URL segments,
status codes (`409` = idempotency/state-machine conflict — handle it as "already paid / in progress",
not an error toast), and the **idempotency key** requirement on the money-path POST.
- **Product truth (read before designing the breakdown/escrow copy):**
- [`../../../product/business/08-payments-and-escrow.md`](../../../product/business/08-payments-and-escrow.md) —
merchant-of-record, the three-amount split, **escrow is an internal ledger state, not held cash**,
VAT applies to **commission only**. This is *why* the C6 copy reads the way it does.
- [`../../../product/payments/index.md`](../../../product/payments/index.md) — the fintech overview;
card → PSP → Shaparak → IBAN rails, the تسهیم split, the "PSP received ≠ cash in bank" timing reality
that justifies a **pending-callback** UI state rather than assuming instant success.
- [`../../../product/wireframes/index.html`](../../../product/wireframes/index.html) — **C6** is the exact
screen you implement (the badge, the breakdown rows, the escrow notice, the "ادامه پرداخت ←" CTA);
confirm the labels and RTL layout against it before building.
- **Code to mirror:** `client/src/services/auth/*` (the `types.ts`/`keys.ts`/`apis/clientApi.ts`/
`hooks/use*.ts`/`index.ts` shape every domain copies) and the f8 `services/booking` service; the f0
**price-breakdown** and **status-chip** components; the f0 money util in `src/utils/`.
## 3. Scope — build this
Everything below lives under `client/` (the customer app shell). Build a `services/payment` domain, the
hooks, the C6 + payment-state + confirmation + invoice screens, and the small composites they need —
each visual surface produced via the **frontend-designer** skill, RTL-first, both locales.
### 3.1 `services/payment` domain (the data layer)
Mirror the `auth`/`booking` service shape exactly. Types come from
[`payments.md`](../../contracts/domains/payments.md) — do not invent fields.
- **`types.ts`** — string-literal unions + DTOs derived from the contract. Expect (confirm exact names
against the published swagger):
- `PaymentTransactionStatus` = `initiated` | `pending` | `succeeded` | `failed` | `cancelled` (the
contract's `payment_transactions.status` enum).
- `BookingStatus` reused from `services/booking` — the value that flips `pending_payment``confirmed`.
- `CheckoutSummary` — the C6 payload: `booking_id`/`booking_request_id`, `nurse` mini-info, the
**three amounts** (`gross_price_irr`, `balinyaar_commission_irr`, `nurse_payout_amount` as IRR
digit-strings), a derived/served **service cost** and **tax (VAT) line** (`vat_irr` +
`vat_rate`), `total_irr` (= `gross_price_irr`), and the escrow flag/copy keys. If the breakdown
rows aren't all served, compute display rows **only** from served amounts via the f0 integer-safe
helper — never float-derive tax client-side; if a needed line is missing, request it (§8).
- `InitiatePaymentRequest` (`booking_id` / `booking_request_id`, `gateway` selector, an
**idempotency key**) and `InitiatePaymentResult` (`transaction_id`, `redirect_url`,
`gateway_reference_code`).
- `VerifyPaymentResult` / `PaymentTransaction` (status, `gateway_reference_code`, amount, the
`booking_id` it confirmed).
- `Invoice``invoice_number`, issued-at, the line items including the **VAT-on-commission** line,
`download_url`/`pdf_url` if served (else a client-rendered receipt).
- **`keys.ts`** — a key factory: `paymentKeys.all`, `paymentKeys.summary(bookingRequestId)`,
`paymentKeys.transaction(transactionId)`, `paymentKeys.invoice(bookingId)`.
- **`apis/clientApi.ts`** — a `PaymentClientApi` namespace wrapping `clientFetch` (one call per
endpoint). Map to the b10 routes from the contract (illustrative `snake_case` slugs — use the
published ones):
- `getCheckoutSummary(bookingRequestId)``GET .../payment/get_checkout_summary`.
- `initiatePayment(req)``POST .../payment/initiate_payment` (sends the **idempotency key**).
- `verifyPayment({ gateway_reference_code, transaction_id })``POST .../payment/verify_payment`
(the **server-side re-check** on gateway return — never trust the redirect query alone).
- `getTransaction(transactionId)``GET .../payment/get_transaction` (the callback-poll target).
- `getInvoice(bookingId)``GET .../payment/get_invoice` (or the refunds-invoices route).
- **Only** add a `serverApi.ts` if an RSC needs to prefetch the summary; otherwise client-only.
- **`hooks/` — one hook per file:**
- `useCheckoutSummary(bookingRequestId)``useQuery`, `staleTime` short (prices can change before
pay), keyed `paymentKeys.summary(...)`.
- `useInitiatePayment()``useMutation`; **generates and reuses one idempotency key** for the
attempt (stable across retries — see §5); on success returns the `redirect_url`.
- `useVerifyPayment()``useMutation`; called on gateway return; on `succeeded`
**`invalidateQueries`** for the booking detail/list (so the booking flips to confirmed without a
refetch storm) and for the transaction key.
- `usePaymentTransaction(transactionId, { enabled })``useQuery` for the **pending-callback poll**;
use `refetchInterval` with **backoff** and **stop on terminal status** (`succeeded`/`failed`/
`cancelled`) — see §5. Do **not** aggressive-poll.
- `useInvoice(bookingId)``useQuery`, longer `staleTime` (an issued invoice is immutable).
- `index.ts` re-exports the **hooks only** (per `client/CLAUDE.md` — no `types`/`keys`/`apis` in the
barrel).
### 3.2 Screens & routes (under the customer shell, inside `[locale]/(private-routes)`)
Decide route segments that read cleanly under the existing role-scoped customer group (e.g. a
`checkout` segment keyed by the booking-request id). No layout above `[locale]`; respect the RSC/client
boundary.
- **C6 · خلاصه و پرداخت (Summary & pay)** — the checkout screen. Composes:
- the **"✓ پرستار تایید کرد"** acceptance badge (reuse the f0 **status-chip**, success token),
- the nurse mini-summary (name, service, schedule) pulled from the booking,
- the **price breakdown** via the f0 **price-breakdown** composite — rows: **هزینه خدمت** (service
cost, e.g. "۸ ساعت"), **کارمزد بالین‌یار** (platform commission), **مالیات (VAT)** (tax), and the
bold **مبلغ کل** (total). Every amount rendered through the f0 money util; the visible rows must
**reconcile to the total** (§5).
- the **escrow notice** — a distinct trust callout (reuse `AppAlert`/an info surface, teal/info
token, **not** an error color): the load-bearing copy
**«مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود»** (i18n key in both
locales). This copy is product-mandated trust UX — keep it verbatim in `fa`, with a faithful `en`
translation.
- the primary CTA **«ادامه پرداخت ←»** (drives `useInitiatePayment` → redirect), plus a disabled-state
**«یا پرداخت اقساطی»** seam stub that f11 wires to D1 (render it as a clearly-deferred secondary,
not a dead button — gate behind a `bnplEnabled` flag defaulting off).
- **Card payment states** — a single payment-state surface (page or modal) driving the wireframe's
documented checkout states **initiating → redirect-to-gateway → pending-callback → succeeded→confirmed
→ failed/retry**:
- **initiating** — CTA shows a spinner while `useInitiatePayment` runs.
- **redirect** — on `redirect_url`, navigate to the **mock gateway** (the b10 mock returns a redirect
URL; in dev this is a local mock-gateway page that immediately returns success — build a tiny
**mock-gateway return page** under the checkout segment so the round-trip is real without a PSP).
- **return / pending-callback** — on return, read `gateway_reference_code`/`transaction_id` from the
query, fire `useVerifyPayment`, and if status is `pending` show a **"در حال تایید پرداخت…"** state
backed by `usePaymentTransaction` polling with backoff until terminal.
- **succeeded → confirmed** — on `succeeded`, invalidate booking queries and route to the confirmation
screen.
- **failed / retry** — on `failed`/`cancelled`, show a retry affordance (re-initiate generates a
**new** idempotency key for the new attempt) and a "بازگشت به خلاصه" link.
- **Confirmation screen** — success state: "پرداخت با موفقیت انجام شد", the booking now **confirmed**,
a summary card, a **«مشاهده رزرو»** link back to the f8 booking-detail route, and a **«دانلود فاکتور»**
link to the invoice.
- **Invoice view / download** — renders `useInvoice(bookingId)`: header (`invoice_number`, issued
Shamsi date), line items, and the **VAT line on the commission** explicitly shown (per product rule:
VAT is on Balinyaar's commission, not the nurse's earnings). If the contract serves a `pdf_url`/
`download_url`, the download button hits it; otherwise render a clean printable receipt
(`window.print()` styled view) — no float math, every figure via the money util. Surface the
مودیان state read-only (e.g. «در انتظار ثبت») **only if** the contract exposes it.
### 3.3 Shared composites (build/extend at the shared level)
- **`PriceBreakdown`** — if f0 stubbed it, finish it here as the shared composite (`src/components/…`)
with a co-located `*.test.tsx`: props are typed display rows + a total; it formats nothing itself
beyond receiving already-formatted strings or raw IRR + the money util — keep money formatting in one
place. A test asserts the rows render and the total equals the sum of the served amounts.
- **`EscrowNotice`** — a small shared callout composite wrapping `AppAlert` with the mandated copy key,
so f10/f11 reuse the identical trust message. Co-located test asserts it renders the key.
- **`PaymentStatusBadge`** — map `PaymentTransactionStatus` → an i18n label + the f0 status-chip
variant. Co-located test for each status.
- Keep page-only composition (the confirmation layout, the mock-gateway return page) in the page.
### 3.4 i18n
Add all C6 / payment-state / confirmation / invoice strings to the **`payment`** namespace in **both**
`messages/en.json` and `messages/fa.json`, in sync, RTL-first. Status/enum codes map to label keys —
never hardcode a label off a code. The escrow copy and the breakdown row labels (هزینه خدمت / کارمزد
بالین‌یار / مالیات / مبلغ کل) are keys.
## 4. Mocks & seams in this phase
- **No new cross-cutting seam is owned here.** The PSP/gateway, تسهیم split, and webhook verification
are **backend** seams (`IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`) introduced
in **b10** — the frontend never talks to a PSP directly; it consumes the b10 contract. Reuse the
`services/{domain}` mock-`clientApi` seam pattern from **f0**.
- **The frontend-side mock (only if a shape is missing):** if `payments.md` (or `refunds-invoices.md`
for the **invoice**) is not yet published when you build, implement a **mock `clientApi`** behind the
`services/payment` seam — real-shaped responses (a `redirect_url` pointing at your local mock-gateway
return page, a transaction that goes `initiated → pending → succeeded`, an invoice with a VAT line) —
swap to the real `clientFetch` calls once the contract lands. **Record it** in your frontend report and
the mock registry, and **append the missing shape** to
`dev/shared-working-context/frontend/requests/for-backend.md` (operating-rules §6).
- **The dev mock-gateway return page** is a *test harness*, not a product feature: it exists so the
redirect→return round-trip is exercisable without a PSP. Keep it behind the checkout segment and note
it as test-only in the report.
## 5. Critical rules you must not get wrong
- **Money is IRR `BIGINT`, no floats.** Every amount on the wire is an **IRR digit-string**; parse it
with the f0 **integer-safe** helper and format to **Toman** for display through the f0 money util — do
**no float math** anywhere on the money path, in the DB, in the API, or in the client.
- **The breakdown must reconcile.** `gross = commission + payout`, and the **displayed rows must sum to
the displayed total** (service cost + platform commission + tax = total) using integer-safe addition —
never render a breakdown that doesn't add up, and never compute tax with a float rate client-side
(show the served `vat_irr`; if absent, request it, don't derive it loosely).
- **VAT is on the commission, not the nurse's earnings.** The invoice's VAT line is computed on
Balinyaar's commission (the taxable supply) — label and place it accordingly; never imply the nurse is
taxed.
- **The escrow copy is load-bearing trust UX.** Render the mandated notice verbatim («مبلغ به‌صورت امانی
نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود») as an info/trust callout, never an error tone,
on C6 — it is *why* the family pays on-platform.
- **Webhook/callback idempotency is the backend's guarantee — don't fight it.** Send **one stable
idempotency key** per payment attempt and **reuse it across retries of the same attempt** (a new
attempt gets a new key). A `409` on initiate/verify means "already in progress / already captured" —
treat it as a benign state convergence (re-fetch the transaction and continue to confirmation), **not**
an error toast. The server enforces one succeeded transaction per booking; the UI must never try to
double-capture.
- **Do not poll aggressively.** The pending-callback state uses `usePaymentTransaction` with
`refetchInterval` **backoff** (e.g. start ~2s, grow, cap; bounded total attempts) and **stops on any
terminal status**; never a tight loop. "PSP received ≠ cash in bank," so a pending state is normal —
reflect it calmly, don't hammer the endpoint.
- **Confirmation flips the booking, by cache invalidation.** On `succeeded`, `invalidateQueries` the
`services/booking` detail/list keys so the booking shows **confirmed** — do not refetch everything and
do not duplicate booking state in `services/payment`.
- **Caching & re-renders.** Deliberate `queryKey`/`staleTime`; the summary is short-lived, an issued
invoice is immutable (long `staleTime`). Use `select`/stable refs so the polling state doesn't re-render
the whole breakdown.
- **RTL + both locales + tokens.** `fa` default & RTL; every string in both locale files; colours from
`tokens.css` (terracotta for financial accents, info/teal for the escrow callout — never hardcoded);
MUI v9 API only; MUI primitives stay MUI, composites live shared.
- **Boundary & fetch discipline.** Fetch only via `clientFetch` through `services/payment`; no raw
`fetch()`; no toast for 401/403/5xx in hooks (only domain 4xx like a gateway-declined message).
## 6. Definition of Done
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
- [ ] **C6** renders the acceptance badge, the reconciling **service cost / commission / tax / total**
breakdown (all via the f0 money util), and the verbatim **escrow notice**; the «ادامه پرداخت ←»
CTA initiates payment.
- [ ] The **card flow** drives all five documented states (initiating → redirect → pending-callback →
succeeded→confirmed → failed/retry) end-to-end against the mock gateway; the **idempotency key** is
stable per attempt; `409` converges instead of erroring.
- [ ] On success the booking flips to **confirmed** via `invalidateQueries` (verified in React Query
Devtools — no over-fetch), and the **confirmation** screen links back to booking detail and to the
invoice.
- [ ] The **invoice** view renders the line items with the **VAT-on-commission** line and downloads
(served `pdf_url`) or prints a clean receipt; every figure via the money util, no float math.
- [ ] The pending-callback poll uses **backoff** and **stops on terminal status** — no aggressive loop.
- [ ] `PriceBreakdown`, `EscrowNotice`, `PaymentStatusBadge` are shared and each has a co-located
`*.test.tsx`; the breakdown test asserts rows sum to total.
- [ ] `payment` strings in **both** `en.json`/`fa.json`, in sync; RTL verified; colours from tokens.
- [ ] `npm run check` green; `npm run test:ci` green (shared components touched); `client/CLAUDE.md`
*Project Structure* updated for the new `services/payment` domain, checkout route segment, and new
shared components.
- [ ] Types derive from the published contract; any gap is appended to
`dev/shared-working-context/frontend/requests/for-backend.md` and mocked behind the
`services/payment` seam meanwhile (recorded in the report + mock registry).
## 7. How to test (what a human can verify after this phase)
Prereq: a booking request that the nurse has **accepted** (`accepted_awaiting_payment`) — create one via
the f7 request flow + nurse accept, or seed it. Run `npm run dev`.
1. **Open C6** from the accepted request (the C5 tracker's "پرداخت و تایید نهایی" step) → the screen
shows the **«✓ پرستار تایید کرد»** badge, the **breakdown** (هزینه خدمت / کارمزد بالین‌یار / مالیات /
مبلغ کل) where the rows **sum to the total**, and the **escrow notice** in an info tone. Switch locale
`dir` flips, all strings translate, amounts still format as Toman.
2. **Pay (mock redirect)** → tap «ادامه پرداخت ←»: CTA spins (initiating) → you are redirected to the
**mock gateway** → it returns to the checkout → **verify** runs; if pending you briefly see "در حال
تایید پرداخت…" (poll with backoff) → it resolves **succeeded** → the **confirmation** screen appears.
3. **Booking flips to confirmed** → follow «مشاهده رزرو» to the f8 booking detail: status is now
**confirmed** (no full refetch — confirm via React Query Devtools that only the booking keys were
invalidated).
4. **Download the invoice** → from confirmation tap «دانلود فاکتور»: the invoice shows the
`invoice_number`, line items, and the **VAT line on the commission**; download (or print) works; every
figure matches C6 to the rial.
5. **Idempotency / retry** → re-trigger pay on the same attempt (double-tap / refresh on return): no
double-capture — a `409`/already-succeeded converges to the confirmation, not an error toast. A
**new** attempt after a simulated failure issues a new idempotency key.
6. **Gate**`npm run check` and `npm run test:ci` pass; the `PriceBreakdown` test proves rows sum to
total; the `PaymentStatusBadge` test covers each status.
## 8. Hand off & document (close the phase)
- **Docs:** update `client/CLAUDE.md` *Project Structure* for the new `services/payment` domain, the
checkout route segment, and the new shared components (`PriceBreakdown` if promoted, `EscrowNotice`,
`PaymentStatusBadge`). Note the `payment` i18n namespace usage. Don't reintroduce removed scaffolding.
- **Contract consumed:** [`../../contracts/domains/payments.md`](../../contracts/domains/payments.md)
(b10) for checkout-summary / initiate / verify / get-transaction, and the invoice part of
`../../contracts/domains/refunds-invoices.md` (b11) **if available**. Types come from the published
swagger — don't guess. **Append any gap** (missing breakdown line, missing `vat_irr`/`vat_rate`,
missing `redirect_url`, missing invoice shape, مودیان state field) to
`dev/shared-working-context/frontend/requests/for-backend.md`.
- **Handoff & report:** append to `dev/shared-working-context/frontend/STATUS.md`; write
`dev/shared-working-context/reports/frontend-phase-9-report.md` (what was built, **what is now testable
and exactly how** — the steps in §7, what is mocked client-side (the gateway return harness / any
unmet shape) and how it swaps to real, contracts consumed, follow-ups for f10/f11). Update
`dev/shared-working-context/reports/mocks-registry.md` for the `services/payment` mock `clientApi` and
the dev mock-gateway page (seam, what's faked, config, how to make it real once the contract lands).
- **Memory:** save a `project` memory note for the checkout state machine (initiating → redirect →
pending-callback → succeeded → failed/retry), the **idempotency-key-per-attempt** rule, the
**backoff poll, no aggressive loop** decision, and the **escrow-copy-is-verbatim-trust-UX** constraint,
with a one-line `MEMORY.md` pointer.
+50
View File
@@ -0,0 +1,50 @@
# Shared working context — the parallel-agent handoff
This folder lets a **backend agent** and a **frontend agent** work **at the same time** without ever
editing the same file. It is the running, append-only record of what each side has done and what it
needs from the other. (Stable API shapes live in [`../contracts/`](../contracts/README.md); this folder
is the *running coordination* on top of them.)
## Lane ownership — the one rule that keeps it safe
> **Each lane writes only its own files. Neither lane edits the other's.**
| Lane | Writes (only) | Reads |
| --- | --- | --- |
| **Backend** | `backend/STATUS.md`, `backend/handoff/after-backend-phase-N.md` (new file per phase), `reports/backend-phase-N-report.md`, `reports/mocks-registry.md` | `frontend/requests/for-backend.md`, prior backend handoffs |
| **Frontend** | `frontend/STATUS.md`, `frontend/requests/for-backend.md` (append), `reports/frontend-phase-N-report.md` | `backend/handoff/*`, `../contracts/*`, prior frontend reports |
Because each handoff is a **new file per phase** and each STATUS/requests file is **append-only**, two
agents can run concurrently and only ever *append* — no merge conflicts, no clobbering.
## Layout
```
shared-working-context/
├── backend/
│ ├── STATUS.md # append: one block per backend phase (what shipped, gate status)
│ └── handoff/
│ └── after-backend-phase-N.md # "context for frontend after backend phase N" (one per phase)
├── frontend/
│ ├── STATUS.md # append: one block per frontend phase
│ └── requests/
│ └── for-backend.md # append: contract gaps / shape requests the backend should fulfil
└── reports/
├── README.md # the per-phase report template
├── mocks-registry.md # master list of every mock/seam + how to make it real (backend-owned)
├── backend-phase-N-report.md # what was built / testable / mocked (one per backend phase)
└── frontend-phase-N-report.md # one per frontend phase
```
## The handoff note (backend → frontend), per phase
`backend/handoff/after-backend-phase-N.md` should answer, for the frontend agent:
- Which endpoints/contracts are now **live** (link the `contracts/domains/*` doc + the swagger snapshot).
- What the frontend can now build because of this phase.
- What is **mocked** server-side (so the frontend knows responses are fake-but-shaped) and what's real.
- Any auth/enum/format detail the frontend must mirror.
- Open questions / things the frontend should *not* assume yet.
## The request note (frontend → backend)
`frontend/requests/for-backend.md` is where the frontend appends: missing endpoints, fields it needs,
shape mismatches, pagination/filter needs — anything that should land in a later backend change. The
backend agent reads this at the start of each phase. The frontend never edits backend code to "fix" it.
@@ -0,0 +1,15 @@
# Backend status log (append-only)
One block per completed backend phase. Newest at the top. Backend lane writes here; frontend reads.
<!-- TEMPLATE — copy for each phase
## backend-phase-N — <Title> — <YYYY-MM-DD>
- **Shipped:** <entities/endpoints/seams in one or two lines>
- **Contracts:** dev/contracts/domains/<domain>.md + openapi snapshot refreshed (yes/no)
- **Mocked:** <which seams> (see reports/mocks-registry.md)
- **Gate:** build clean / tests green
- **Handoff:** backend/handoff/after-backend-phase-N.md
- **Notes for frontend:** <anything load-bearing>
-->
_(no phases completed yet)_
@@ -0,0 +1,15 @@
# Frontend status log (append-only)
One block per completed frontend phase. Newest at the top. Frontend lane writes here; backend reads
for awareness.
<!-- TEMPLATE — copy for each phase
## frontend-phase-N-bM — <Title> — <YYYY-MM-DD>
- **Shipped:** <screens/flows/services/hooks/components in one or two lines>
- **Consumes:** dev/contracts/domains/<domain>.md (backend phase bM)
- **Mocked client-side:** <which services are mocked pending which backend phase>
- **Gate:** npm run check green / tests green
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
_(no phases completed yet)_
@@ -0,0 +1,15 @@
# Frontend → Backend requests (append-only)
The frontend lane appends here when it needs a contract that doesn't exist yet, finds a shape mismatch,
or needs a new field/filter/endpoint. The backend agent reads this at the start of each phase and
delivers fixes in its own change. **Frontend never edits backend code to "fix" a gap — it requests it.**
<!-- TEMPLATE — copy per request
## REQ-NNN — <short title> — filed by frontend-phase-N-bM — <YYYY-MM-DD>
- **Need:** <endpoint / field / filter / shape>
- **Why:** <which screen/flow needs it>
- **Proposed shape:** <JSON sketch, optional>
- **Status:** open | delivered in backend-phase-K
-->
_(no requests yet)_
@@ -0,0 +1,32 @@
# Phase reports — template
Every phase writes a report here when it finishes (operating-rules §7, Definition of Done). The report
is the durable answer to *"what did this phase do, what can I test now, and what's still fake?"* — saved
to a file, not left in chat.
File name: `backend-phase-N-report.md` or `frontend-phase-N-bM-report.md`.
```markdown
# <Backend|Frontend> Phase N — <Title> — Report (<YYYY-MM-DD>)
## What was built
- Bullet list of concrete deliverables (entities/migrations/endpoints, or screens/services/components).
## What is now testable (and exactly how)
- Step-by-step for a human: e.g. "Swagger → POST /api/v1/auth/otp/request with {phone} → 200; the OTP is
logged to the console; POST /api/v1/auth/otp/verify with that code → returns access+refresh tokens."
- For the frontend: which screen, which route, what to click, expected result, how mock data shows up.
## What is mocked / waiting on a real service
- Each seam touched: interface + file, what's faked, and a link to its entry in `mocks-registry.md`.
## Contracts
- Produced (backend): which `contracts/domains/*.md` + openapi snapshot.
- Consumed (frontend): which contract/version; any request filed in `frontend/requests/for-backend.md`.
## Docs updated
- Which CLAUDE.md / product doc / conventions were updated and why.
## Follow-ups for later phases
- Anything intentionally deferred, with the phase that should pick it up.
```
@@ -0,0 +1,36 @@
# Mock & integration registry
The master list of every external dependency that is **mocked behind a DI seam** in this build, and the
exact steps to make each one real. Backend lane owns this file; every phase that introduces or touches a
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
"production with real providers".
Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live.
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
| --- | --- | --- | --- | --- | --- |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — logs the code instead of sending | _tbd_ | Implement a Kavenegar/Ghasedak/SMS.ir client; keep idempotency + rate-limit | 🔴 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local/in-memory instead of object store | _tbd_ | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🔴 |
| `ICacheService` | backend-phase-0 | Caching — in-memory dictionary | _tbd_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🔴 |
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
| `IBnplProvider` | backend-phase-12 | BNPL — drives state machine, fake settle/revert | _tbd_ | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🔴 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 | _tbd_ | Config-driven per provider boundary | 🔴 |
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 |
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — seeded static table | _tbd_ | Iranian banking-holiday feed / sync job | 🔴 |
| `IShahkarVerifier` | backend-phase-6 | Phone↔national-id match — fake pass | _tbd_ | Real Shahkar/KYC vendor; persist `external_response_json` | 🔴 |
| `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 |
| `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 |
| `IBankAccountOwnershipVerifier` | backend-phase-3/6 | استعلام شبا IBAN↔national-id — fake match | _tbd_ | Real KYC vendor; store `ownership_vendor_ref` | 🔴 |
| `IGeocoder` | backend-phase-4 | Address→lat/lng — echo/static | _tbd_ | Neshan/Google geocoding | 🔴 |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 |
| `IFieldEncryptor` | backend-phase-0 | PII encryption — local symmetric key | _tbd_ | KMS / column encryption / Key Vault / HSM | 🔴 |
| `INotificationDispatcher` | backend-phase-0/15 | Notification channels — in-app write only | _tbd_ | Add SMS/push (FCM); polling → Redis pub/sub or SignalR later | 🔴 |
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.