diff --git a/CLAUDE.md b/CLAUDE.md index 62914cf..64cacff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,10 +54,18 @@ solution — each project is built, linted, and run on its own. | [`client/`](client/) | Web frontend | Next.js 16 (App Router) · React 19 · TypeScript · MUI v9 · next-intl | [client/CLAUDE.md](client/CLAUDE.md) | | [`server/`](server/) | Backend API | ASP.NET Core (.NET 10) · Clean Architecture · CQRS · EF Core | [server/CLAUDE.md](server/CLAUDE.md) | | [`product/`](product/) | Product docs | Markdown | — (see table above) | +| [`dev/`](dev/) | Build plan (not app code) | Markdown | [dev/README.md](dev/README.md) | The two communicate over **HTTP/JSON** (optionally gRPC). The client reads the API base URL from `NEXT_PUBLIC_API_URL`; the server listens on `https://localhost:5002` by default. +[`dev/`](dev/README.md) holds the **phased build plan** that takes the repo from its current baseline to +the MVP: a chain of agent-runnable prompt files split into a `backend/` and a `frontend/` track +([dev/phases/](dev/phases/README.md)), the cross-project API [`contracts/`](dev/contracts/README.md), and +a [`shared-working-context/`](dev/shared-working-context/README.md) that lets a backend agent and a +frontend agent run in parallel without touching the same files. It is planning/tooling, **not** a third +project — there is nothing to build in it. + --- ## Working agreements (apply to both projects) diff --git a/Prompt.md b/Prompt.md new file mode 100644 index 0000000..e69de29 diff --git a/dev/README.md b/dev/README.md new file mode 100644 index 0000000..489708e --- /dev/null +++ b/dev/README.md @@ -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/` (b0–b15) and `frontend/` (f0–f15), 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. diff --git a/dev/contracts/README.md b/dev/contracts/README.md new file mode 100644 index 0000000..7528c4f --- /dev/null +++ b/dev/contracts/README.md @@ -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/.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/.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/.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. diff --git a/dev/contracts/conventions/api-conventions.md b/dev/contracts/conventions/api-conventions.md new file mode 100644 index 0000000..5e23d0d --- /dev/null +++ b/dev/contracts/conventions/api-conventions.md @@ -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` 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 ` (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. diff --git a/dev/contracts/conventions/money-and-types.md b/dev/contracts/conventions/money-and-types.md new file mode 100644 index 0000000..a2fdb05 --- /dev/null +++ b/dev/contracts/conventions/money-and-types.md @@ -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. diff --git a/dev/contracts/domains/_TEMPLATE.md b/dev/contracts/domains/_TEMPLATE.md new file mode 100644 index 0000000..6464e6b --- /dev/null +++ b/dev/contracts/domains/_TEMPLATE.md @@ -0,0 +1,36 @@ +# Contract — (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 +- ``: `value_a` | `value_b` | … — meaning of each. + +## Endpoints + +### ` api/v1//` +- **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 +- ``: field-by-field (name, type, nullable, masked?, meaning). + +## Changelog +- bN — initial contract. diff --git a/dev/contracts/openapi/README.md b/dev/contracts/openapi/README.md new file mode 100644 index 0000000..75351aa --- /dev/null +++ b/dev/contracts/openapi/README.md @@ -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. diff --git a/dev/phases/README.md b/dev/phases/README.md new file mode 100644 index 0000000..46e08e7 --- /dev/null +++ b/dev/phases/README.md @@ -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 b9–b13: 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. diff --git a/dev/phases/_shared/agent-operating-rules.md b/dev/phases/_shared/agent-operating-rules.md new file mode 100644 index 0000000..657d9d2 --- /dev/null +++ b/dev/phases/_shared/agent-operating-rules.md @@ -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` 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/.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/-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. diff --git a/dev/phases/_shared/backend-conventions-checklist.md b/dev/phases/_shared/backend-conventions-checklist.md new file mode 100644 index 0000000..be69212 --- /dev/null +++ b/dev/phases/_shared/backend-conventions-checklist.md @@ -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//{Commands|Queries}//`. +- [ ] 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` per entity in `Persistence/Configuration/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. diff --git a/dev/phases/_shared/definition-of-done.md b/dev/phases/_shared/definition-of-done.md new file mode 100644 index 0000000..6f72dd5 --- /dev/null +++ b/dev/phases/_shared/definition-of-done.md @@ -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/.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//...` 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/-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. diff --git a/dev/phases/_shared/frontend-conventions-checklist.md b/dev/phases/_shared/frontend-conventions-checklist.md new file mode 100644 index 0000000..58c52a1 --- /dev/null +++ b/dev/phases/_shared/frontend-conventions-checklist.md @@ -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. diff --git a/dev/phases/_shared/phase-template.md b/dev/phases/_shared/phase-template.md new file mode 100644 index 0000000..f34b9c5 --- /dev/null +++ b/dev/phases/_shared/phase-template.md @@ -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. + +--- + +``` +# Phase N — + +> 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 2–3 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 §5–8.) +``` + +--- + +## 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. diff --git a/dev/phases/backend/backend-phase-0.md b/dev/phases/backend/backend-phase-0.md new file mode 100644 index 0000000..46cbd9f --- /dev/null +++ b/dev/phases/backend/backend-phase-0.md @@ -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. diff --git a/dev/phases/backend/backend-phase-1.md b/dev/phases/backend/backend-phase-1.md new file mode 100644 index 0000000..64ff82d --- /dev/null +++ b/dev/phases/backend/backend-phase-1.md @@ -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. diff --git a/dev/phases/backend/backend-phase-10.md b/dev/phases/backend/backend-phase-10.md new file mode 100644 index 0000000..42876a5 --- /dev/null +++ b/dev/phases/backend/backend-phase-10.md @@ -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 b11–b13, 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`. diff --git a/dev/phases/backend/backend-phase-11.md b/dev/phases/backend/backend-phase-11.md new file mode 100644 index 0000000..45d4e28 --- /dev/null +++ b/dev/phases/backend/backend-phase-11.md @@ -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 **7–10 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 ~7–10 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 7–10-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 ~7–10 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`. diff --git a/dev/phases/backend/backend-phase-12.md b/dev/phases/backend/backend-phase-12.md new file mode 100644 index 0000000..fa62179 --- /dev/null +++ b/dev/phases/backend/backend-phase-12.md @@ -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 7–15%; Torob Pay's published 6.6%; **read the actual deducted amount + from the settlement, never hardcode**), **settlement timing is NOT instant** (daily/T+1–3/weekly/15-day, + per-transaction `settled_at`), Toman↔Rial conversion at the boundary, and the async ~7–10-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+1–3/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` (~7–10 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+1–3/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 ~7–10 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` (~7–10 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 ~7–10-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`. diff --git a/dev/phases/backend/backend-phase-13.md b/dev/phases/backend/backend-phase-13.md new file mode 100644 index 0000000..e3ba4c8 --- /dev/null +++ b/dev/phases/backend/backend-phase-13.md @@ -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`. diff --git a/dev/phases/backend/backend-phase-14.md b/dev/phases/backend/backend-phase-14.md new file mode 100644 index 0000000..3d65bbd --- /dev/null +++ b/dev/phases/backend/backend-phase-14.md @@ -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 1–5 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` (1–5) + `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 1–5, 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. diff --git a/dev/phases/backend/backend-phase-15.md b/dev/phases/backend/backend-phase-15.md new file mode 100644 index 0000000..304a655 --- /dev/null +++ b/dev/phases/backend/backend-phase-15.md @@ -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. diff --git a/dev/phases/backend/backend-phase-2.md b/dev/phases/backend/backend-phase-2.md new file mode 100644 index 0000000..5d345a7 --- /dev/null +++ b/dev/phases/backend/backend-phase-2.md @@ -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. diff --git a/dev/phases/backend/backend-phase-3.md b/dev/phases/backend/backend-phase-3.md new file mode 100644 index 0000000..10d466c --- /dev/null +++ b/dev/phases/backend/backend-phase-3.md @@ -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`. diff --git a/dev/phases/backend/backend-phase-4.md b/dev/phases/backend/backend-phase-4.md new file mode 100644 index 0000000..14444f2 --- /dev/null +++ b/dev/phases/backend/backend-phase-4.md @@ -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`. diff --git a/dev/phases/backend/backend-phase-5.md b/dev/phases/backend/backend-phase-5.md new file mode 100644 index 0000000..6868349 --- /dev/null +++ b/dev/phases/backend/backend-phase-5.md @@ -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. diff --git a/dev/phases/backend/backend-phase-6.md b/dev/phases/backend/backend-phase-6.md new file mode 100644 index 0000000..cbbe219 --- /dev/null +++ b/dev/phases/backend/backend-phase-6.md @@ -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`. diff --git a/dev/phases/backend/backend-phase-7.md b/dev/phases/backend/backend-phase-7.md new file mode 100644 index 0000000..7b5e359 --- /dev/null +++ b/dev/phases/backend/backend-phase-7.md @@ -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`. diff --git a/dev/phases/backend/backend-phase-8.md b/dev/phases/backend/backend-phase-8.md new file mode 100644 index 0000000..0f95e7c --- /dev/null +++ b/dev/phases/backend/backend-phase-8.md @@ -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. diff --git a/dev/phases/backend/backend-phase-9.md b/dev/phases/backend/backend-phase-9.md new file mode 100644 index 0000000..cf0b651 --- /dev/null +++ b/dev/phases/backend/backend-phase-9.md @@ -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 (b10–b13). +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) — 0–100), `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 + 0–100, 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`. diff --git a/dev/phases/frontend/frontend-phase-0.md b/dev/phases/frontend/frontend-phase-0.md new file mode 100644 index 0000000..914c403 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-0.md @@ -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 f1–f15 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. diff --git a/dev/phases/frontend/frontend-phase-1-b2.md b/dev/phases/frontend/frontend-phase-1-b2.md new file mode 100644 index 0000000..1f9f4c6 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-1-b2.md @@ -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. diff --git a/dev/phases/frontend/frontend-phase-10-b11.md b/dev/phases/frontend/frontend-phase-10-b11.md new file mode 100644 index 0000000..95db629 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-10-b11.md @@ -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 (~7–10 +> 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 ~7–10 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 **~7–10 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` 7–10-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 7–10-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 **~7–10 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 ~7–10-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 **~7–10 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. diff --git a/dev/phases/frontend/frontend-phase-11-b12.md b/dev/phases/frontend/frontend-phase-11-b12.md new file mode 100644 index 0000000..a6994a3 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-11-b12.md @@ -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 ("~7–10 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 (D1–D4) 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) — D1–D5 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 + (D1–D5)** — the exact screens, RTL Persian, terracotta financial accent. D1 روش پرداخت, D2 انتخاب طرح + اقساط, D3 اعتبارسنجی, D4 تایید طرح و قرارداد, D5 پیگیری اقساط (in Wallet). D5 carries the bottom tab nav + (Wallet active); D1–D4 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 (D1–D4) 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): **دیجی‌پی** (3–12 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 "~7–10 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 D1–D5 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 D1–D5 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). +- [ ] **D1–D5** 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 D1–D4 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 D1–D5 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. diff --git a/dev/phases/frontend/frontend-phase-12-b13.md b/dev/phases/frontend/frontend-phase-12-b13.md new file mode 100644 index 0000000..1a5bf14 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-12-b13.md @@ -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 §6–7). 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 b9–b13):** 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. diff --git a/dev/phases/frontend/frontend-phase-13-b14.md b/dev/phases/frontend/frontend-phase-13-b14.md new file mode 100644 index 0000000..8665535 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-13-b14.md @@ -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 1–5 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` 1–5, + `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 **1–5 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`. diff --git a/dev/phases/frontend/frontend-phase-14-b15.md b/dev/phases/frontend/frontend-phase-14-b15.md new file mode 100644 index 0000000..4436c7d --- /dev/null +++ b/dev/phases/frontend/frontend-phase-14-b15.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). diff --git a/dev/phases/frontend/frontend-phase-15-b15.md b/dev/phases/frontend/frontend-phase-15-b15.md new file mode 100644 index 0000000..93f15a2 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-15-b15.md @@ -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 A1–E3 — 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 **0–1**), `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 0–1 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 b9–b13):** 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 **0–1**, 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 0–1), 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 **0–1** (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. diff --git a/dev/phases/frontend/frontend-phase-2-b3.md b/dev/phases/frontend/frontend-phase-2-b3.md new file mode 100644 index 0000000..a4a62ca --- /dev/null +++ b/dev/phases/frontend/frontend-phase-2-b3.md @@ -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** (B3–B6, 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. diff --git a/dev/phases/frontend/frontend-phase-3-b4.md b/dev/phases/frontend/frontend-phase-3-b4.md new file mode 100644 index 0000000..6300dd5 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-3-b4.md @@ -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`. diff --git a/dev/phases/frontend/frontend-phase-4-b5.md b/dev/phases/frontend/frontend-phase-4-b5.md new file mode 100644 index 0000000..b97a127 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-4-b5.md @@ -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* (f1–f3, 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. diff --git a/dev/phases/frontend/frontend-phase-5-b6.md b/dev/phases/frontend/frontend-phase-5-b6.md new file mode 100644 index 0000000..75472c0 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-5-b6.md @@ -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 B3–B6** 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 "در حال بررسی" (24–48h, 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 **24–48h** 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 24–48h 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`. diff --git a/dev/phases/frontend/frontend-phase-6-b7.md b/dev/phases/frontend/frontend-phase-6-b7.md new file mode 100644 index 0000000..8777f69 --- /dev/null +++ b/dev/phases/frontend/frontend-phase-6-b7.md @@ -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`. diff --git a/dev/phases/frontend/frontend-phase-7-b8.md b/dev/phases/frontend/frontend-phase-7-b8.md new file mode 100644 index 0000000..809b7bd --- /dev/null +++ b/dev/phases/frontend/frontend-phase-7-b8.md @@ -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 15–30s 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`. diff --git a/dev/phases/frontend/frontend-phase-8-b9.md b/dev/phases/frontend/frontend-phase-8-b9.md new file mode 100644 index 0000000..0ae5a6f --- /dev/null +++ b/dev/phases/frontend/frontend-phase-8-b9.md @@ -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`. diff --git a/dev/phases/frontend/frontend-phase-9-b10.md b/dev/phases/frontend/frontend-phase-9-b10.md new file mode 100644 index 0000000..da51bed --- /dev/null +++ b/dev/phases/frontend/frontend-phase-9-b10.md @@ -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 (D1–D5) — 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. diff --git a/dev/shared-working-context/README.md b/dev/shared-working-context/README.md new file mode 100644 index 0000000..c6d47c4 --- /dev/null +++ b/dev/shared-working-context/README.md @@ -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. diff --git a/dev/shared-working-context/backend/STATUS.md b/dev/shared-working-context/backend/STATUS.md new file mode 100644 index 0000000..04210a6 --- /dev/null +++ b/dev/shared-working-context/backend/STATUS.md @@ -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)_ diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md new file mode 100644 index 0000000..3cd9046 --- /dev/null +++ b/dev/shared-working-context/frontend/STATUS.md @@ -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)_ diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md new file mode 100644 index 0000000..5338017 --- /dev/null +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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)_ diff --git a/dev/shared-working-context/reports/README.md b/dev/shared-working-context/reports/README.md new file mode 100644 index 0000000..992f94a --- /dev/null +++ b/dev/shared-working-context/reports/README.md @@ -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. +``` diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md new file mode 100644 index 0000000..c9d49cf --- /dev/null +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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.