add build development phases
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Contracts — the shared interface between `client/` and `server/`
|
||||
|
||||
The two projects are independent (no shared build). This folder is their **single shared source of
|
||||
truth** for everything that crosses the wire: API routes, request/response shapes, status codes, enums,
|
||||
shared flows, and money/format conventions. It lets a frontend agent build against a stable contract
|
||||
**before, during, and after** the matching backend phase, and it lets the two run in parallel.
|
||||
|
||||
## Ownership (this is what makes parallel work safe)
|
||||
|
||||
- **Backend owns and writes** `contracts/domains/*` and `contracts/openapi/*`. A backend phase that
|
||||
ships an API writes/updates the contract in the **same** change.
|
||||
- **Frontend reads** contracts and derives its TypeScript types from them. The frontend never edits
|
||||
files here. If a contract is missing or wrong, the frontend appends a request to
|
||||
[`../shared-working-context/frontend/requests/for-backend.md`](../shared-working-context/frontend/requests/for-backend.md);
|
||||
the backend delivers the fix in a later change.
|
||||
|
||||
## What's here
|
||||
|
||||
| Path | What it is |
|
||||
| --- | --- |
|
||||
| `conventions/api-conventions.md` | The envelope, routing, pagination, errors, auth, locale — read first. |
|
||||
| `conventions/money-and-types.md` | How money, dates, enums, gender, IDs are represented on the wire. |
|
||||
| `domains/_TEMPLATE.md` | The shape every per-domain contract doc follows. |
|
||||
| `domains/<domain>.md` | One file per domain (identity, catalog, booking, payments, …), added by the backend phase that ships it. |
|
||||
| `openapi/` | The published `swagger.json` snapshot(s) — the machine-readable contract for type generation. |
|
||||
|
||||
## How a contract is produced (backend)
|
||||
|
||||
1. Build the endpoints following `server/CONVENTIONS.md`.
|
||||
2. Write/extend `domains/<domain>.md` from `domains/_TEMPLATE.md`: every route, its method + snake_case
|
||||
path, auth/policy, request and response JSON (with a real example), the enums it uses, and the error
|
||||
cases. Reference, don't restate, `conventions/*`.
|
||||
3. Publish the OpenAPI snapshot per `openapi/README.md`.
|
||||
4. Note in your handoff (`shared-working-context/backend/handoff/after-backend-phase-N.md`) that the
|
||||
contract is live.
|
||||
|
||||
## How a contract is consumed (frontend)
|
||||
|
||||
1. Read `domains/<domain>.md` + `conventions/*`. Derive types in `src/services/{domain}/types.ts`
|
||||
(or generate from `openapi/swagger.json`) — keep names aligned with the contract.
|
||||
2. If something is missing/ambiguous, request it (don't guess) and mock behind the `services/{domain}`
|
||||
seam meanwhile.
|
||||
|
||||
> Keep contracts **versioned by being honest**: when a shipped shape changes, update its `domains/*` doc
|
||||
> and the OpenAPI snapshot in the same change, and call it out in the handoff so the frontend re-syncs.
|
||||
@@ -0,0 +1,50 @@
|
||||
# API conventions (read before writing or consuming any contract)
|
||||
|
||||
These hold for **every** Balinyaar endpoint. Per-domain contract docs assume them and don't restate them.
|
||||
|
||||
## Base & versioning
|
||||
- Base URL from `NEXT_PUBLIC_API_URL` (client) / `https://localhost:5002` (server default).
|
||||
- Versioned routes: `api/v{version}/...` (Asp.Versioning). Default `v1`.
|
||||
- **All URL segments are `snake_case`** (server `SnakeCaseParameterTransformer`). Controllers use
|
||||
`[controller]`/`[action]` tokens, so `GetNurseProfile` → `.../get_nurse_profile`.
|
||||
|
||||
## Response envelope (`OperationResult` → `ApiResult`)
|
||||
Every response is the server's standard envelope, not a bare body. Success and failure share the shape;
|
||||
the frontend's `clientFetch`/`serverFetch` already unwrap it and throw `ApiError` on failure. Document
|
||||
each endpoint's **payload** (the `data`/result) and its failure cases. The envelope carries at least:
|
||||
a success flag, an HTTP-aligned status, a user-safe message, and the typed `data` (or validation errors).
|
||||
The canonical shape is defined by `Baya.Application/Models/ApiResult` + `OperationResult<T>` on the
|
||||
server — mirror that, don't invent a new envelope.
|
||||
|
||||
## Status codes
|
||||
- `200` success (payload in `data`).
|
||||
- `400` validation/business-rule failure (field-level errors included).
|
||||
- `401` unauthenticated (missing/expired token) · `403` unauthorized (lacks permission).
|
||||
- `404` not found.
|
||||
- `409` conflict (idempotency / duplicate / state-machine violation) where applicable.
|
||||
- `5xx` unexpected (generic safe message; details only in server logs).
|
||||
|
||||
## Auth
|
||||
- Bearer JWE in `Authorization: Bearer <token>` (access token, ~15 min). Refresh via the refresh
|
||||
endpoint; rotation + reuse-detection apply. The client attaches the header automatically in
|
||||
`clientFetch`. State which policy/role each endpoint needs.
|
||||
|
||||
## Localisation
|
||||
- The client sends the active locale (`Accept-Language` / `x-app-locale`, `fa` default). Server-produced
|
||||
user-facing messages should honour it. Reference data that has `name_fa`/`name_en` returns both;
|
||||
the client picks by locale.
|
||||
|
||||
## Pagination (mandatory on lists)
|
||||
- Query params: `page` (1-based) + `page_size` (cap it server-side, e.g. ≤100). Response payload carries
|
||||
`items` + `total` (+ `page`/`page_size`). Document the default and max `page_size` per endpoint.
|
||||
|
||||
## Idempotency (money & side-effecting POSTs)
|
||||
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
|
||||
endpoints dedup on the provider's `external_event_id`. Document which endpoints require a key.
|
||||
|
||||
## Naming
|
||||
- JSON properties are the server's serialized casing (follow what NSwag/Swagger emits — typically
|
||||
`snake_case` to match routing, or the project's configured policy; **derive the exact casing from the
|
||||
published `swagger.json`, don't assume**). The frontend types match the wire exactly.
|
||||
|
||||
> When in doubt about an envelope/casing detail, the published `openapi/swagger.json` is authoritative.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Money & shared types on the wire
|
||||
|
||||
## Money — IRR Rials, integer, no floats
|
||||
- All monetary values are **IRR Rials as integers** (`BIGINT` server-side). There are **no floats** on
|
||||
the money path anywhere — not in the DB, not in the API, not in the client.
|
||||
- Because IRR amounts exceed JS's safe integer range in some aggregates and to avoid float coercion,
|
||||
represent money on the wire as a **string of digits** (e.g. `"23300000"`) unless the published
|
||||
`swagger.json` shows otherwise; the client parses with `BigInt`/integer-safe helpers and formats for
|
||||
display. **Toman is display-only** and is converted to/from Rials **only** inside a provider adapter
|
||||
at its boundary — never in shared contracts or the client's own math.
|
||||
- The three booking amounts always satisfy `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`.
|
||||
|
||||
## Dates & times
|
||||
- Timestamps are **UTC ISO-8601** (`DATETIME2(7)` server-side). Persian-calendar (Shamsi) display is a
|
||||
**client** concern. The exception is bank-closure scheduling, which the server resolves via the
|
||||
holiday calendar — the client never computes payout dates.
|
||||
- `day_of_week` for availability uses the **Shamsi week (0 = Saturday … 6 = Friday)**, not ISO Monday-start.
|
||||
|
||||
## Enums (string-valued, stable codes)
|
||||
Enums cross the wire as their stable string code (e.g. `male`/`female`/`any`, `per_hour`/`per_session`/
|
||||
`per_half_day`/`per_day`/`per_24h`, booking/verification/payment statuses, `refund_channel` =
|
||||
`psp_card`/`bnpl_revert`/`manual`). Each domain contract doc lists the exact set it uses. The frontend
|
||||
mirrors them as string-literal union types and **never** hardcodes a display label off the code — labels
|
||||
are i18n keys.
|
||||
|
||||
## Identifiers
|
||||
- Entity IDs are integers/`BIGINT` (serialized per the published schema). Human-facing references
|
||||
(`reference_code` on tickets, `invoice_number`) are strings shown to users — treat as opaque.
|
||||
|
||||
## PII & sensitive fields
|
||||
- Encrypted-at-rest fields (phone, national_id, IBAN, addresses, clinical notes) are returned **only**
|
||||
to authorized callers and often **masked** (e.g. last 4 of an IBAN). Contracts must state when a field
|
||||
is masked vs. full, and the two-stage clinical-disclosure rule (full care instructions only after a
|
||||
booking is confirmed, to the assigned nurse + admin) applies to the relevant payloads.
|
||||
|
||||
## Gender (load-bearing)
|
||||
- `gender` (`male`/`female`) drives **same-gender caregiver matching** — a near-hard requirement. It is
|
||||
present on users/patients and on the booking request as `required_caregiver_gender`
|
||||
(`male`/`female`/`any`). Never default or drop it silently.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Contract — <Domain> (backend phase bN)
|
||||
|
||||
> One-line: what this domain's API covers. Assumes
|
||||
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
|
||||
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
|
||||
> machine schema: [`../openapi/`](../openapi/README.md).
|
||||
|
||||
**Status:** live as of backend-phase-bN · **Frontend consumer:** frontend-phase-fM
|
||||
|
||||
## Enums used
|
||||
- `<enum_name>`: `value_a` | `value_b` | … — meaning of each.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `<HTTP> api/v1/<controller>/<action>`
|
||||
- **Purpose:** …
|
||||
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
|
||||
- **Path/query params:** `name` (type) — meaning; pagination `page`/`page_size` (default/max) for lists.
|
||||
- **Request body:**
|
||||
```json
|
||||
{ "field": "example" }
|
||||
```
|
||||
- **Success `200` payload (`data`):**
|
||||
```json
|
||||
{ "field": "example" }
|
||||
```
|
||||
- **Failure cases:** `400` …, `401` …, `403` …, `404` …, `409` … (when/why each).
|
||||
- **Notes:** masking, two-stage disclosure, tenancy, side effects (notifications/ledger/audit), etc.
|
||||
|
||||
_(repeat per endpoint)_
|
||||
|
||||
## Shared shapes
|
||||
- `<DtoName>`: field-by-field (name, type, nullable, masked?, meaning).
|
||||
|
||||
## Changelog
|
||||
- bN — initial contract.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,204 @@
|
||||
# Agent Operating Rules — read this before every phase
|
||||
|
||||
> **You are an autonomous engineer working one phase of the Balinyaar build.** This file is the
|
||||
> contract for *how* you work. Every `backend-phase-N.md` / `frontend-phase-N-bM.md` links here and
|
||||
> assumes you have read it. Do not skip it. Do not be lazy. Build production-quality, scalable,
|
||||
> maintainable code — not a demo.
|
||||
|
||||
---
|
||||
|
||||
## 0. The golden behaviours
|
||||
|
||||
1. **No laziness, ever.** Implement the whole phase scope. No `// TODO: implement later`, no stubbed
|
||||
handlers that return fake data *unless the phase explicitly says "mock this behind a seam"*, no
|
||||
"left as an exercise". If you mock something, you mock it **behind a dependency-injected interface**
|
||||
and you record it in the mock registry (§7) — that is the *only* sanctioned form of "not real yet".
|
||||
2. **Read before you write.** Complete the **Required reading** list in your phase file *first*. The
|
||||
business rules are decisions, not guesses — the product docs are the source of truth. Inferring a
|
||||
rule from code instead of reading the doc is a defect.
|
||||
3. **Don't duplicate prior work.** Each phase lists **"What already exists"** with links to the prior
|
||||
phases that built it. Extend it; never re-create it. If you need to change something a prior phase
|
||||
built, change it in place and note it in your report.
|
||||
4. **Stay in your project.** Backend phases touch `server/` (+ `dev/contracts`, `dev/shared-working-context/backend`). Frontend phases touch `client/` (+ `dev/shared-working-context/frontend`). Do **not** edit the other side's app code — coordinate through contracts and the handoff (§6).
|
||||
5. **Match the surrounding style.** Mirror existing patterns; introduce no new ones without reason.
|
||||
The per-project `CLAUDE.md` (and server `CONVENTIONS.md`) are non-negotiable — follow them exactly.
|
||||
6. **Leave the tree green.** Your phase is not done until the project's own quality gate passes
|
||||
(§4) and the Definition of Done ([definition-of-done.md](definition-of-done.md)) is fully met.
|
||||
7. **Think about the future.** Every decision should consider scalability and maintenance: indexing,
|
||||
pagination, caching, idempotency, re-render cost, bundle size, the seam that lets a mock become real.
|
||||
See the conventions checklists ([backend](backend-conventions-checklist.md) ·
|
||||
[frontend](frontend-conventions-checklist.md)).
|
||||
|
||||
---
|
||||
|
||||
## 1. The canonical sources of truth (know where each rule lives)
|
||||
|
||||
| You need… | Read |
|
||||
| --- | --- |
|
||||
| Repo-wide rules, two-project layout | root [`CLAUDE.md`](../../../CLAUDE.md) |
|
||||
| Backend engineering rules | [`server/CLAUDE.md`](../../../server/CLAUDE.md) + [`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md) |
|
||||
| Frontend engineering rules | [`client/CLAUDE.md`](../../../client/CLAUDE.md) |
|
||||
| Frontend design/brand rules | the **frontend-designer** skill (invoke it for any UI work) |
|
||||
| Product / business / data-model / payments truth | [`product/`](../../../product/) (start at `product/index.md`) |
|
||||
| The whole build plan + order | [`dev/phases/README.md`](../README.md) |
|
||||
| Cross-project API/flow contracts | [`dev/contracts/`](../../contracts/README.md) |
|
||||
| What the other agent has handed you | [`dev/shared-working-context/`](../../shared-working-context/README.md) |
|
||||
|
||||
**Precedence when two sources seem to conflict:** product docs (business truth) → the relevant
|
||||
`CLAUDE.md`/`CONVENTIONS.md` (engineering truth) → this file → the phase file's specifics. If a real
|
||||
conflict remains, do the safe thing, implement it, and flag it in your report — never silently guess
|
||||
on money, auth, tenancy, or clinical-data rules.
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase lifecycle (do these in order)
|
||||
|
||||
1. **Orient.** Read this file, your phase file end-to-end, and the **Required reading** it lists.
|
||||
Skim the prior-phase reports in `dev/shared-working-context/reports/` for what changed recently.
|
||||
2. **Plan.** Restate the scope to yourself; list the entities/endpoints/screens you will build and the
|
||||
order. Identify every external dependency you must mock and the seam it sits behind.
|
||||
3. **Build.** Implement the full scope following the project conventions and the checklists. Wire mocks
|
||||
behind DI. Keep commits/changes coherent.
|
||||
4. **Self-verify.** Run the project's quality gate (§4) and the phase's **"How to test"** steps. Fix
|
||||
everything. Re-read your own diff as a reviewer would.
|
||||
5. **Document & hand off.** Update the project docs (§5), write the contract(s) (§6, backend), write
|
||||
the phase report and update the mock registry (§7), write the handoff note (§6), and save memory (§8).
|
||||
6. **Declare done** only when the Definition of Done is fully satisfied.
|
||||
|
||||
---
|
||||
|
||||
## 3. Best-practice mandate (this is graded, not optional)
|
||||
|
||||
**Backend** (full list in [backend-conventions-checklist.md](backend-conventions-checklist.md)):
|
||||
Clean-Architecture boundaries (Domain → Application → Infrastructure → API; dependencies point inward);
|
||||
CQRS via `martinothamar/Mediator` (`ISender`/`ICommand`/`IQuery`, `internal sealed` handlers,
|
||||
`OperationResult` for expected failures — never throw); `AsNoTracking()` + `Select()` projection on
|
||||
every read; pagination on every list; one `IEntityTypeConfiguration<T>` per entity; soft-delete query
|
||||
filters; audit fields via the SaveChanges interceptor; **caching** read-heavy/config data behind the
|
||||
cache seam; **idempotency + (where stated) Redis distributed locks** on the money path; **money is IRR
|
||||
`BIGINT`, no floats, ever**; validate at the boundary with FluentValidation; `CancellationToken`
|
||||
through every async call; zero new build warnings; no dead code; comment the *why*.
|
||||
|
||||
**Frontend** (full list in [frontend-conventions-checklist.md](frontend-conventions-checklist.md)):
|
||||
Respect the RSC/client boundary; **fetch only through `clientFetch`/`serverFetch`** in
|
||||
`services/{domain}`; use **TanStack Query with sensible `queryKey`s, `staleTime`, and cache
|
||||
invalidation** so you never re-fetch data you already have; prevent needless re-renders (stable
|
||||
references, `select`, colocated state, memo only where it pays); **MUI primitives stay MUI** (Button,
|
||||
Avatar, Paper… — never re-implement a root component), but **compose shareable mid-level components at
|
||||
the right shared level** (not buried in a page) when they'll be reused; every user-visible string is an
|
||||
i18n key in **both** `en.json` and `fa.json`; colours from `tokens.css`; RTL-correct; `npm run check`
|
||||
green; no dead code.
|
||||
|
||||
If a phase's work could be done quickly-but-wrong or properly-but-slower, **do it properly.**
|
||||
|
||||
---
|
||||
|
||||
## 4. The quality gate (run before declaring done)
|
||||
|
||||
- **Backend:** `dotnet build Baya.sln` (zero new warnings) **and** `dotnet test Baya.sln` (all pass).
|
||||
Add the handler/integration tests your phase introduces. A reachable SQL Server is required to run.
|
||||
- **Frontend:** `npm run check` (type + lint) **and** `npm run test:ci` if you touched/added a shared
|
||||
component. Keep `en.json`/`fa.json` in sync.
|
||||
|
||||
A phase that doesn't pass its own gate is **not done**, regardless of how complete the code looks.
|
||||
|
||||
---
|
||||
|
||||
## 5. Update the project documents (in the same change)
|
||||
|
||||
Keep the docs honest — stale instructions are worse than none. When your phase changes how something
|
||||
works or what the architecture is:
|
||||
|
||||
- **Architecture map:** if you add/rename/remove a project, layer, route group, provider, domain folder,
|
||||
or a cross-boundary seam, update the canonical map in the **same** change — server: the *Project map*
|
||||
in `server/CLAUDE.md`; client: the *Project Structure* tree in `client/CLAUDE.md`; repo-level: the
|
||||
*Repository layout* in root `CLAUDE.md`.
|
||||
- **Product docs:** if you discover or decide a business rule that the `product/` docs don't yet
|
||||
capture (or that drifts from them), update the relevant `product/**.md` (and regenerate the HTML view
|
||||
per `product/CLAUDE.md` guidance if you changed Markdown). Do not invent rules — record decisions.
|
||||
- **Conventions:** if you establish a new reusable pattern (a seam, a base class, a hook family), add a
|
||||
short note to the relevant `CLAUDE.md`/`CONVENTIONS.md` so the next phase reuses it.
|
||||
|
||||
Do **not** re-introduce removed starter/template scaffolding while editing docs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Contracts & the parallel-agent handoff (how the two sides stay in sync)
|
||||
|
||||
Backend and frontend phases are designed to run **as two independent agents in parallel**. They never
|
||||
edit the same files. They coordinate through two folders:
|
||||
|
||||
- **`dev/contracts/`** — *backend-owned, frontend-read.* When a backend phase ships an API, it writes
|
||||
the contract: a per-domain doc under `dev/contracts/domains/<domain>.md` (request/response shapes,
|
||||
routes, status codes, enums, examples) **and** ensures the server's `swagger.json` snapshot is
|
||||
published per `dev/contracts/openapi/README.md`. Follow the envelope/format rules in
|
||||
`dev/contracts/conventions/`. The frontend treats these as the source of truth for types — it does
|
||||
**not** guess shapes.
|
||||
- **`dev/shared-working-context/`** — *the running handoff.* Strict lane ownership so parallel agents
|
||||
never collide:
|
||||
- **Backend writes only:** `shared-working-context/backend/STATUS.md` (append your phase summary),
|
||||
`shared-working-context/backend/handoff/after-backend-phase-N.md` (one new file per phase — what
|
||||
the frontend can now build, which endpoints/contracts are live, what's mocked), and your report +
|
||||
the mock registry in `shared-working-context/reports/`.
|
||||
- **Frontend writes only:** `shared-working-context/frontend/STATUS.md`, and
|
||||
`shared-working-context/frontend/requests/for-backend.md` (append contract gaps / shape requests —
|
||||
the backend agent reads this, you never edit backend files), and your frontend report in
|
||||
`shared-working-context/reports/`.
|
||||
- **Never edit a file the other lane owns.** If the frontend needs a contract change, it *requests*
|
||||
it; the backend *delivers* it in a subsequent change. This is what makes parallel runs safe.
|
||||
|
||||
When mock data is needed on the frontend before its backend phase is merged, build it behind the same
|
||||
`services/{domain}` seam (a mock `clientApi`) and record it in your frontend report so it's swapped out
|
||||
cleanly once the real endpoint lands.
|
||||
|
||||
---
|
||||
|
||||
## 7. The mock / integration report (mandatory, saved to a file — not just chat)
|
||||
|
||||
Every phase that mocks or defers an external service **must**:
|
||||
|
||||
1. Put the mock behind a **DI-registered interface** (a "seam") with a real-shaped contract, so the real
|
||||
implementation is a drop-in later. Mock and real both implement the same interface; selection is by
|
||||
configuration/registration, never by `if (mock)` scattered in handlers.
|
||||
2. Append an entry to **`dev/shared-working-context/reports/mocks-registry.md`** with: the seam
|
||||
(interface name + file), what is faked, why, the config keys it reads, and **step-by-step how to make
|
||||
it real** (which provider, which package, which settings, which methods to implement, what to test).
|
||||
3. Write a **per-phase report** at `dev/shared-working-context/reports/<backend|frontend>-phase-N-report.md`
|
||||
covering: *what was built*, *what is now testable and exactly how*, *what is mocked / waiting on a real
|
||||
service*, *contracts produced/consumed*, *follow-ups for later phases*. (See
|
||||
[reports/README.md](../../shared-working-context/reports/README.md) for the template.)
|
||||
|
||||
Services that will be mocked across the chain and the seam each lives behind (define once, reuse):
|
||||
SMS/OTP delivery (`ISmsSender`), object storage / MinIO/S3 (`IObjectStorage`), cache/Redis
|
||||
(`ICacheService`), distributed locks/Redis (`IDistributedLock`), search/Elasticsearch (`INurseSearch`),
|
||||
PSP card gateway (`IPaymentProvider`), settlement-sharing/تسهیم (`ISettlementSplitProvider`), BNPL
|
||||
(`IBnplProvider`), bank transfer PAYA/SATNA (`IBankTransferProvider`), webhook verification
|
||||
(`IWebhookVerifier`), Shahkar (`IShahkarVerifier`), identity KYC/liveness (`IIdentityKycProvider`),
|
||||
credential/license check (`ICredentialVerifier`), IBAN ownership (`IBankAccountOwnershipVerifier`),
|
||||
geocoding/maps (`IGeocoder`), مودیان e-invoicing (`IMoadianClient`), AI review moderation
|
||||
(`IReviewModerationService`), field encryption/KMS (`IFieldEncryptor`), notification dispatch
|
||||
(`INotificationDispatcher`). The exact phase that *introduces* each seam is in the roadmap; later phases
|
||||
*reuse* the seam, they do not redefine it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Write memory & context when done
|
||||
|
||||
After the work passes its gate, record what a future agent would need and could not cheaply re-derive:
|
||||
|
||||
- **Persistent memory** (the Claude memory dir, per the repo's memory instructions): save/update a
|
||||
`project`-type memory note for any non-obvious decision, new seam, or gotcha this phase introduced,
|
||||
and add a one-line pointer to `MEMORY.md`. Don't record what the code/docs already make obvious.
|
||||
- **Working context:** the handoff note (§6) and the phase report (§7) — these are the durable record
|
||||
the *other* agent and the *next* phase rely on.
|
||||
|
||||
---
|
||||
|
||||
## 9. When you're genuinely blocked
|
||||
|
||||
Some things are intentionally out of scope and must be **mocked, not invented**: real PSP/BNPL
|
||||
connections, the Shahkar/MoH/INO/criminal-record vendors, MinIO/S3 credentials, the مودیان enrollment.
|
||||
If you reach one, implement the seam + a faithful mock + the registry entry, and move on — do not stall
|
||||
and do not fabricate a real integration. If a *business* rule is truly undecided (the product docs say
|
||||
"open question"), pick the safe default, implement it config-drivenly where possible, and flag it in
|
||||
your report for the human to confirm. Never block the chain on an external unknown.
|
||||
@@ -0,0 +1,59 @@
|
||||
# Backend conventions checklist (quick reference)
|
||||
|
||||
The authoritative rules are [`server/CLAUDE.md`](../../../server/CLAUDE.md) and
|
||||
[`server/CONVENTIONS.md`](../../../server/CONVENTIONS.md). This is a tick-list to keep handy while you
|
||||
build. If anything here seems to conflict with those files, **those files win**.
|
||||
|
||||
## Architecture
|
||||
- [ ] Clean Architecture respected: Domain → Application → Infrastructure → API; **dependencies point
|
||||
inward**. Domain references nothing; Application references only Domain; Infrastructure/API
|
||||
implement Application contracts. Never reference Infrastructure/API from Domain/Application.
|
||||
- [ ] New cross-layer dependency or project/folder ⇒ update the **Project map** in `server/CLAUDE.md`.
|
||||
- [ ] New infrastructure is registered via a `ServiceConfiguration/` extension method called from
|
||||
`Program.cs` — never inline in `Program.cs`.
|
||||
|
||||
## CQRS / features
|
||||
- [ ] Feature lives in `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`.
|
||||
- [ ] Requests are `record`; handlers are `internal sealed`; one handler per request.
|
||||
- [ ] **Never throw for expected failures** — return `OperationResult.SuccessResult/FailureResult/NotFoundResult`.
|
||||
- [ ] Contracts the handler needs are interfaces in `Application/Contracts/`, implemented in Infrastructure.
|
||||
- [ ] Input-bearing commands have a FluentValidation validator (picked up by `ValidateCommandBehavior`).
|
||||
|
||||
## Persistence (EF Core)
|
||||
- [ ] Read queries use `AsNoTracking()` **and** project with `.Select(...)` to a DTO — never hydrate
|
||||
entities to map them. Mapping (Mapster) happens in the handler after the query.
|
||||
- [ ] Every unbounded list is **paginated** (`Skip`/`Take`); no unbounded `ToListAsync()`.
|
||||
- [ ] Access the DB via `IUnitOfWork` in handlers; commit once per command (`CommitAsync`).
|
||||
- [ ] One `IEntityTypeConfiguration<T>` per entity in `Persistence/Configuration/<Area>Config/`.
|
||||
- [ ] Soft-deletable entities declare a global query filter (`!IsDeleted` / `deleted_at IS NULL`).
|
||||
- [ ] Audit fields (`CreatedAt/ModifiedAt/CreatedById/ModifiedById`) are set by the SaveChanges
|
||||
interceptor via `ICurrentUser` — handlers don't pass them.
|
||||
- [ ] Encrypted PII columns (phone, national_id, IBAN, addresses, clinical notes) go through the field
|
||||
encryptor seam — never stored or logged in plaintext.
|
||||
|
||||
## Performance, caching, money, idempotency
|
||||
- [ ] Read-heavy/config/reference data is cached behind the cache seam with sensible invalidation;
|
||||
`platform_configs` values are read through the typed config accessor (cached), not hardcoded.
|
||||
- [ ] **Money is IRR `BIGINT` — no floats, anywhere.** Toman conversion happens only inside a provider
|
||||
adapter at its boundary. The three booking amounts always satisfy
|
||||
`gross = commission + payout`.
|
||||
- [ ] Money-path writes are **idempotent** (webhook dedup on the unique external-event key; filtered
|
||||
unique on succeeded transaction; forward-only state machines) and, where the phase says so, guarded
|
||||
by a Redis **distributed lock** — with the DB constraint as the authoritative backstop.
|
||||
- [ ] `ledger_entries` is append-only and balanced (Σdebit = Σcredit per `transaction_group_id`).
|
||||
|
||||
## API surface
|
||||
- [ ] Controllers are `sealed`, inherit `BaseController`, inject `ISender`, return
|
||||
`base.OperationResult(...)` — never `Ok()/BadRequest()/NotFound()` directly. (Note: the baseline
|
||||
has **no** controllers yet — REST is introduced in an early phase; follow that pattern thereafter.)
|
||||
- [ ] Routes use `[controller]`/`[action]` tokens (snake_case transformer); pass `CancellationToken`.
|
||||
- [ ] Authorize with the narrowest fitting policy; auth/OTP/refund/payout-sensitive endpoints are
|
||||
rate-limited.
|
||||
- [ ] The endpoint's contract is published to `dev/contracts/` (see operating-rules §6).
|
||||
|
||||
## Quality
|
||||
- [ ] `async`/`await` all the way; `CancellationToken` threaded through; no `.Result`/`.Wait()`/`async void`.
|
||||
- [ ] Package versions only in `Directory.Packages.props`.
|
||||
- [ ] Handler unit tests (NSubstitute) + at least one `WebApplicationFactory` integration test per new
|
||||
feature area (happy path, 401, validation 400).
|
||||
- [ ] `dotnet build` zero new warnings; `dotnet test` green; no dead code.
|
||||
@@ -0,0 +1,43 @@
|
||||
# Definition of Done — applies to every phase
|
||||
|
||||
A phase is **done** only when *all* of the following are true. Each phase file adds its own
|
||||
phase-specific criteria on top of this shared baseline.
|
||||
|
||||
## Code
|
||||
- [ ] The full scope in the phase file is implemented — no laziness, no unsanctioned stubs. Anything
|
||||
not real is mocked **behind a DI seam** and recorded in the mock registry.
|
||||
- [ ] It follows the project conventions exactly (server `CLAUDE.md` + `CONVENTIONS.md`, or client
|
||||
`CLAUDE.md` + the frontend-designer skill) and the relevant conventions checklist.
|
||||
- [ ] No dead code (unused vars/imports/usings/params/members). Comments explain *why*, not *what*.
|
||||
- [ ] Best practices honoured: (backend) clean-arch boundaries, projected/paginated reads, caching where
|
||||
it pays, idempotency/locks on the money path, IRR `BIGINT` money, validation at the boundary;
|
||||
(frontend) RSC/client boundary, query caching + no needless refetch, minimal re-renders, MUI
|
||||
primitives reused, i18n in both locales, tokens-based colours, RTL-correct.
|
||||
|
||||
## Gate
|
||||
- [ ] **Backend:** `dotnet build Baya.sln` has zero new warnings **and** `dotnet test Baya.sln` passes,
|
||||
including the tests this phase adds.
|
||||
- [ ] **Frontend:** `npm run check` passes **and** `npm run test:ci` passes if a shared component was
|
||||
touched/added; `en.json` and `fa.json` are in sync.
|
||||
|
||||
## Documentation
|
||||
- [ ] The relevant architecture map is updated in the same change (server *Project map* / client
|
||||
*Project Structure* / root *Repository layout*) if the structure changed.
|
||||
- [ ] Any business rule discovered/decided is reflected in the `product/` docs (no invented rules).
|
||||
- [ ] New reusable patterns/seams are noted in the relevant `CLAUDE.md`/`CONVENTIONS.md`.
|
||||
|
||||
## Contracts & handoff
|
||||
- [ ] **Backend:** the API contract for what shipped is written to `dev/contracts/domains/<domain>.md`
|
||||
and the `swagger.json` snapshot is published per `dev/contracts/openapi/README.md`.
|
||||
- [ ] **Frontend:** types/services are derived from the published contract (not guessed); any contract
|
||||
gap is appended to `dev/shared-working-context/frontend/requests/for-backend.md`.
|
||||
- [ ] The handoff note `dev/shared-working-context/<lane>/...` is written (backend: a new
|
||||
`handoff/after-backend-phase-N.md`; both lanes: a STATUS append).
|
||||
|
||||
## Reporting & memory
|
||||
- [ ] The per-phase report `dev/shared-working-context/reports/<lane>-phase-N-report.md` is written:
|
||||
what was built, **what is now testable and exactly how**, what is mocked + how to make it real,
|
||||
contracts produced/consumed, follow-ups.
|
||||
- [ ] The mock registry `dev/shared-working-context/reports/mocks-registry.md` is updated for every
|
||||
seam this phase mocked/touched.
|
||||
- [ ] Persistent memory is saved for any non-obvious decision/seam/gotcha, with a `MEMORY.md` pointer.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Frontend conventions checklist (quick reference)
|
||||
|
||||
The authoritative rules are [`client/CLAUDE.md`](../../../client/CLAUDE.md) and the **frontend-designer**
|
||||
skill (invoke it for any visual work). This is a tick-list. If anything here seems to conflict with
|
||||
those, **they win**.
|
||||
|
||||
## Boundaries & structure
|
||||
- [ ] Never add a layout above `[locale]`; `src/app/[locale]/layout.tsx` is the root layout.
|
||||
- [ ] Respect the RSC/client boundary: no `next/headers`, `next-intl/server`, or `@/lib/cookies/server`
|
||||
in client components; no `@/lib/cookies/client` in an RSC.
|
||||
- [ ] New route group / provider / top-level `src/` folder ⇒ update the **Project Structure** tree in
|
||||
`client/CLAUDE.md`.
|
||||
|
||||
## Data fetching & caching (prevent extra fetches and re-renders)
|
||||
- [ ] Fetch **only** through `clientFetch`/`serverFetch` (`@/lib/api`); domain calls live in
|
||||
`src/services/{domain}/apis/`. Never raw `fetch()`.
|
||||
- [ ] Server state is **TanStack Query**: a `keys.ts` factory per domain, deliberate `staleTime`/`gcTime`,
|
||||
and **cache invalidation on mutation** (`invalidateQueries`/`setQueryData`) so you never refetch
|
||||
data you already hold. Prefer prefetch/`initialData` from RSC where it removes a client round-trip.
|
||||
- [ ] One hook per file (`use{Action}.ts`), `useQuery`/`useMutation`. Don't toast 401/403/5xx in hooks
|
||||
(the fetch layer already does) — only domain-specific 4xx messages.
|
||||
- [ ] Minimise re-renders: stable references (`useCallback`/`useMemo` only where it pays), `select` to
|
||||
subscribe to slices, colocate state low, lift only when shared. Don't put fast-changing state in
|
||||
a high context provider.
|
||||
|
||||
## UI composition
|
||||
- [ ] **Concrete MUI primitives stay MUI** — use `Button`, `Avatar`, `Paper`, `TextField`, etc. (or the
|
||||
existing `App*` wrappers); never invent a new root-level Button/Avatar.
|
||||
- [ ] **Composite/shareable components** (built from primitives, reused in >1 place — a nurse card, an
|
||||
OTP input, a price-breakdown, a step header) live at the right **shared** level (`src/components/…`),
|
||||
not inline in a page or buried in a leaf. A component imported from >1 place gets a co-located
|
||||
`*.test.tsx`.
|
||||
- [ ] Page-only, never-reused composition can stay in the page.
|
||||
|
||||
## i18n, theme, direction
|
||||
- [ ] Every user-visible string is a key in **both** `messages/en.json` and `messages/fa.json` (in sync).
|
||||
`fa` is default and **RTL** — design RTL-first; verify mirroring.
|
||||
- [ ] Colours come from `tokens.css` (`var(--bal-…)` / brand tokens), never hardcoded in `sx`. Use the
|
||||
pre-built `APP_THEME_LTR/RTL`; never `createTheme()` in a component.
|
||||
- [ ] MUI **v9** API only (`sx={{ mb: 4 }}`, no v5/v6-only props).
|
||||
|
||||
## Quality
|
||||
- [ ] Magic strings → named constants (`src/constants/` or a colocated `constants.ts`).
|
||||
- [ ] Cookies/app state only through the cookie manager; never `document.cookie`/`localStorage` for auth.
|
||||
- [ ] No dead code (unused vars/imports are lint **errors**). Comment the *why*, not the *what*.
|
||||
- [ ] `npm run check` green; `npm run test:ci` green when a shared component changed.
|
||||
- [ ] Types come from the published contract in `dev/contracts/` — don't guess server shapes; if a shape
|
||||
is missing, append a request to `dev/shared-working-context/frontend/requests/for-backend.md` and
|
||||
mock behind the `services/{domain}` seam meanwhile.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Phase prompt template (the skeleton every phase file follows)
|
||||
|
||||
> This is the shape of every `backend-phase-N.md` and `frontend-phase-N-bM.md`. It exists so the
|
||||
> chain is uniform and an agent always knows where to find each thing. When you (a planning author)
|
||||
> create or edit a phase file, fill every section. When you (an executing agent) run a phase, expect
|
||||
> every section to be present.
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
# <Backend|Frontend> Phase N — <Title>
|
||||
|
||||
> One-paragraph mission: what this phase delivers and why it matters to the product.
|
||||
> **Track:** backend|frontend · **Depends on:** <prior phases / backend phase bM> · **Unlocks:** <what comes next>
|
||||
> **Before you start, read [_shared/agent-operating-rules.md](../_shared/agent-operating-rules.md).** It is not optional.
|
||||
|
||||
## 1. Context — where this sits
|
||||
- Where we are in the chain; the 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,342 @@
|
||||
# Frontend Phase 1 — Auth: phone-OTP login & role routing
|
||||
|
||||
> **Mission:** make people able to actually sign in. Replace the inherited username/password stub with
|
||||
> Balinyaar's real credential — **phone-OTP** — and wire the gate that every other screen sits behind.
|
||||
> Build the customer login (A1), the OTP screen (A2), the family↔nurse login switch (B1/B2 entry), and
|
||||
> the **role router** that reads `/me` after sign-in and sends a customer to the family app, a nurse to
|
||||
> the nurse app, and a brand-new user with no role to role selection. After this phase the app has a real
|
||||
> front door; f2-b3 (onboarding & profiles) and every authenticated screen build on it.
|
||||
>
|
||||
> **Track:** frontend · **Depends on:** [`frontend-phase-0`](./frontend-phase-0.md) (shells, the OTP/phone
|
||||
> composites, the `services/{domain}` + Query pattern, the cookie manager, `AuthContext`) · **backend
|
||||
> contract** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md) (from
|
||||
> backend-phase-2) · **Unlocks:** every authenticated screen; f2-b3 onboarding & profiles
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — where this sits
|
||||
|
||||
The client (`client/`, **Next.js 16 App Router + React 19 + MUI v9 + next-intl**, `fa` default & RTL) has
|
||||
a full app shell, the three actor app shells from f0, and a cookie/fetch/Query/auth stack — but **no way
|
||||
to log in**. Today's auth domain is a **username/password stub** (`useLogin({ username, password })` →
|
||||
`/auth/login`) with no UI; per the product docs, Balinyaar's only real credential is **phone-OTP**
|
||||
([`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md):
|
||||
"Phone number is the primary login credential … Authentication is phone-OTP"). This phase swaps the stub
|
||||
for the real thing and adds the role router that turns "authenticated" into "in the right app".
|
||||
|
||||
**What already exists (do not rebuild) — confirmed in the codebase:**
|
||||
- **From [`frontend-phase-0`](./frontend-phase-0.md):** the three actor app shells + role-scoped route
|
||||
groups under `(private-routes)`; the **shared OTP code input** and **Iranian phone-number field**
|
||||
composites (`src/components/…` with co-located tests); the **`services/{domain}` + TanStack Query**
|
||||
reference pattern (a `keys.ts` factory, `apis/clientApi.ts` over `clientFetch`, one-hook-per-file,
|
||||
invalidate-on-mutation); the **types-from-contract** convention; the money/format util; the `auth`,
|
||||
`common`, `nav` i18n namespaces seeded in **both** `en.json`/`fa.json`. Read the f0 report
|
||||
([`reports/frontend-phase-0-report.md`](../../shared-working-context/reports/frontend-phase-0-report.md))
|
||||
for exactly what shipped and how the mock-`clientApi` seam works.
|
||||
- **The stack (built before f0):** root layout `src/app/[locale]/layout.tsx` (providers NextIntl → Auth →
|
||||
Theme → Query → Notistack); `clientFetch`/`serverFetch` + `ApiError` (`@/lib/api`) — **401 already
|
||||
clears cookies + toasts + redirects without throwing; 403/5xx toast+throw; other 4xx throw-no-toast**;
|
||||
the **cookie manager** (`@/lib/cookies/client` — `setClientCookie`/`deleteClientCookie`, with
|
||||
`AUTH_ACCESS_COOKIE_OPTIONS` ≈15 min and `AUTH_REFRESH_COOKIE_OPTIONS` ≈7 d, both **non-httpOnly** by
|
||||
the current design); `AuthContext` (`useReducer` `[state, dispatch]`, actions `LOG_IN{user?}`/`LOG_OUT`,
|
||||
server-seeded `initialState`); `makeQueryClient` (staleTime 30 s); the **middleware auth gate**
|
||||
(`isTokenAlive` on `access_token`, redirects non-public paths to `/${locale}/login`).
|
||||
- **The auth domain (the stub you are replacing):** `src/services/auth/` — `types.ts`
|
||||
(`LoginDto {username,password}`, `AuthTokens {accessToken,refreshToken}`, `User {id,username}`),
|
||||
`keys.ts` (`authKeys.currentUser()`), `apis/clientApi.ts` (`AuthClientApi.login/logout/getCurrentUser`),
|
||||
`hooks/useLogin|useLogout|useCurrentUser`, `index.ts`. The login hook writes both tokens as non-httpOnly
|
||||
cookies and dispatches `LOG_IN`; `useLogout` deletes both, dispatches `LOG_OUT`, redirects to
|
||||
`/${locale}/login`. **There is no login page yet** — only the hooks. You extend this domain in place.
|
||||
|
||||
**What you are replacing / removing in this phase:**
|
||||
- The username/password `LoginDto` + `useLogin({username,password})` + `POST /auth/login` path — replaced
|
||||
by the OTP request/verify pair. Remove the dead `username/password` types and the `login` api/hook once
|
||||
the OTP flow is wired; don't leave both (no-unused-vars is a lint **error**).
|
||||
|
||||
> **Wireframe reality check:** A1/A2 (customer) and B1/B2 (nurse) are the **same OTP mechanism** with
|
||||
> different copy and a different post-login destination. Build **one** OTP flow and parameterise the
|
||||
> intended-role/copy — do not fork two parallel login stacks.
|
||||
|
||||
## 2. Required reading (do this first)
|
||||
|
||||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you
|
||||
work, the gate, the §6 contract-gap handoff, the caching/re-render rules.
|
||||
- **The contract you consume:** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md)
|
||||
(published by **backend-phase-2**) — the **source of truth** for the OTP/refresh/logout/me shapes, routes,
|
||||
status codes, and the `Me`/roles enum. **Do not guess shapes.** Read it together with
|
||||
[`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md)
|
||||
(envelope, `snake_case` routes — `[controller]/[action]` so `RequestOtp` → `.../request_otp`; 400/401/403/409
|
||||
meanings; the locale header) and
|
||||
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md)
|
||||
(enums cross the wire as **stable string codes** — `male`/`female`, roles as codes — and the frontend
|
||||
mirrors them as string-literal unions; **never** hardcode a display label off a code, labels are i18n keys).
|
||||
**If the contract isn't published yet or a needed shape is missing** (e.g. the exact `Me` role codes, the
|
||||
resend-cooldown / max-attempts fields), follow operating-rules §6: append a request to
|
||||
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
|
||||
and mock behind the `services/auth` seam meanwhile (§4 below).
|
||||
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC/client boundary, the cookie rules (**auth state only
|
||||
through the cookie manager — never `document.cookie`/`localStorage`**), the services pattern, anti-patterns.
|
||||
- **Invoke the `frontend-designer` skill** before any visual work — A1/A2/B1 are full branded screens
|
||||
(brand mark, teal `#1d4a40` primary CTA, RTL Persian, Vazirmatn/Mikhak). It is the palette/token/typography
|
||||
and `App*`-library contract; all the login UI goes through it.
|
||||
- [`product/wireframes/index.html`](../../../product/wireframes/index.html) — screens **A1, A2, B1, B2**
|
||||
(and the role-router branch implied by A5 home vs B3 nurse status). The exact Persian strings are there:
|
||||
A1 CTA **"دریافت کد تایید"**, nurse-switch link **"پرستار هستید؟ ورود پرستاران ←"**; A2 resend
|
||||
**"ارسال مجدد کد تا ۰۰:۴۵"** + CTA **"تایید و ادامه"**; B1 subtitle
|
||||
**"ویژه پرستاران دارای پروانه نظام پرستاری"**, CTA **"دریافت کد تایید"**; B2 CTA **"تایید و ورود"**.
|
||||
- [`product/business/01-actors-and-onboarding.md`](../../../product/business/01-actors-and-onboarding.md) —
|
||||
the auth/role rules: **phone is the primary credential** (email never a login key), customer browses with
|
||||
only a verified phone, **sessions are revocable with refresh-token rotation + stolen-token detection**,
|
||||
admin roles are **internal-only / never self-assigned**.
|
||||
- The existing `src/services/auth/*`, `src/context/auth/*`, `src/lib/cookies/*`, `src/lib/api/*`,
|
||||
`src/middleware.ts` — the exact code you extend.
|
||||
|
||||
## 3. Scope — build this
|
||||
|
||||
A vertical slice: **service → hooks → screens → role router**, all over the published contract.
|
||||
|
||||
### 3.1 Rewrite `services/auth` for OTP (replace the username/password stub)
|
||||
|
||||
Keep the f0 service shape (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` / `index.ts`);
|
||||
swap its contents to OTP. **Mirror the published contract for every shape** — the names below are the
|
||||
intended surface; reconcile field names/casing against `swagger.json` and file a `for-backend.md` request
|
||||
for any gap.
|
||||
|
||||
- **`types.ts`** — replace `LoginDto` with:
|
||||
- `OtpRequest` — `{ phone: string; intended_role?: RoleCode }` (the role the user is logging in *as*,
|
||||
from the A1/B1 switch — used only for routing/copy; the server decides actual roles).
|
||||
- `OtpVerify` — `{ phone: string; code: string }`.
|
||||
- `Tokens` — `{ accessToken: string; refreshToken: string }` (rename/keep from `AuthTokens`; match wire casing).
|
||||
- `Me` — `{ id: number; phone: string; roles: RoleCode[]; active_role?: RoleCode;
|
||||
profile_completed: boolean; nurse_verification_status?: NurseVerificationStatus }` — the role-router
|
||||
input. Trim/extend to **exactly** what `GET /me` returns in the contract.
|
||||
- `RoleCode` — string-literal union `'customer' | 'nurse' | 'admin'` (codes, not labels).
|
||||
- `NurseVerificationStatus` — string-literal union mirroring the contract's enum (e.g.
|
||||
`'not_started' | 'in_progress' | 'pending_review' | 'verified' | 'rejected'`); used by the router to
|
||||
decide whether a nurse lands on B3 status vs the nurse home.
|
||||
- `OtpRequestResult` — whatever `request_otp` returns (e.g. `{ resend_after_seconds: number;
|
||||
code_length: number; expires_in_seconds: number }`); drive the **resend countdown** and **OTP box
|
||||
count** from this, not from a hardcoded `45`/`4`.
|
||||
- **`keys.ts`** — `authKeys.me()` (replaces `currentUser()`); keep `authKeys.all`.
|
||||
- **`apis/clientApi.ts`** — `AuthClientApi` over `clientFetch`:
|
||||
- `requestOtp(body: OtpRequest): Promise<OtpRequestResult>` → `POST .../auth/request_otp`
|
||||
- `verifyOtp(body: OtpVerify): Promise<Tokens>` → `POST .../auth/verify_otp`
|
||||
- `refresh(refreshToken: string): Promise<Tokens>` → `POST .../auth/refresh`
|
||||
- `logout(): Promise<void>` → `POST .../auth/logout`
|
||||
- `getMe(): Promise<Me>` → `GET .../me`
|
||||
(Use the **exact snake_case routes** from the contract; the examples follow the `[controller]/[action]`
|
||||
transform.) Remove the old `login` call.
|
||||
- **`hooks/` (one per file):**
|
||||
- `useRequestOtp.ts` — `useMutation` over `requestOtp`. `onSuccess` hands the cooldown/length back to A2.
|
||||
**Do not toast 401/403/5xx** (the fetch layer does) — only surface **domain 4xx** (e.g. 429
|
||||
rate-limit "try again in N s", invalid-phone 400) as inline field/screen errors.
|
||||
- `useVerifyOtp.ts` — `useMutation` over `verifyOtp`. `onSuccess`: **persist tokens via the cookie
|
||||
manager** (`setClientCookie` with `AUTH_ACCESS_COOKIE_OPTIONS`/`AUTH_REFRESH_COOKIE_OPTIONS`),
|
||||
dispatch **`LOG_IN`**, **`invalidateQueries(authKeys.me())`**, then trigger the role router (3.4).
|
||||
Map the contract's wrong-code / expired-code / max-attempts (lockout) failures to inline states.
|
||||
- `useLogout.ts` — keep behaviour: call `logout`, `deleteClientCookie` both tokens, dispatch `LOG_OUT`,
|
||||
**`invalidateQueries(authKeys.me())`** (or `queryClient.clear()` for auth keys), redirect to
|
||||
`/${locale}/login`.
|
||||
- `useMe.ts` — `useQuery(authKeys.me(), getMe)` (replaces `useCurrentUser`); `enabled` only when
|
||||
authenticated; sensible `staleTime`. This is the role router's data source and the shell's role source.
|
||||
- `useRefresh.ts` (DEFERRED token-rotation UI is out of scope, but) — expose `refresh` as a callable so
|
||||
the **silent refresh** path can rotate the access token when it expires (see 3.5). If the existing
|
||||
fetch layer is to own refresh, document that instead and don't duplicate it; flag the decision in your
|
||||
report.
|
||||
- **`index.ts`** — re-export the new hooks; drop `useLogin`/`useCurrentUser`.
|
||||
|
||||
### 3.2 A1 — customer phone login screen
|
||||
|
||||
The public login page (the route the middleware redirects to: `/${locale}/login`). Per A1 / the
|
||||
frontend-designer skill:
|
||||
- Brand mark + tagline; the **Iranian phone-number field** composite from f0 (placeholder `۰۹۱۲ ۰۰۰ ۰۰۰۰`,
|
||||
RTL-safe, validates an Iranian mobile).
|
||||
- Primary CTA **"دریافت کد تایید"** → `useRequestOtp({ phone, intended_role: 'customer' })`; on success,
|
||||
advance to A2 (carry the masked phone + cooldown + code length).
|
||||
- Secondary link **"پرستار هستید؟ ورود پرستاران ←"** → switches to the **nurse login** variant (B1).
|
||||
- States: **sending** (CTA spinner/disabled), **invalid/non-Iranian number** (inline), **rate-limit hit**
|
||||
("تا N ثانیه دیگر دوباره تلاش کنید"), **network error** (the fetch layer toasts; keep the field intact).
|
||||
|
||||
### 3.3 B1/B2 — nurse login switch (same flow, nurse intent)
|
||||
|
||||
Not a separate stack — the **same** login screen with `intended_role: 'nurse'`, nurse copy (B1 subtitle
|
||||
**"ویژه پرستاران دارای پروانه نظام پرستاری"**, OTP CTA **"تایید و ورود"**), and a link back to family
|
||||
login. Carry `intended_role` through A1→A2 (B1→B2) so the OTP screen shows the right CTA and the router
|
||||
knows the entry intent. Implement the variant via a query param or a small client switch on the login
|
||||
page — **no second route tree**.
|
||||
|
||||
### 3.4 A2 — OTP screen + auto-advance
|
||||
|
||||
Per A2/B2, using the **shared OTP code input** from f0:
|
||||
- N-digit boxes (N from `OtpRequestResult.code_length`, default 4 per the wireframe), **masked phone echo**
|
||||
("کد به شماره ۰۹۱۲•••۰۰۰۰ ارسال شد"), **resend countdown** **"ارسال مجدد کد تا ۰۰:۴۵"** driven by
|
||||
`resend_after_seconds` (a single timer, cleaned up on unmount — no leaked intervals).
|
||||
- CTA **"تایید و ادامه"** (customer) / **"تایید و ورود"** (nurse) → `useVerifyOtp`.
|
||||
- States: **auto-advance** (verify fires automatically when the last box is filled), **wrong code**
|
||||
(inline, clear boxes, keep focus), **expired code** (offer resend), **max-attempts lockout** (disable
|
||||
input + show the locked message + when it lifts), **resend** (re-calls `useRequestOtp`, restarts the
|
||||
countdown, disabled until the countdown hits 0).
|
||||
- On verify success the hook stores tokens + dispatches `LOG_IN` + invalidates `/me`; then the **role
|
||||
router** takes over (next).
|
||||
|
||||
### 3.5 The role router (after `/me`)
|
||||
|
||||
A small client component/hook (`useRoleRouter` or a `RoleRouter` boundary placed where the shell decides
|
||||
chrome) that consumes `useMe()` and routes:
|
||||
- **loading** (`/me` in flight) → the f0 `AppLoading` splash; never flash the wrong shell.
|
||||
- **roles includes `nurse`** → the **nurse app** home; if `nurse_verification_status` is not `verified`,
|
||||
land on **B3 verification status** (built in f5) — for now route to the nurse shell's home/placeholder
|
||||
and leave a clear pointer (f5 fills B3). A persistent "verification in progress" banner is **f5's** job;
|
||||
here just route correctly.
|
||||
- **roles includes `customer`** (and not nurse, or `active_role === 'customer'`) → the **customer/family
|
||||
app** home (A5, built in f4 — route to the customer shell's home/placeholder now).
|
||||
- **roles empty / no usable role** → the **SelectRole** screen (3.6).
|
||||
- **`active_role`** (if the contract returns one) wins when a user has multiple roles; otherwise prefer the
|
||||
`intended_role` carried from login, else default customer.
|
||||
- **admin** → the admin shell (f15) — route correctly but don't build admin screens here.
|
||||
|
||||
Honour the **middleware gate**: it already redirects unauthenticated users to `/login`; the role router
|
||||
runs **after** auth and only decides *which app*. Don't duplicate the auth check in the router.
|
||||
|
||||
### 3.6 SelectRole screen (first use)
|
||||
|
||||
A minimal post-login screen for a brand-new user with **no role**: choose **خانواده (customer)** or
|
||||
**پرستار (nurse)** → call the role-selection mutation (`POST /me/role`, `SelectRoleCommand` per the
|
||||
digest; consume the contract's exact route/body) → `invalidateQueries(authKeys.me())` → re-run the router.
|
||||
Admin is **never** selectable here (admin roles are internal-only). If `intended_role` from login is set,
|
||||
pre-select it. Keep this screen shared/simple; the full onboarding (A3 "who is care for", A4 add-patient,
|
||||
nurse profile bootstrap) is **f2-b3** — link it, don't build it.
|
||||
|
||||
### 3.7 Seed `AuthState` with roles
|
||||
|
||||
Extend the auth context beyond `{ id, username }`:
|
||||
- `src/context/auth/types.ts` — extend `AuthState.currentUser` to carry `{ id; phone; roles: RoleCode[];
|
||||
active_role?: RoleCode }` (mirror the `Me` essentials the shell needs for chrome) and keep
|
||||
`isAuthenticated`. Keep the reducer's `LOG_IN{user?}`/`LOG_OUT` actions; just widen the `user` payload.
|
||||
- The server-seeded `initialState` (root layout) should seed roles when it can derive them
|
||||
(it reads cookies/`getServerAuthState`); if roles aren't available server-side yet, seed
|
||||
`isAuthenticated` only and let `useMe()` hydrate roles client-side — **don't** put a second source of
|
||||
truth for roles. Document which it is in your report.
|
||||
- The f0 shells already read "a role" to pick chrome — now feed them the real `roles`/`active_role`.
|
||||
|
||||
### 3.8 i18n
|
||||
|
||||
All A1/A2/B1/B2/SelectRole/role-router strings as keys in the **`auth`** namespace (and `common`/`nav`
|
||||
for shared bits) in **both** `messages/en.json` and `messages/fa.json`, in sync, RTL-first. No label is
|
||||
ever derived from a role/status **code** in code — codes map to i18n keys. (DEFERRED) onboarding copy
|
||||
(A3/A4) belongs to f2's `onboarding` namespace.
|
||||
|
||||
## 4. Mocks & seams in this phase
|
||||
|
||||
This phase **introduces no new external-service seam** — OTP/SMS delivery is the **backend's** seam
|
||||
(`ISmsSender`, introduced in backend-phase-2; the frontend never sends SMS). The only frontend "mock" is
|
||||
the standard **contract-gap fallback** from operating-rules §6 and the f0 pattern:
|
||||
|
||||
- **If [`identity-auth.md`](../../contracts/domains/identity-auth.md) / the b2 swagger snapshot isn't
|
||||
merged when you start**, build a **mock `AuthClientApi`** behind the same `services/auth` seam (the f0
|
||||
mock-`clientApi` pattern): `requestOtp` returns a fixed `{ resend_after_seconds: 45, code_length: 4,
|
||||
expires_in_seconds: 120 }`; `verifyOtp` accepts a fixed dev code (e.g. `0000`) and returns fake tokens
|
||||
+ a `Me` you can toggle (customer / nurse-unverified / no-role) to exercise all three router branches;
|
||||
`getMe` returns the matching `Me`. Selection is by config (env/flag), **never** an `if (mock)` in a
|
||||
hook or screen.
|
||||
- **Record it:** append a row to
|
||||
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) (seam =
|
||||
`AuthClientApi` mock + file, what's faked, the config key, exact swap steps = point the service at the
|
||||
real `clientApi` once the contract is live) and a follow-up in your phase report.
|
||||
- Any shape the contract doesn't cover (resend/lockout fields, `Me` role codes, `active_role`) →
|
||||
**append a request** to
|
||||
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md); do **not** edit
|
||||
backend files.
|
||||
|
||||
(Reuse the f0 mock-seam mechanism — don't invent a new one.)
|
||||
|
||||
## 5. Critical rules you must not get wrong
|
||||
|
||||
- **Phone is the only credential.** No username/password anywhere after this phase; email is never a login
|
||||
key. Remove the stub `login` path completely (no dead code — it's a lint error).
|
||||
- **No token in `localStorage`, ever.** Tokens live **only** in cookies via the cookie manager
|
||||
(`@/lib/cookies/client`), non-httpOnly per the current design. Never `document.cookie`/`localStorage`
|
||||
for auth. Logout must actually clear both cookies *and* the server session (call `logout`).
|
||||
- **Sessions are revocable; refresh rotates.** Wire `refresh` so an expired access token is silently
|
||||
rotated (or confirm the fetch layer owns this and don't duplicate it). The server does reuse-detection;
|
||||
the client must send the current refresh token and replace **both** tokens on a successful refresh.
|
||||
- **`invalidateQueries(authKeys.me())` on login and logout.** `/me` is the role router's input — a stale
|
||||
`/me` routes the wrong person to the wrong app. Never read roles from two sources.
|
||||
- **Don't toast 401/403/5xx in hooks** — the fetch layer already does. Hooks surface only **domain 4xx**
|
||||
(invalid phone, rate-limit/429, wrong/expired code, lockout) as inline UI states.
|
||||
- **OTP states are explicit, not edges:** sending, resend-cooldown, wrong code, expired code,
|
||||
**max-attempts lockout**, auto-advance. A single timer, cleaned up on unmount (no leaked intervals →
|
||||
no re-render storms).
|
||||
- **The router never flashes the wrong shell.** Show `AppLoading` while `/me` is in flight; only render a
|
||||
shell once the role is known. **admin role is never self-assignable** — SelectRole offers customer/nurse
|
||||
only.
|
||||
- **RSC/client boundary:** login screens, OTP, the router, and the cookie writes are **client**
|
||||
(`'use client'`); no `next/headers`/`@/lib/cookies/server` in them. The middleware already gates routes —
|
||||
don't re-implement the gate in a component.
|
||||
- **Both locales, RTL-first, tokens for colour, MUI v9, MUI primitives stay MUI.** Every string in
|
||||
`en.json` **and** `fa.json`; no label derived from a code.
|
||||
- **Minimise re-renders:** the countdown timer and per-box OTP state stay **low** (in the OTP component),
|
||||
not in a high context; `useMe` subscribers use `select` if they only need `roles`.
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
||||
- [ ] `services/auth` is OTP-based: `OtpRequest`/`OtpVerify`/`Tokens`/`Me`(+roles) types, `authKeys.me()`,
|
||||
`requestOtp`/`verifyOtp`/`refresh`/`logout`/`getMe` apis, and the `useRequestOtp`/`useVerifyOtp`/
|
||||
`useMe`/`useLogout` hooks — the username/password stub is **removed**, not left dangling.
|
||||
- [ ] A1 (customer login), A2 (OTP, auto-advance, resend countdown, lockout), and the **B1/B2 nurse
|
||||
switch** render per the wireframe via the f0 phone/OTP composites, RTL, both locales, branded.
|
||||
- [ ] The **role router** sends customer→family app, nurse→nurse app (B3 status if unverified),
|
||||
no-role→**SelectRole**, admin→admin shell; it shows `AppLoading` during `/me` and never flashes the
|
||||
wrong shell.
|
||||
- [ ] Tokens are stored **only** via the cookie manager; `LOG_IN`/`LOG_OUT` dispatched; `authKeys.me()`
|
||||
invalidated on login **and** logout; refresh wired (or fetch-layer ownership documented).
|
||||
- [ ] `AuthState` carries `roles`/`active_role`; the f0 shells pick chrome from the real role.
|
||||
- [ ] All strings in `auth`/`common`/`nav` in **both** `en.json` and `fa.json`, in sync.
|
||||
- [ ] `npm run check` green; `npm run test:ci` green (you touched/added shared composites and the
|
||||
router — add tests for the OTP state machine and the router branches).
|
||||
- [ ] `client/CLAUDE.md` updated: the OTP login flow, the role-router seam, and the widened `AuthState`
|
||||
noted in *Project Structure* / the auth notes; the now-stale username/password references corrected.
|
||||
|
||||
## 7. How to test (what a human can verify after this phase)
|
||||
|
||||
With the b2 backend running (or the mock seam configured), `npm run dev`:
|
||||
- **Customer happy path:** open `/fa/login` → enter a valid mobile → "دریافت کد تایید" → A2 shows the
|
||||
masked phone + a counting-down resend → enter the code (auto-advance fires verify) → tokens appear in
|
||||
cookies (DevTools → Application → Cookies; **nothing** in localStorage) → redirected to the **customer**
|
||||
app home. `/me` is in the Query cache (React Query Devtools).
|
||||
- **Nurse switch:** from A1 tap "پرستار هستید؟ ورود پرستاران ←" → B1 nurse copy/subtitle → request →
|
||||
verify ("تایید و ورود") → routed to the **nurse** app (B3 status placeholder if unverified).
|
||||
- **No-role user:** verify as a user whose `/me` has empty `roles` → lands on **SelectRole** → choose a
|
||||
role → re-routed into that app.
|
||||
- **OTP edge states:** wrong code → inline error + boxes clear; let the code expire → resend re-enabled by
|
||||
the countdown; trigger max attempts → input locks with a clear message.
|
||||
- **Session:** log out → both cookies cleared, `/me` invalidated, redirected to `/login`; let the access
|
||||
token expire and act → silent refresh rotates it (or you're sent to `/login` cleanly).
|
||||
- **i18n/RTL:** switch to `en` → all auth strings translate, layout stays correct; `fa` is RTL.
|
||||
- `npm run check` and `npm run test:ci` pass (OTP state-machine + router-branch tests included).
|
||||
|
||||
## 8. Hand off & document (close the phase)
|
||||
|
||||
- **Docs:** update [`client/CLAUDE.md`](../../../client/CLAUDE.md) — the phone-OTP flow, the `useRoleRouter`
|
||||
seam, the widened `AuthState` (roles/active_role), and the new auth screens/route; **fix the stale
|
||||
username/password references**. If you add a route group/folder, update *Project Structure* in the same
|
||||
change. No `product/` rule changes expected (you're implementing decided rules); if you discover drift,
|
||||
record it there.
|
||||
- **Contract:** **consume** [`dev/contracts/domains/identity-auth.md`](../../contracts/domains/identity-auth.md)
|
||||
— types come from it, not guesses. Append every gap (resend/lockout fields, exact `Me` role codes,
|
||||
`active_role`, role-selection route/body) to
|
||||
[`dev/shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md).
|
||||
Frontend produces **no** contract.
|
||||
- **Handoff & report:** append to
|
||||
[`dev/shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md);
|
||||
write [`reports/frontend-phase-1-report.md`](../../shared-working-context/reports/frontend-phase-1-report.md)
|
||||
(what was built, **what is now testable and exactly how**, what's mocked behind the `AuthClientApi` seam
|
||||
+ how f-next swaps it, the contract consumed + gaps filed, follow-ups: B3 banner is f5, A3/A4 onboarding
|
||||
is f2, admin screens are f15). Update
|
||||
[`reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md) for the
|
||||
`AuthClientApi` mock if you used it.
|
||||
- **Memory:** save a `project` memory note for the phone-OTP decision, the role-router seam + branch logic,
|
||||
and the widened `AuthState`, with a `MEMORY.md` pointer — what a future agent can't cheaply re-derive.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Frontend Phase 10 — Cancellation & refund status (customer)
|
||||
|
||||
> **Mission:** give the family an honest, trust-first picture of *what cancelling costs* and *where their
|
||||
> money is*. Before a customer confirms a cancellation, the screen must **resolve the applicable
|
||||
> cancellation policy by lead time** and **disclose the fee / refund percentage up front** — no surprise
|
||||
> charges. After a cancellation, the customer follows a read-only **refund status** (pending → on-its-way
|
||||
> with an expected ETA → completed) that tells the truth about the asynchronous BNPL window (~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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -0,0 +1,330 @@
|
||||
# Frontend Phase 14 — Messaging (tickets) & notifications
|
||||
|
||||
> **Mission:** give families and nurses the *only* sanctioned way to talk after a booking — the
|
||||
> admin-readable **ticket** system — plus the in-app **notification center** that pulls them back in
|
||||
> and deep-links them to the right place. There is no live chat by design: communication is structured,
|
||||
> auditable, and anti-disintermediation. This phase ships the customer/nurse "My Tickets" inbox + thread,
|
||||
> the notification bell with a polled unread count, and the prominent **emergency playbook banner** on
|
||||
> booking/support entry. It must never leak an internal admin note into a user's view.
|
||||
>
|
||||
> **Track:** frontend · **Depends on:** [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) (booking
|
||||
> detail — the ticket/support entry points hang off it) · backend **b15** contract
|
||||
> ([messaging-notifications-admin](../../contracts/domains/messaging-notifications-admin.md)) + the
|
||||
> **b1** notifications endpoints · **Unlocks:** [`frontend-phase-15-b15`](./frontend-phase-15-b15.md)
|
||||
> (admin & partner consoles — reuses the same ticket/notification services with the admin lens) ·
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — where this sits
|
||||
|
||||
We are at the social/communication layer of the customer & nurse apps. Bookings, sessions, EVV,
|
||||
payments, refunds, and reviews already exist; what is missing is the **channel** that ties them
|
||||
together for humans. Balinyaar deliberately has **no direct nurse↔customer messaging and no live chat** —
|
||||
all post-booking communication runs through **tickets** that admin can read in full (anti-disintermediation
|
||||
and patient-safety; see [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md)).
|
||||
A booking-coordination ticket is auto-created on confirmation; users also open support/refund tickets.
|
||||
In parallel, the **notification center** is the app's pull mechanism — in-app only (no push at launch),
|
||||
**polled**, with a typed `data_json` payload that tells the front-end where to deep-link.
|
||||
|
||||
This phase builds **two new domain services** (`services/tickets`, `services/notifications`) and the
|
||||
screens on top of them, for the **customer and nurse apps only**. The admin lens over the very same
|
||||
data — the global ticket queue with the internal-note composer, the support-alert worklist — is
|
||||
**(DEFERRED to [`frontend-phase-15-b15`](./frontend-phase-15-b15.md))**; build the services so f15 reuses
|
||||
them without rewrite.
|
||||
|
||||
**What already exists (do not rebuild) — confirmed from prior phases:**
|
||||
- The app shells, role-aware nav, the **5-tab customer bottom nav** and the nurse shell, the
|
||||
`services/{domain}` + TanStack Query caching pattern, the contracts→types pattern, the money/format
|
||||
utils, and the shared composites (status chip, stepper, cards) — [`frontend-phase-0`](./frontend-phase-0.md).
|
||||
- `AuthContext` with roles, the OTP login/role router — [`frontend-phase-1-b2`](./frontend-phase-1-b2.md).
|
||||
- The **booking detail & sessions** screen, the status timeline, and the nurse EVV check-in/out — these
|
||||
are where the **"Get support / Open ticket"** entry point and the nurse **emergency banner** attach —
|
||||
[`frontend-phase-8-b9`](./frontend-phase-8-b9.md). Reuse its booking-detail layout and booking query;
|
||||
**do not** rebuild booking fetching here.
|
||||
- Reviews & patient-record screens (the prior phase) — [`frontend-phase-13-b14`](./frontend-phase-13-b14.md);
|
||||
unrelated to this phase except that both consume the b14/b15 contract bundle.
|
||||
- `clientFetch`/`serverFetch` + `ApiError`, the toast bridge (already toasts 401/403/5xx — do **not**
|
||||
re-toast those in your hooks), the cookie manager, `APP_THEME_LTR/RTL`, `tokens.css`.
|
||||
|
||||
> **Backend readiness note.** The contract you consume,
|
||||
> [`messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md), is
|
||||
> produced by **backend-phase-b15** (tickets) and the notification endpoints by **b1**. If a shape you
|
||||
> need is absent or wrong when you start, **do not guess and do not block** — append a request to
|
||||
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
|
||||
> and mock behind the `services/tickets` / `services/notifications` seam meanwhile (operating-rules §6).
|
||||
> Record every mock in your report so it swaps cleanly.
|
||||
|
||||
## 2. Required reading (do this first)
|
||||
|
||||
**Operating rules & checklists**
|
||||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how you work and the tick-list.
|
||||
- [`../_shared/definition-of-done.md`](../_shared/definition-of-done.md) — the bar this phase adds to (§6).
|
||||
|
||||
**Product / business truth (read before designing any screen)**
|
||||
- [`product/business/12-messaging-and-emergencies.md`](../../../product/business/12-messaging-and-emergencies.md) —
|
||||
**the core rules**: no live chat / no direct channel, ticket-only, `is_internal` admin notes, the
|
||||
emergency playbook ("call the surfaced emergency contact, then open a ticket"), why the family's phone
|
||||
is never exposed beyond the controlled emergency surface.
|
||||
- [`product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md) —
|
||||
in-app, polled notifications (no push), 90-day retention, deep-link via the typed payload; the admin
|
||||
tooling spine is **(DEFERRED to f15)**.
|
||||
- [`product/data-model/09-messaging.md`](../../../product/data-model/09-messaging.md) — `tickets` /
|
||||
`ticket_participants` / `ticket_messages`, `is_internal`, `reference_code`, optional booking/refund links.
|
||||
- [`product/data-model/11-notifications.md`](../../../product/data-model/11-notifications.md) —
|
||||
`notifications` (`data_json` typed payload, polled, 90-day read retention); `support_alerts` are
|
||||
internal-only (not in this phase's user app).
|
||||
|
||||
**Contracts & types (the source of truth for shapes — do not guess)**
|
||||
- [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md) —
|
||||
the b15 ticket endpoints + the b1 notification endpoints, request/response shapes, enums, status codes,
|
||||
the user-vs-admin filtering note for `is_internal`.
|
||||
- [`../../contracts/conventions/api-conventions.md`](../../contracts/conventions/api-conventions.md) and
|
||||
[`../../contracts/conventions/money-and-types.md`](../../contracts/conventions/money-and-types.md) —
|
||||
the envelope, enums-as-codes, UTC timestamps (render Shamsi), pagination.
|
||||
|
||||
**Code to mirror (existing patterns — copy, don't invent)**
|
||||
- `client/src/services/auth/*` (`types.ts` / `keys.ts` / `apis/clientApi.ts` / `hooks/use*.ts` /
|
||||
`index.ts`) — the exact shape every new domain service copies.
|
||||
- The booking-detail screen + its booking query from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) —
|
||||
where the support/emergency entry points mount.
|
||||
- [`client/CLAUDE.md`](../../../client/CLAUDE.md) — RSC boundary, layouts, i18n, theme, fetch services,
|
||||
anti-patterns.
|
||||
|
||||
**Design**
|
||||
- **Invoke the `frontend-designer` skill** before building any screen. All visual work (the inbox list,
|
||||
the thread bubbles, the bell + badge, the emergency banner, message-send states) goes through it —
|
||||
brand palette, tokens, typography, the `App*` library, RTL rules. Do not hand-roll colours or spacing.
|
||||
|
||||
## 3. Scope — build this
|
||||
|
||||
Two new domain services + the customer/nurse screens that consume them. Customer **and** nurse apps share
|
||||
these screens (role decides chrome, not the components). The admin global queue is **(DEFERRED to
|
||||
[`frontend-phase-15-b15`](./frontend-phase-15-b15.md))**.
|
||||
|
||||
### 3.1 `services/tickets` (new domain seam)
|
||||
Copy the `auth` service skeleton exactly.
|
||||
- **`types.ts`** — derive from the b15 contract: `Ticket` (`id`, `referenceCode`, `subject`/`category`,
|
||||
`status` enum e.g. `open|pending|closed`, optional `bookingId`/`refundId`, `lastMessageAt`,
|
||||
`unreadCount`), `TicketMessage` (`id`, `ticketId`, `body`, `authorRole` e.g. `customer|nurse|admin`,
|
||||
`authorName`, `createdAt`, `isMine`). **Do not model an `isInternal` field in the user-app types** —
|
||||
the server strips internal messages from the user view; modelling it invites a leak (§5).
|
||||
- **`keys.ts`** — query-key factory: `tickets.list(params)`, `tickets.detail(id)`,
|
||||
`tickets.thread(id, page)`. Deliberate `staleTime` (thread is short-lived; list moderate).
|
||||
- **`apis/clientApi.ts`** wrapping `clientFetch` — `listMyTickets(params)`, `getTicket(id)`,
|
||||
`getThread(id, page)`, `openTicket(body)`, `postMessage(ticketId, body, clientMessageId)`.
|
||||
- **`hooks/` (one hook per file):** `useMyTickets`, `useTicket`, `useTicketThread`, `useOpenTicket`,
|
||||
`usePostMessage`. `usePostMessage` is **optimistic** (§3.5). Mutations **invalidate**
|
||||
`tickets.list`/`tickets.thread` on settle so cached data isn't refetched needlessly.
|
||||
- **`index.ts`** barrel.
|
||||
|
||||
### 3.2 `services/notifications` (new domain seam)
|
||||
Copy the same skeleton.
|
||||
- **`types.ts`** — from the b1 contract: `AppNotification` (`id`, `type` enum, `title`/`body`, `isRead`,
|
||||
`createdAt`, `dataJson`), and a **discriminated-union `NotificationData`** typed off `type` (e.g.
|
||||
`booking_confirmed → { bookingId }`, `payment_captured → { bookingId }`, `payout_paid → { batchId }`,
|
||||
`review_published → { reviewId }`, `ticket_message → { ticketId }`). `data_json` is a *typed contract*
|
||||
— parse it into the union; never trust an arbitrary blob (§5).
|
||||
- **`keys.ts`** — `notifications.list(params)`, `notifications.unreadCount()`.
|
||||
- **`apis/clientApi.ts`** — `listNotifications(params)` (paged, unread-first), `getUnreadCount()`,
|
||||
`markRead(id)`, `markAllRead()`.
|
||||
- **`hooks/`:** `useNotifications`, `useUnreadCount` (the **polling** query — §3.4), `useMarkNotificationRead`,
|
||||
`useMarkAllRead`. Mark-read mutations **`setQueryData`** to flip `isRead` and decrement the cached count
|
||||
optimistically, then invalidate on settle — no full refetch of the list.
|
||||
- **`index.ts`** barrel.
|
||||
- A small **`notificationDeepLink(n: AppNotification): string`** util that maps the parsed `dataJson` to an
|
||||
in-app route (e.g. `bookingId → /bookings/{id}`, `ticketId → /support/tickets/{id}`). Centralise it so the
|
||||
bell and the center both deep-link identically.
|
||||
|
||||
### 3.3 Ticket screens (customer + nurse apps)
|
||||
- **"My Tickets" inbox** (`/support/tickets` in the customer shell; the equivalent under the nurse shell) —
|
||||
a paginated list of `Ticket` cards built from MUI/`App*` primitives: **`reference_code` shown prominently**,
|
||||
subject/category, status chip (reuse the f0 status chip), linked-booking/refund hint (handle the
|
||||
null link gracefully), relative Shamsi time, and an **unread indicator**. States: **empty**
|
||||
("هنوز گفتگویی ندارید" / "No conversations yet"), loading skeleton, error→retry. A **"Contact support"**
|
||||
CTA opens a new ticket (category select → submit → confirmation showing the new `reference_code`).
|
||||
- **Ticket thread view** (`/support/tickets/[id]`) — role-aware message bubbles (mine vs theirs, mirrored
|
||||
for RTL), the `reference_code` in the header, linked-booking chip, a sticky composer at the bottom.
|
||||
States: thread skeleton, **empty** ("هنوز پیامی نیست، هماهنگی را شروع کنید" / "No messages yet — start
|
||||
coordinating"), per-message send states (sending / sent / **failed→retry, draft preserved**),
|
||||
not-a-participant / ticket-closed errors. **The user thread NEVER renders internal-note content or
|
||||
styling** — there is no internal-note affordance anywhere in the user app (§5).
|
||||
- **Open-from-booking entry point** — on the booking-detail screen from
|
||||
[`frontend-phase-8-b9`](./frontend-phase-8-b9.md), add a **"Get support / Open ticket"** action that
|
||||
opens a new ticket pre-linked to that `bookingId` (or jumps to the existing coordination ticket if one
|
||||
exists). Wire it through `services/tickets`; don't fetch the booking again — reuse its query.
|
||||
|
||||
### 3.4 Notification center + bell
|
||||
- **Notification bell** — a shared component in the app chrome (customer bottom-nav/top-bar and nurse
|
||||
shell) showing the **unread count badge**. The count comes from `useUnreadCount`, which **polls with
|
||||
stale-while-revalidate**: set a sensible `refetchInterval` (e.g. 60s), `refetchOnWindowFocus`, and a
|
||||
matching `staleTime` so you serve the cached count instantly and revalidate in the background — **do
|
||||
not hammer the endpoint** (§5).
|
||||
- **Notification center** (`/notifications`, or a drawer/sheet off the bell) — a paged, **unread-first**
|
||||
list; each row deep-links via `notificationDeepLink` and **marks itself read on open** (optimistic
|
||||
`setQueryData` flips `isRead` and decrements the cached unread count, invalidate on settle). A
|
||||
**"Mark all read"** action. States: **empty** ("بهروز هستید" / "You're all caught up"), loading,
|
||||
error→retry, unread badge styling. Reuse the f0 status chip / list primitives; build the row as a
|
||||
shared composite with a co-located test.
|
||||
|
||||
### 3.5 Optimistic message send (the interaction that must feel instant)
|
||||
`usePostMessage` is `useMutation` with `onMutate`: generate a `clientMessageId`, `cancelQueries` on the
|
||||
thread key, snapshot, and `setQueryData` to append a **pending bubble**; on error **roll back to the
|
||||
snapshot but keep the typed text in the composer draft** so the user can retry without retyping; on
|
||||
success replace the pending bubble with the server message; `onSettled` invalidate the thread key.
|
||||
Reconcile by `clientMessageId` so you never double-render a message. The composer disables submit while
|
||||
sending but never clears the draft until the server confirms.
|
||||
|
||||
### 3.6 Emergency banner (the operational playbook)
|
||||
A shared **emergency banner** composite shown on the **booking detail (esp. nurse app)** and on the
|
||||
**support entry**: prominent, branded, copy = "For emergencies, call the emergency contact, then open a
|
||||
ticket" (both locales). It surfaces the **emergency-contact number as a `tel:` click-to-call link** drawn
|
||||
from the booking's (post-confirmation, decrypted) care instructions exposed by
|
||||
[`frontend-phase-8-b9`](./frontend-phase-8-b9.md)/the booking contract — **the platform never exposes a
|
||||
nurse's or family's general phone number; only this controlled, post-confirmation emergency surface**
|
||||
(§5). Pre-confirmation, render **nothing** (no placeholder). If the contact can't be loaded, still show
|
||||
the banner with a path to open a support ticket. This is `tel:` only — **do not build any calling/VoIP
|
||||
seam** (telephony is out-of-platform by design).
|
||||
|
||||
### 3.7 i18n + types housekeeping
|
||||
- Add a `messaging`/`tickets` and a `notifications` namespace to **both** `messages/en.json` and
|
||||
`messages/fa.json`, in sync, RTL-first (`fa` default). Every user-visible string is a key.
|
||||
- Types come from the published contract; any gap → append to
|
||||
[`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md) and mock behind the seam.
|
||||
|
||||
**(DEFERRED)** — admin global ticket queue + internal-note composer, support-alert worklist
|
||||
([`frontend-phase-15-b15`](./frontend-phase-15-b15.md)); push/real-time message delivery, SignalR/SSE
|
||||
replacing polling, file attachments on messages, a first-class incidents/SLA UI (product-doc DEFERRED).
|
||||
Build the services so f15 layers the admin lens on top; do not stub admin screens here.
|
||||
|
||||
## 4. Mocks & seams in this phase
|
||||
|
||||
This is a **frontend** phase — its only "seams" are the two domain services behind which a mock
|
||||
`clientApi` lives until the backend is merged (operating-rules §6, frontend-checklist last bullet).
|
||||
|
||||
- **`services/tickets` seam** — if b15 isn't merged, ship a mock `clientApi` (same method signatures as
|
||||
the real one) returning realistic tickets/threads (with `reference_code`, **no internal messages** — the
|
||||
mock must mimic the server's user-view filtering), and an in-memory append for optimistic-send testing.
|
||||
- **`services/notifications` seam** — likewise: a mock returning a paged unread-first list, a decrementing
|
||||
unread count, and a `data_json` payload per type so `notificationDeepLink` can be exercised.
|
||||
|
||||
Record both mocks in your **frontend report** and (since they're client-side mocks behind a seam) note
|
||||
them so f15/real-endpoint swap is a one-file change. **Do not** introduce backend seams (`IFieldEncryptor`,
|
||||
`INotificationDispatcher`, etc.) — those are b1/b15's; **reuse** the booking-detail care-instruction
|
||||
disclosure from [`frontend-phase-8-b9`](./frontend-phase-8-b9.md) for the emergency contact, do not
|
||||
re-implement decryption client-side.
|
||||
|
||||
## 5. Critical rules you must not get wrong
|
||||
|
||||
- **`is_internal` never reaches the user app.** Internal admin notes are server-filtered, but **do not
|
||||
leak the concept**: don't model an `isInternal` field in the user-app types, don't render internal
|
||||
styling, and never add an internal-note affordance in the customer/nurse thread. Treat any internal
|
||||
message in a user-view payload as a backend defect — file it via `for-backend.md`, don't render it.
|
||||
- **No direct out-of-band channel.** There is no live chat and no nurse↔customer phone exchange. The
|
||||
**only** sanctioned bypass is the **emergency-contact `tel:` surface**, and only **post-confirmation**.
|
||||
Never expose a nurse's or family's general phone number; never turn the emergency surface into a contact
|
||||
directory. The emergency path is an **operational playbook, not a real-time feature** — no SLA timers,
|
||||
no VoIP/calling seam, just `tel:` + "then open a ticket".
|
||||
- **Show `reference_code` prominently** in the inbox and thread header — it is what a user quotes to
|
||||
support and must be stable and visible.
|
||||
- **Poll the unread count politely.** `useUnreadCount` uses **stale-while-revalidate** (sensible
|
||||
`refetchInterval` + `staleTime`, refetch-on-focus) — serve the cached count, revalidate in the
|
||||
background, and **do not hammer** the endpoint. Don't poll the full notification list on an interval;
|
||||
only the count.
|
||||
- **Optimistic send is draft-preserving.** On failure, roll the thread back to its snapshot **but keep the
|
||||
user's text in the composer** with a retry; reconcile by `clientMessageId` to avoid double-render. Never
|
||||
clear the draft until the server confirms.
|
||||
- **`data_json` is a typed contract.** Parse it into the discriminated `NotificationData` union and
|
||||
deep-link off that; never `eval`/trust an arbitrary blob, and degrade gracefully (no deep-link) for an
|
||||
unknown `type`.
|
||||
- **Tenancy / null links.** A user sees only their own tickets and notifications (server-enforced — don't
|
||||
fetch by raw id you don't own). Ticket↔booking/refund links are **optional** — render the linked-entity
|
||||
chip only when present; handle `null` gracefully.
|
||||
- **Frontend conventions (non-negotiable):** fetch only through `clientFetch` in `services/{domain}`;
|
||||
TanStack Query caching with deliberate keys + invalidation/`setQueryData` (no needless refetch); minimise
|
||||
re-renders (`select`, stable refs, colocated state — the bell's fast-changing count must not re-render the
|
||||
whole shell); MUI primitives stay MUI, shared composites (notification row, emergency banner, message
|
||||
bubble) live at `src/components/…` with co-located tests; colours from `tokens.css`; both locales in sync;
|
||||
RTL-correct (mirror message bubbles); no layout above `[locale]`; respect the RSC/client boundary.
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
The shared [definition-of-done.md](../_shared/definition-of-done.md), plus:
|
||||
- [ ] `services/tickets` and `services/notifications` exist following the `auth` service shape (types from
|
||||
the b15/b1 contract, keys factory, `clientApi`, one hook per file, barrel), with mutations
|
||||
invalidating / `setQueryData`-ing cache.
|
||||
- [ ] **My Tickets inbox** (customer + nurse) lists tickets with prominent `reference_code`, status chip,
|
||||
unread indicator, null-safe linked-entity hint, and empty/loading/error states; **Contact support**
|
||||
opens a ticket and shows the new `reference_code`.
|
||||
- [ ] **Ticket thread** renders role-aware bubbles, **never any internal-note content/styling**, with
|
||||
empty/skeleton/error and per-message send states; **optimistic send** appends instantly and, on
|
||||
failure, rolls back while **preserving the composer draft** and offering retry.
|
||||
- [ ] **Open-from-booking** entry on the f8 booking detail opens/links a ticket without re-fetching the booking.
|
||||
- [ ] **Notification bell** shows a **polled** unread count (stale-while-revalidate, not hammering); the
|
||||
**center** lists unread-first, **marks read on open** (optimistic), has **Mark all read**, and
|
||||
**deep-links via `data_json`** through `notificationDeepLink`.
|
||||
- [ ] **Emergency banner** appears on booking detail (nurse) + support entry **only post-confirmation**,
|
||||
with a `tel:` click-to-call and the "call the emergency contact, then open a ticket" copy; hidden
|
||||
pre-confirmation; degrades to a support path if the contact can't load. No calling/VoIP seam built.
|
||||
- [ ] `messaging`/`tickets` + `notifications` i18n namespaces added to `en.json` **and** `fa.json` in sync;
|
||||
RTL verified (mirrored bubbles, badge placement).
|
||||
- [ ] Shared composites (notification row, message bubble, emergency banner) live at the shared level each
|
||||
with a co-located `*.test.tsx`; `npm run check` green; `npm run test:ci` green.
|
||||
- [ ] Any contract gap is in [`for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
|
||||
and the corresponding client-side mock is behind the seam and recorded in the report.
|
||||
- [ ] `client/CLAUDE.md` *Project Structure* updated for the new `services/tickets`, `services/notifications`,
|
||||
the new route segments (`/support/tickets`, `/notifications`), and the shared bell/banner components.
|
||||
|
||||
## 7. How to test (what a human can verify after this phase)
|
||||
|
||||
Run `npm run dev` (with the b15/b1 endpoints live, or the seam mocks if not yet merged):
|
||||
|
||||
1. **Open a ticket from a booking.** Go to a confirmed booking's detail → tap **"Get support / Open
|
||||
ticket"** → a ticket opens pre-linked to that booking and lands in **My Tickets** with its
|
||||
`reference_code` shown. *Expected:* the new ticket appears at the top of the inbox without a manual refresh.
|
||||
2. **Post a message (optimistic).** Open the thread, type, send → the bubble appears **immediately** with a
|
||||
"sending" state, then resolves to "sent". Kill the network (or trigger the mock's error path) and send
|
||||
again → the bubble shows **failed→retry** and **the text stays in the composer**; retry succeeds when the
|
||||
network returns. *Expected:* no duplicate bubble, no lost draft.
|
||||
3. **No internal notes leak.** With a ticket that has an admin internal note (mock or seeded), confirm the
|
||||
**user thread shows none of it** — no hidden styling, no affordance. *Expected:* user view is identical
|
||||
whether or not internal notes exist.
|
||||
4. **Notification bell unread count.** Trigger a notification (e.g. a new ticket message) → the **bell badge
|
||||
increments** within the poll interval. Open the **center**, open a notification → it **marks read**, the
|
||||
**badge decrements**, and it **deep-links** to the right screen (booking/ticket/etc.) via `data_json`.
|
||||
**Mark all read** clears the badge. *Expected:* count is served from cache instantly, revalidates in the
|
||||
background, and the endpoint is **not** hit more often than the interval (check the network tab).
|
||||
5. **Emergency banner.** On a **confirmed** booking (nurse app), the **emergency banner** is visible with a
|
||||
`tel:` link and the playbook copy; on an **unconfirmed** booking it is **absent** (not a placeholder).
|
||||
*Expected:* tapping the link initiates a phone call; "open a ticket" routes to the ticket flow.
|
||||
6. **RTL + locales.** Switch `fa`/`en`: bubbles mirror, the badge sits correctly, every string is
|
||||
translated. `npm run check` and `npm run test:ci` pass.
|
||||
|
||||
## 8. Hand off & document (close the phase)
|
||||
|
||||
- **Docs to update:** `client/CLAUDE.md` *Project Structure* — add `services/tickets`,
|
||||
`services/notifications`, the `/support/tickets` + `/notifications` route segments, the shared
|
||||
notification-bell / message-bubble / emergency-banner components, and a one-line note on the polling
|
||||
unread-count pattern and the optimistic-send/draft-preserve pattern so f15 reuses them. If you discover a
|
||||
business-rule drift (e.g. the contract exposes `is_internal` to users), record it and file the request —
|
||||
do not invent rules.
|
||||
- **Contract to consume:** [`../../contracts/domains/messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md)
|
||||
(b15 tickets) + the b1 notification endpoints — derive all types from it; **never** guess shapes. Any
|
||||
missing field/filter/endpoint → append a `REQ-NNN` entry to
|
||||
[`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
|
||||
(e.g. a `clientMessageId`/idempotency field for optimistic send, an `unreadCount` on the ticket list, the
|
||||
emergency-contact field on the booking payload) and mock behind the seam meanwhile.
|
||||
- **Handoff & report:** append your phase summary to
|
||||
[`../../shared-working-context/frontend/STATUS.md`](../../shared-working-context/frontend/STATUS.md); write
|
||||
`../../shared-working-context/reports/frontend-phase-14-report.md` (operating-rules §7) — what was built,
|
||||
**what is now testable and exactly how** (the 6 steps above), which client-side mocks sit behind the two
|
||||
seams and how f15/the real endpoint swaps them, the contracts consumed, and the follow-ups left for
|
||||
[`frontend-phase-15-b15`](./frontend-phase-15-b15.md) (the admin lens over these services). Update the
|
||||
mock registry [`../../shared-working-context/reports/mocks-registry.md`](../../shared-working-context/reports/mocks-registry.md)
|
||||
for the two client-side service mocks.
|
||||
- **Memory:** save a `project` memory note (with a `MEMORY.md` pointer) for the non-obvious decisions this
|
||||
phase locks in — the user-app types deliberately omit `isInternal`, the polled-unread-count
|
||||
stale-while-revalidate pattern, the optimistic draft-preserving send + `clientMessageId` reconciliation,
|
||||
the `data_json`→`notificationDeepLink` typed-union mapping, and the post-confirmation `tel:`-only
|
||||
emergency surface (no VoIP seam).
|
||||
@@ -0,0 +1,661 @@
|
||||
# Frontend Phase 15 — Admin backoffice & partner-center consoles
|
||||
|
||||
> **Mission:** ship the **operational cockpit** that runs Balinyaar — the internal, role-gated admin
|
||||
> backoffice in the **desktop sidebar shell** from f0, plus the **partner-center portal** (a separate
|
||||
> authz scope for the licensed sponsoring centers). The backoffice consolidates the worklists ops needs:
|
||||
> the **verification review queue** (pass/reject nurse steps with a signed-URL document viewer + structured
|
||||
> credential entry), **refund admin** (ticket-linked, fee/payout-decomposed, channel-aware, BNPL ETA),
|
||||
> the **payout dashboard** (batch preview → processing → completed/partially-failed, retry), **review
|
||||
> moderation** (publish/hide/reject), the **config editor** (typed inputs by `data_type`, audited save +
|
||||
> change-history), the **holiday calendar manager**, the **audit-log viewer** (filtered, paginated), and
|
||||
> the **support-alert worklist** (assign/resolve). The partner portal shows a center its onboarding/
|
||||
> verification state, its sponsored nurses, its sponsored bookings, and — when it is merchant-of-record —
|
||||
> its settlement/invoice view. There is **no wireframe** for any of these screens — you design them with
|
||||
> the **frontend-designer** skill. Internal-only data (`support_alerts`, internal ticket notes) must
|
||||
> **never** leak to a non-admin. When this lands, **MVP is complete.**
|
||||
>
|
||||
> **Track:** frontend · **Depends on:** [`frontend-phase-14-b15`](./frontend-phase-14-b15.md) (the
|
||||
> `services/tickets` + `services/notifications` domains this phase layers the admin lens over) + the
|
||||
> **b15** contract ([`messaging-notifications-admin`](../../contracts/domains/messaging-notifications-admin.md))
|
||||
> **and** the admin endpoints across **b1** (config/holidays/audit/support-alerts), **b6** (verification
|
||||
> queue), **b11** (refunds/invoices), **b13** (payout batches), **b14** (review moderation) ·
|
||||
> **Unlocks:** **MVP complete** — this is the final frontend phase.
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md).** It is not optional.
|
||||
|
||||
---
|
||||
|
||||
## 1. Context — where this sits
|
||||
|
||||
This is the **last frontend phase** and the only one whose primary audience is **internal staff** (and
|
||||
the licensed partner centers), not families or nurses. Every customer- and nurse-facing surface already
|
||||
exists; what is missing is the back office that makes the marketplace *run*: an admin must be able to
|
||||
**verify a nurse**, **process a refund**, **preview and run a payout batch**, **moderate a review**,
|
||||
**edit a config value**, and **resolve a support alert** — and a partner-center admin must be able to see
|
||||
the nurses and bookings their license covers. All of this rides the **desktop sidebar admin shell**
|
||||
established in [`frontend-phase-0`](./frontend-phase-0.md) (the third actor shell) and the existing
|
||||
`services/{domain}` + TanStack Query patterns. No new app-shell architecture; this phase fills the admin
|
||||
shell with real worklists and adds a **separately-scoped** partner portal.
|
||||
|
||||
The backoffice is a **read-and-act** surface over data other domains own. It does **not** re-implement
|
||||
verification logic, refund math, payout scheduling, moderation recompute, or config typing — those are
|
||||
**server authority** (b1/b6/b11/b13/b14). The client renders the contract's values and issues the
|
||||
sanctioned admin commands; it **never** computes eligibility, money decomposition, holiday shifts, or the
|
||||
`is_verified` flip on the client (§5).
|
||||
|
||||
**What already exists (do not rebuild) — link the prior phases:**
|
||||
- **f0 foundations** ([`frontend-phase-0`](./frontend-phase-0.md)): the three actor app shells + route
|
||||
groups, the **admin/backoffice desktop sidebar shell** (the shell this phase lives in), role-aware nav
|
||||
from `AuthContext`, the `services/{domain}` + TanStack Query caching pattern (`keys.ts` factory,
|
||||
`apis/clientApi.ts`, one-hook-per-file, hooks-only `index.ts`), the contracts→`types.ts` step, the
|
||||
**money/format util** in `src/utils/` (`formatIrrToToman`, integer-safe IRR-string parse, Shamsi date
|
||||
display), the shared composites (**status chip**, **stepper/progress header**, **price-breakdown**,
|
||||
cards), and the i18n namespace conventions (the `admin` namespace was reserved in f0 — fill it).
|
||||
**Reuse the money util, the status chip, and the price-breakdown — do not re-implement them.**
|
||||
- **f1-b2 auth** ([`frontend-phase-1-b2`](./frontend-phase-1-b2.md)): phone-OTP login, the role router,
|
||||
**roles in `AuthContext`**. Admins arrive authenticated with an admin role (`super_admin` / `admin` /
|
||||
`support` / `finance` / `moderator`); partner-center admins arrive with the **partner-center scope**.
|
||||
This phase **role-gates** every admin route and command off these roles — there is no separate admin
|
||||
login to build.
|
||||
- **f5-b6 nurse verification flow** ([`frontend-phase-5-b6`](./frontend-phase-5-b6.md)): the nurse-facing
|
||||
verification checklist, the per-step status enums, the `services/verification` types, the document
|
||||
uploader, and the trust badge. **The admin verification queue is the staff lens over the same
|
||||
`services/verification` domain** — extend it with the admin-review endpoints; reuse the step/status
|
||||
enums and the badge, do not fork them.
|
||||
- **f10-b11 refund & cancellation** ([`frontend-phase-10-b11`](./frontend-phase-10-b11.md)): the
|
||||
customer-side cancellation/refund-status UI, the policy-fee disclosure, the BNPL ETA display, and the
|
||||
refund money-display conventions. **The admin refund tool is the staff lens** — reuse the same
|
||||
fee/payout decomposition rendering and the money util; the admin **initiates/approves** refunds the
|
||||
customer can only watch.
|
||||
- **f12-b13 nurse earnings & payouts** ([`frontend-phase-12-b13`](./frontend-phase-12-b13.md)): the
|
||||
read-only nurse earnings/payout-history view, the four earnings states, the payout-status enum
|
||||
(`pending`/`processing`/`paid`/`failed`), the batch-detail shape with booking links. **The admin payout
|
||||
dashboard is the action surface** explicitly deferred from f12 — build the **batch preview → run →
|
||||
retry** flow here; reuse the payout shapes and money rendering.
|
||||
- **f13-b14 reviews & patient records** ([`frontend-phase-13-b14`](./frontend-phase-13-b14.md)): the
|
||||
`services/reviews` domain, the review shapes, the star/tag rendering. **The admin moderation queue is
|
||||
the staff lens** — add the `publish`/`hide`/`reject` moderation actions over the same domain.
|
||||
- **f14-b15 messaging & notifications** ([`frontend-phase-14-b15`](./frontend-phase-14-b15.md)): the
|
||||
`services/tickets` and `services/notifications` domains, the ticket thread, the `reference_code`
|
||||
rendering. **This phase layers the admin ticket lens** (the global queue, the **internal-note
|
||||
composer**, the refund-from-ticket entry) on top of `services/tickets`, and reuses the notification
|
||||
bell in the admin shell. The user-app types deliberately omit `isInternal`; the **admin** types include
|
||||
it (admins *see* internal notes) — keep the user/admin type surfaces distinct (§5).
|
||||
- **f9-b10 / f11-b12** money UI conventions (checkout, BNPL) — the Toman-display + IRR-string handling the
|
||||
admin money screens must match. **Reuse the single money util; do not fork a formatter.**
|
||||
- `clientFetch`/`serverFetch` + `ApiError`, the toast bridge (already toasts 401/403/5xx — do **not**
|
||||
re-toast those in hooks), the cookie manager, `APP_THEME_LTR/RTL`, `tokens.css`.
|
||||
|
||||
> **Backend readiness note.** The primary contract you consume,
|
||||
> [`messaging-notifications-admin.md`](../../contracts/domains/messaging-notifications-admin.md), is
|
||||
> produced by **backend-phase-b15** (admin ticket queue, support-alert worklist, partner centers,
|
||||
> RBAC role grants) and is the consolidation point for the admin endpoints. The other admin endpoints
|
||||
> live in their own domain contracts — verification (**b6**, [`verification.md`](../../contracts/domains/verification.md)),
|
||||
> refunds/invoices (**b11**, [`refunds.md`](../../contracts/domains/refunds.md)), payouts (**b13**,
|
||||
> [`payouts.md`](../../contracts/domains/payouts.md)), reviews (**b14**, [`reviews.md`](../../contracts/domains/reviews.md)),
|
||||
> config/holidays/audit (**b1**, [`config-reference.md`](../../contracts/domains/config-reference.md)).
|
||||
> If a shape you need is absent or wrong when you start, **do not guess and do not block** — append a
|
||||
> `REQ-NNN` request to
|
||||
> [`../../shared-working-context/frontend/requests/for-backend.md`](../../shared-working-context/frontend/requests/for-backend.md)
|
||||
> and mock behind the `services/admin` / `services/partnerCenter` seam meanwhile (operating-rules §6).
|
||||
> Record every mock in your report so it swaps cleanly once the endpoint lands.
|
||||
|
||||
## 2. Required reading (do this first)
|
||||
|
||||
**Operating rules & checklists**
|
||||
- [`../_shared/agent-operating-rules.md`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/frontend-conventions-checklist.md`](../_shared/frontend-conventions-checklist.md) — how
|
||||
you work, the gate, the contract/handoff lanes, the mock-then-swap rule (§6).
|
||||
- [`../_shared/definition-of-done.md`](../_shared/definition-of-done.md) — the bar this phase adds to (§6).
|
||||
|
||||
**Product / business truth (read before designing any screen)**
|
||||
- [`../../../product/business/14-notifications-and-admin.md`](../../../product/business/14-notifications-and-admin.md) —
|
||||
**the admin operational spine**: the five worklists (verification / refund / payout / support-alert /
|
||||
RBAC), the append-only audit trail, config-change auditing, in-app notifications, and that
|
||||
back-office must reason over the Shamsi calendar + `iranian_holidays`. This is *why* each console exists.
|
||||
- [`../../../product/business/13-tax-invoicing-and-legal.md`](../../../product/business/13-tax-invoicing-and-legal.md) —
|
||||
the **partner-center / merchant-of-record** model: the licensed center (پروانه تأسیس + مسئول فنی +
|
||||
نماد اعتماد الکترونیکی) sponsors nurses and **may be the merchant-of-record / invoice issuer**; the
|
||||
commission invoice (gross / platform commission / BNPL commission / VAT on commission) and the
|
||||
config-driven VAT rate. This drives the partner portal's settlement/invoice view and what it may show.
|
||||
- [`../../../product/data-model/12-audit-config-and-reference.md`](../../../product/data-model/12-audit-config-and-reference.md) —
|
||||
`audit_logs` (immutable, append-only, `changed_fields_json`), `platform_configs` (typed by `data_type`;
|
||||
the seeded keys — `platform_fee_rate`, `vat_rate`, `dispute_window_hours`, `nurse_payout_interval_days`,
|
||||
`evv_location_tolerance_meters`, `min_rating_for_support_alert`, cancellation-tier defaults, BNPL keys),
|
||||
and `iranian_holidays` (`holiday_date`, `name_fa`, `type`, `is_bank_closed`). These shape the config
|
||||
editor, the holiday manager, and the audit viewer.
|
||||
- [`../../../product/data-model/13-partner-centers-and-future.md`](../../../product/data-model/13-partner-centers-and-future.md) —
|
||||
`partner_centers` fields (`name`, `legal_entity_type`, `moh_establishment_permit_no`,
|
||||
`technical_director_nurse_user_id` + `technical_director_license_no`, `enamad_code`, `settlement_iban`
|
||||
(enc), `is_merchant_of_record`, `commission_rate`, `admin_user_id`, `is_active`, `verified_at`); the
|
||||
1:N relations to `nurse_profiles` / `bookings` / `invoices`; and the **deferred** tables
|
||||
(`organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`) that have **no
|
||||
UI** this phase.
|
||||
|
||||
> **No wireframe exists for the admin or partner screens** (the wireframe is the mobile customer/nurse
|
||||
> flow 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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Shared working context — the parallel-agent handoff
|
||||
|
||||
This folder lets a **backend agent** and a **frontend agent** work **at the same time** without ever
|
||||
editing the same file. It is the running, append-only record of what each side has done and what it
|
||||
needs from the other. (Stable API shapes live in [`../contracts/`](../contracts/README.md); this folder
|
||||
is the *running coordination* on top of them.)
|
||||
|
||||
## Lane ownership — the one rule that keeps it safe
|
||||
|
||||
> **Each lane writes only its own files. Neither lane edits the other's.**
|
||||
|
||||
| Lane | Writes (only) | Reads |
|
||||
| --- | --- | --- |
|
||||
| **Backend** | `backend/STATUS.md`, `backend/handoff/after-backend-phase-N.md` (new file per phase), `reports/backend-phase-N-report.md`, `reports/mocks-registry.md` | `frontend/requests/for-backend.md`, prior backend handoffs |
|
||||
| **Frontend** | `frontend/STATUS.md`, `frontend/requests/for-backend.md` (append), `reports/frontend-phase-N-report.md` | `backend/handoff/*`, `../contracts/*`, prior frontend reports |
|
||||
|
||||
Because each handoff is a **new file per phase** and each STATUS/requests file is **append-only**, two
|
||||
agents can run concurrently and only ever *append* — no merge conflicts, no clobbering.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
shared-working-context/
|
||||
├── backend/
|
||||
│ ├── STATUS.md # append: one block per backend phase (what shipped, gate status)
|
||||
│ └── handoff/
|
||||
│ └── after-backend-phase-N.md # "context for frontend after backend phase N" (one per phase)
|
||||
├── frontend/
|
||||
│ ├── STATUS.md # append: one block per frontend phase
|
||||
│ └── requests/
|
||||
│ └── for-backend.md # append: contract gaps / shape requests the backend should fulfil
|
||||
└── reports/
|
||||
├── README.md # the per-phase report template
|
||||
├── mocks-registry.md # master list of every mock/seam + how to make it real (backend-owned)
|
||||
├── backend-phase-N-report.md # what was built / testable / mocked (one per backend phase)
|
||||
└── frontend-phase-N-report.md # one per frontend phase
|
||||
```
|
||||
|
||||
## The handoff note (backend → frontend), per phase
|
||||
`backend/handoff/after-backend-phase-N.md` should answer, for the frontend agent:
|
||||
- Which endpoints/contracts are now **live** (link the `contracts/domains/*` doc + the swagger snapshot).
|
||||
- What the frontend can now build because of this phase.
|
||||
- What is **mocked** server-side (so the frontend knows responses are fake-but-shaped) and what's real.
|
||||
- Any auth/enum/format detail the frontend must mirror.
|
||||
- Open questions / things the frontend should *not* assume yet.
|
||||
|
||||
## The request note (frontend → backend)
|
||||
`frontend/requests/for-backend.md` is where the frontend appends: missing endpoints, fields it needs,
|
||||
shape mismatches, pagination/filter needs — anything that should land in a later backend change. The
|
||||
backend agent reads this at the start of each phase. The frontend never edits backend code to "fix" it.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Backend status log (append-only)
|
||||
|
||||
One block per completed backend phase. Newest at the top. Backend lane writes here; frontend reads.
|
||||
|
||||
<!-- TEMPLATE — copy for each phase
|
||||
## backend-phase-N — <Title> — <YYYY-MM-DD>
|
||||
- **Shipped:** <entities/endpoints/seams in one or two lines>
|
||||
- **Contracts:** dev/contracts/domains/<domain>.md + openapi snapshot refreshed (yes/no)
|
||||
- **Mocked:** <which seams> (see reports/mocks-registry.md)
|
||||
- **Gate:** build clean / tests green
|
||||
- **Handoff:** backend/handoff/after-backend-phase-N.md
|
||||
- **Notes for frontend:** <anything load-bearing>
|
||||
-->
|
||||
|
||||
_(no phases completed yet)_
|
||||
@@ -0,0 +1,15 @@
|
||||
# Frontend status log (append-only)
|
||||
|
||||
One block per completed frontend phase. Newest at the top. Frontend lane writes here; backend reads
|
||||
for awareness.
|
||||
|
||||
<!-- TEMPLATE — copy for each phase
|
||||
## frontend-phase-N-bM — <Title> — <YYYY-MM-DD>
|
||||
- **Shipped:** <screens/flows/services/hooks/components in one or two lines>
|
||||
- **Consumes:** dev/contracts/domains/<domain>.md (backend phase bM)
|
||||
- **Mocked client-side:** <which services are mocked pending which backend phase>
|
||||
- **Gate:** npm run check green / tests green
|
||||
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
|
||||
-->
|
||||
|
||||
_(no phases completed yet)_
|
||||
@@ -0,0 +1,15 @@
|
||||
# Frontend → Backend requests (append-only)
|
||||
|
||||
The frontend lane appends here when it needs a contract that doesn't exist yet, finds a shape mismatch,
|
||||
or needs a new field/filter/endpoint. The backend agent reads this at the start of each phase and
|
||||
delivers fixes in its own change. **Frontend never edits backend code to "fix" a gap — it requests it.**
|
||||
|
||||
<!-- TEMPLATE — copy per request
|
||||
## REQ-NNN — <short title> — filed by frontend-phase-N-bM — <YYYY-MM-DD>
|
||||
- **Need:** <endpoint / field / filter / shape>
|
||||
- **Why:** <which screen/flow needs it>
|
||||
- **Proposed shape:** <JSON sketch, optional>
|
||||
- **Status:** open | delivered in backend-phase-K
|
||||
-->
|
||||
|
||||
_(no requests yet)_
|
||||
@@ -0,0 +1,32 @@
|
||||
# Phase reports — template
|
||||
|
||||
Every phase writes a report here when it finishes (operating-rules §7, Definition of Done). The report
|
||||
is the durable answer to *"what did this phase do, what can I test now, and what's still fake?"* — saved
|
||||
to a file, not left in chat.
|
||||
|
||||
File name: `backend-phase-N-report.md` or `frontend-phase-N-bM-report.md`.
|
||||
|
||||
```markdown
|
||||
# <Backend|Frontend> Phase N — <Title> — Report (<YYYY-MM-DD>)
|
||||
|
||||
## What was built
|
||||
- Bullet list of concrete deliverables (entities/migrations/endpoints, or screens/services/components).
|
||||
|
||||
## What is now testable (and exactly how)
|
||||
- Step-by-step for a human: e.g. "Swagger → POST /api/v1/auth/otp/request with {phone} → 200; the OTP is
|
||||
logged to the console; POST /api/v1/auth/otp/verify with that code → returns access+refresh tokens."
|
||||
- For the frontend: which screen, which route, what to click, expected result, how mock data shows up.
|
||||
|
||||
## What is mocked / waiting on a real service
|
||||
- Each seam touched: interface + file, what's faked, and a link to its entry in `mocks-registry.md`.
|
||||
|
||||
## Contracts
|
||||
- Produced (backend): which `contracts/domains/*.md` + openapi snapshot.
|
||||
- Consumed (frontend): which contract/version; any request filed in `frontend/requests/for-backend.md`.
|
||||
|
||||
## Docs updated
|
||||
- Which CLAUDE.md / product doc / conventions were updated and why.
|
||||
|
||||
## Follow-ups for later phases
|
||||
- Anything intentionally deferred, with the phase that should pick it up.
|
||||
```
|
||||
@@ -0,0 +1,36 @@
|
||||
# Mock & integration registry
|
||||
|
||||
The master list of every external dependency that is **mocked behind a DI seam** in this build, and the
|
||||
exact steps to make each one real. Backend lane owns this file; every phase that introduces or touches a
|
||||
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
|
||||
"production with real providers".
|
||||
|
||||
Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live.
|
||||
|
||||
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — logs the code instead of sending | _tbd_ | Implement a Kavenegar/Ghasedak/SMS.ir client; keep idempotency + rate-limit | 🔴 |
|
||||
| `IObjectStorage` | backend-phase-0/6 | File storage — local/in-memory instead of object store | _tbd_ | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🔴 |
|
||||
| `ICacheService` | backend-phase-0 | Caching — in-memory dictionary | _tbd_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🔴 |
|
||||
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
|
||||
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
|
||||
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
|
||||
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
|
||||
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
|
||||
| `IBnplProvider` | backend-phase-12 | BNPL — drives state machine, fake settle/revert | _tbd_ | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🔴 |
|
||||
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 | _tbd_ | Config-driven per provider boundary | 🔴 |
|
||||
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout — fake transfer ref | _tbd_ | Jibit/Vandar/Sadad payout; source account; PAYA vs SATNA | 🔴 |
|
||||
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — seeded static table | _tbd_ | Iranian banking-holiday feed / sync job | 🔴 |
|
||||
| `IShahkarVerifier` | backend-phase-6 | Phone↔national-id match — fake pass | _tbd_ | Real Shahkar/KYC vendor; persist `external_response_json` | 🔴 |
|
||||
| `IIdentityKycProvider` | backend-phase-6 | National-ID + liveness — fake pass | _tbd_ | Finnotech/U-ID/Jibbit/Verify liveness+OCR | 🔴 |
|
||||
| `ICredentialVerifier` | backend-phase-6 | MoH/INO/criminal-record — manual/fake | _tbd_ | Manual admin today; API when a portal appears (`verification_method=api`) | 🔴 |
|
||||
| `IBankAccountOwnershipVerifier` | backend-phase-3/6 | استعلام شبا IBAN↔national-id — fake match | _tbd_ | Real KYC vendor; store `ownership_vendor_ref` | 🔴 |
|
||||
| `IGeocoder` | backend-phase-4 | Address→lat/lng — echo/static | _tbd_ | Neshan/Google geocoding | 🔴 |
|
||||
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
|
||||
| `IReviewModerationService` | backend-phase-14 | AI moderation — keyword/pass-through | _tbd_ | Real classifier/LLM endpoint | 🔴 |
|
||||
| `IFieldEncryptor` | backend-phase-0 | PII encryption — local symmetric key | _tbd_ | KMS / column encryption / Key Vault / HSM | 🔴 |
|
||||
| `INotificationDispatcher` | backend-phase-0/15 | Notification channels — in-app write only | _tbd_ | Add SMS/push (FCM); polling → Redis pub/sub or SignalR later | 🔴 |
|
||||
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 |
|
||||
|
||||
> Exact config keys and file paths get filled in by the phase that builds each seam. Keep the
|
||||
> "Make it real →" column actionable enough that a developer can pick up any single row and ship it.
|
||||
Reference in New Issue
Block a user