create mvp path

This commit is contained in:
hamid
2026-08-02 20:01:31 +03:30
parent 72ab290da1
commit fb58ca54e1
203 changed files with 863 additions and 156 deletions
+61
View File
@@ -0,0 +1,61 @@
# Balinyaar docs
The entry point. Start here, follow one link, stop reading.
> **Built by the clarify chain**, phases 07, 2026-07-29 → 2026-08-02. The chain's own plan — inventory,
> contradiction log, phase files, and their progress table — is now history, kept at
> [`archive/clarify-chain/`](../archive/clarify-chain/README.md). Phase 7 (skills & guardrails) is done:
> three playbooks live in [`.claude/skills/`](../.claude/skills/); the anti-drift convention below is
> enforced by review rather than a git hook (an MVP-stage call — see
> [decisions.md](status/decisions.md)).
---
## The rule that keeps three trees apart
| Tree | Answers | Example |
| --- | --- | --- |
| [`product/`](../product/index.md) | **What the business is** | escrow holds funds until check-out is confirmed |
| **`docs/`** (here) | **What we built, and how we work** | the escrow ledger is implemented; here is how to test it |
| `archive/` | **How we got here** | the phase-10 prompt that built the ledger, and its report |
**A file belongs in exactly one.** If you are about to write a business rule into `docs/`, it belongs in
`product/`. If you are about to obey something in `archive/`, stop — it is a record, not an instruction.
`archive/` holds the executed build-chain prompts, reports, and the pre-cleanup `docs/_plan/`. Start at
[`archive/README.md`](../archive/README.md).
---
## Map
| Section | What it holds | Status |
| --- | --- | --- |
| [rules/](rules/index.md) | What must never be broken — the tiered rule set behind the `CLAUDE.md` files | **written** · phase 1 |
| [integration/](integration/index.md) | The client↔server seam in one place: contract, config, topology, OpenAPI | **written** · phase 2 |
| [flows/](flows/index.md) | What is implemented, and how to test it — one file per user journey | **written** · phase 3 |
| [status/](status/index.md) | Where the project actually is: implemented, backlog, decisions | **written** · phase 4 |
| [roadmap/](roadmap/index.md) | Where it goes next, and what gates a launch | **written** · phase 5 |
## Still elsewhere
Two documents stay outside this tree on purpose:
- [`DEPLOY.md`](../DEPLOY.md) — the deploy *procedure*, at the repo root where an operator will look for
it. The runtime *topology* it implies moves to `docs/integration/topology.md`.
- [`product/`](../product/index.md) — untouched by this chain. `docs/status/implemented.md` overlays
build state onto its 14 business areas rather than restating them.
## Conventions
1. **Every status or flow doc carries `> Last verified: <date> against <commit>`.** A doc without one is
a claim, not a fact.
2. **Verify, don't copy.** A load-bearing claim is checked against code or run. If it cannot be checked,
it is written with an explicit `UNVERIFIED:` prefix.
3. **Write short.** A reference doc over ~400 lines should be split. One deliberate exception:
[flows/testing-setup.md](flows/testing-setup.md) is the single page you hand a new tester, and splitting
it would defeat that.
4. **English throughout**, including in files that describe Persian UI copy.
The full convention is in [docs/rules/documentation.md](rules/documentation.md) — enforced by review,
not tooling (an MVP-stage call; see [git-and-gates.md](rules/shared/git-and-gates.md)).
+154
View File
@@ -0,0 +1,154 @@
# Flow — account-and-settings
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** every signed-in actor (customer · nurse · admin · partner) · **Status:** partial
**Client:** partial · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
(§(a) as-built auth/session rules; the account-hub UI itself is a UI-phase-9 decision with no product-doc source
— see the coverage note in the business-area map)
**Integration:** [docs/integration/domains/profiles.md](../integration/domains/profiles.md) ·
[docs/integration/domains/auth.md](../integration/domains/auth.md)
## What it does
Each of the four shells ends in a settings hub: who you are signed in as, the handful of preferences the app
keeps (appearance, language, and — for a customer — name and emergency contact), the links out to
notifications and support, and the way out of the app. Since UI phase 9 the customer's `/fa/profile` is an
**account hub**, not a profile form: each section opens its own bottom sheet. Sign-out is the one action here
that reaches the server, and it revokes the session rather than just clearing cookies.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Customer hub | `/fa/profile` | [`(customer)/profile/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/profile/page.tsx) — `ProfileSummary` + rows; three `FormDialogShell` sheets (اطلاعات شخصی / زبان / مخاطب اضطراری) over **one** `react-hook-form`, because the wire upsert has no PATCH semantics (`:76-89`) |
| Nurse hub | `/fa/nurse/more` | [`NurseMoreScreen.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/more/NurseMoreScreen.tsx) — `ProfileSummary` (+ `TrustBadge`), support/notification hub rows, `SettingsPanel`, `SignOutRow` |
| Admin hub | `/fa/admin/system` | [`admin/system/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/admin/system/page.tsx) — the 6 system consoles (capability-filtered) **plus** identity/appearance/sign-out. Always shown: it is the only way out |
| Partner hub | `/fa/partner/more` | [`partner/more/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/partner/more/page.tsx) — center name + MoR chip, `SettingsPanel`, `SignOutRow` |
| Appearance + language | (in every hub) | [`SettingsPanel.tsx`](../../client/src/components/settings/SettingsPanel.tsx) → [`ThemeModeSetting`](../../client/src/components/settings/ThemeModeSetting.tsx) (روشن/تیره/سیستم) + [`LocaleSwitcher`](../../client/src/components/common/LocaleSwitcher/LocaleSwitcher.tsx). **The customer hub does not use `SettingsPanel`** — it inlines `ThemeModeSetting` (`page.tsx:155`) and puts `LocaleSwitcher` inside the زبان sheet (`:207`) |
| Actor switch | customer + nurse hubs | [`ActorSwitcher`](../../client/src/layout/components/ActorSwitcher.tsx) — renders **nothing** unless the session holds both `customer` and `nurse` (`:27`). No seeded demo account is dual-role |
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| identity for every hub | `GET /api/v1/me` | `MeController.cs:23`. The customer's first/last name is sourced from here by design |
| customer profile read | `GET /api/v1/customer_profiles/me` | `404 → null` (an empty form), not an error — `profiles/apis/clientApi.ts:19-26` |
| customer save (all 3 sheets) | `POST /api/v1/customer_profiles/upsert` | one call per sheet, always sending the **whole** profile — `clientApi.ts:60-75`. Names are written to the `users` row, not the profile (`UpsertCustomerProfileCommand.Handler.cs:45-57`) |
| nurse identity | `GET /api/v1/nurse_profiles/me` | supplies `avatarUrl` + verification state for the header |
| partner identity | `useMyPartnerCenter()` | **mocked**`USE_PARTNER_MOCK = true`, and the mock resolves `MOCK_MY_CENTER_ID = 1` for any caller |
| sign out | `POST /api/v1/auth/logout` | `AuthController.cs:48``LogoutCommand.Handler.cs` |
Request/response shapes: [profiles.md](../integration/domains/profiles.md), [auth.md](../integration/domains/auth.md).
### Does logout actually revoke server-side? **Yes — and it revokes everywhere.**
Traced: `SignOutRow.tsx:14` / `profile/page.tsx:269``useLogout()``authApi.logout({})`
`authClientApi.logout` (`auth/apis/clientApi.ts:47-52`) → `POST /api/v1/auth/logout`
[`LogoutCommand.Handler.cs:25-43`](../../server/src/Core/Baya.Application/Features/Identity/Commands/Logout/LogoutCommand.Handler.cs).
Both callers send an **empty body**, and the handler treats a missing `refreshToken` as
`Everywhere`: `RevokeAllActiveForUserAsync(...)` for every active session, then
`UpdateSecurityStampAsync`, which makes every outstanding JWE access token fail the bearer handler's
stamp check. Cookies are cleared in `onSettled` **regardless of the call's outcome**
(`useLogout.ts``clearAuthTokens()`), so an offline sign-out still ends the local session.
Proven server-side by the integration test `RefreshAndLogoutTests.cs:41-53`, which posts `{}` — the exact
client body — and asserts the follow-up `/me` returns `401`. **Not probed live on purpose:** calling
`/auth/logout` would revoke the shared pre-minted demo token for that account and break other testers.
## Rules that must hold
| Rule | Value / source |
| --- | --- |
| Sign-out kills **all** sessions, not just this device | `LogoutCommand.cs:12` — no `refreshToken``Everywhere`. Session TTL itself is CONFIG `auth_session_ttl_days` = 30 |
| Self-selectable roles are `customer` and `nurse` only | `RoleNames.SelfAssignable`; an admin sub-role self-assign is a **403**. The hub has no role-change affordance at all |
| Phones are masked on `/me`-style payloads | INV-21 — live `/me` returns `0912*****10`; `ProfileSummary` renders it as a `dir="ltr"` island |
| PII is encrypted at rest | INV-21 — the emergency contact name/phone are field-encrypted; only the masked/decrypted read comes back |
| Colors come from tokens, never a literal | [docs/rules/client/theme.md](../rules/client/theme.md); the no-flash boot is **CSS-only** (client hard rule 9) |
| `prefers-reduced-motion` has exactly one gate | `src/app/globals.css` (client hard rule 10) |
**Theme mechanics (no JS boot script).** `ColorSchemeCookieSync` (`theme/ThemeProvider.tsx:20-30`) writes the
*resolved* scheme to a `color-scheme` cookie; the root layout reads it (`getThemeMode`,
`lib/cookies/server.ts:42-50`) and stamps `data-mui-color-scheme` on `<html>` server-side
(`[locale]/layout.tsx:112,125`). With no cookie the attribute is omitted and `tokens.css`'s
`@media (prefers-color-scheme: dark)` fallback paints instead. `ThemeModeSetting` is the **only** subtree
subscribed to `useColorScheme()`.
## How to test
1. Log in as **09120000010** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/profile`. **Expect:** initials avatar, «سارا محمدی», masked `0912*****10`, a «تماس اضطراری»
card with a **green** check icon reading «بهرام محمدی · 09121110010», and rows for
اطلاعات شخصی / نشانی‌ها / زبان / نمایش / اعلان‌ها / پشتیبانی, then a red «خروج از حساب» row.
3. Tap the **نمایش** segments روشن → تیره. **Expect:** the whole app repaints immediately, no reload; reload
the page and the choice survives (the `color-scheme` cookie), with no flash of the wrong scheme.
4. Tap **زبان** → the globe button. **Expect:** the URL becomes `/en/profile` — the *same* page, not home.
The «زبان برنامه» select below it saves `preferredLanguage` to the server but **changes nothing visible**
(see gaps).
5. Tap **اطلاعات شخصی**, change the family name, save. **Expect:** a «ذخیره شد» toast and the header name
updates (the mutation invalidates `/me`).
6. Tap **خروج از حساب** → confirm «خروج از حساب؟». **Expect:** you land on `/fa/login`. Re-using that
account's old bearer token against `GET /api/v1/me` now returns `401`.
7. Log in as **09120000001** (زهرا عزیزی, nurse) and open `/fa/nurse/more`. **Expect:** name + a green
✓ تاییدشده `TrustBadge`, the نمایش/زبان panel, and a «خروج» button that signs out on **one tap, with no
confirmation** (unlike the customer hub).
8. `/fa/admin/system` as **09120000020**: the identity card renders, but the role label under the name is the
raw key `admin.role_super_admin` (see gaps). The six console rows render; every one of them 403s — that is
the [admin RBAC gap](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint), not this flow.
9. `/fa/partner/more`**type the URL**; nothing links to `/fa/partner`, and `09120000030` has no partner
role (`/me``["customer"]`). Whatever center name you see is mock data.
**Seeded-world caveat:** every seeded customer already has an emergency contact, so the 400 in the first gap
below **will not reproduce on a demo account**. To see it, sign in with a fresh phone, complete onboarding,
then edit only the name.
## Known gaps
- **A customer with no emergency contact cannot save their name or language.** `save()`
(`(customer)/profile/page.tsx:97-116`) always posts the whole profile, and
`UpsertCustomerProfileCommand.Validator.cs:9-13` requires `DefaultEmergencyContactName` non-empty and
`DefaultEmergencyContactPhone` to be a valid Iranian mobile. Verified live: `POST customer_profiles/upsert`
with empty emergency fields → **400** (`'Default Emergency Contact Name' must not be empty.`). Only
`saveEmergency` pre-validates (`:120-123`); `savePersonal`/`saveLanguage` do not.
- **That 400 is completely silent.** `useUpsertCustomerProfile` has no `onError`, the call site passes only
`onSuccess`, and `clientFetch` deliberately does not toast non-401/403/5xx 4xx
(`lib/api/client.ts:11`). The sheet just stays open — a client hard-rule-22 violation.
- **`preferredLanguage` is stored and never consumed.** It round-trips server-side
(`CustomerProfileRepository.cs:25`), but the only client reads are the form's own default and its save
(`profile/page.tsx:85,105`). The UI locale comes from the URL via next-intl; nothing reads the stored
preference at login or on the server. The زبان sheet therefore shows **two** language controls that do
different things.
- **Nurse avatars never load.** `LocalDiskObjectStorage.GetUrl` returns a `file://` URI (`:51`); live
`GET /nurse_profiles/me` for nurse 1 returns
`avatarUrl: "file:///C:/Users/.../avatars/nurse/1/....png"`, which a browser will not fetch from an
`http://` page. `ProfileSummary` degrades to the name's first letter. Affects the deployed `local`
provider too — only `Seams:ObjectStorage:Provider = s3` would emit a usable URL.
- **The admin hub renders a raw translation key as the role label.** `admin/system/page.tsx:79` calls
``ta(`role_${primaryRoleCode}`)``; `admin.role_super_admin` / `role_finance` / `role_support` /
`role_moderation` / `role_admin` exist in **neither** `messages/fa.json` nor `en.json`. No `onError`/
`getMessageFallback` is configured in `i18n/request.ts`, so next-intl renders the key path.
`npm run check` misses it — `check-copy.mjs` lints orthography and en/fa symmetry, not key existence.
- **The partner settings hub shows fabricated identity.** `USE_PARTNER_MOCK = true` and the mock resolves
`MOCK_MY_CENTER_ID = 1` for **any** caller, so the center name and the merchant-of-record chip are the same
for everyone. There is no real tenancy on this screen.
- **Sign-out confirmation is inconsistent.** The customer hub gates it behind a `ConfirmDialog`
(`profile/page.tsx:259-271`); nurse, admin and partner use `SignOutRow`, which fires on the first tap. One
mis-tap on `/fa/nurse/more` ends the session — and, per the handler, every other session too.
- **A customer cannot set an avatar.** `POST /api/v1/customer_profiles/avatar` is live but the client's
`uploadAvatar` targets the nurse route only (`profiles/apis/clientApi.ts:100-109`); the customer hub passes
`initialsFallback` and no `avatarUrl`. Already recorded in
[profiles.md](../integration/domains/profiles.md).
- **No notification-preference surface exists.** The «اعلان‌ها» row deep-links to the notification *centre*;
there is no per-channel opt-in/out anywhere in the app.
- **The nurse hub's support badge can never show a number.** `ticketsApi.getUnreadTotal` on the real path is
literally `async () => null` (`tickets/apis/clientApi.ts:223`, REQ-059), so `useSupportUnreadTotal()`
always returns `null` and `NurseMoreScreen.tsx:40` renders no badge.
- **The customer hub navigates with a hand-built locale prefix.** `profile/page.tsx:66,129` imports
`useRouter` from `next/navigation` and does ``router.push(`/${locale}${path}`)`` — client hard rule 13
requires `@/i18n/navigation`. Works today; breaks silently if `localePrefix` ever changes.
- **UNVERIFIED (no browser in this environment): a system-mode user may see one scheme flip after
hydration.** `getThemeMode` collapses the cookie to a concrete `defaultMode` of `dark`/`light`
(`lib/cookies/server.ts:47-48`), while `ThemeModeSetting` reads MUI's `mode`, which can still be
`'system'`. If the OS scheme changed since the last visit, the SSR paint and the post-hydration resolution
disagree. Code-traced only; not observed rendering.
+108
View File
@@ -0,0 +1,108 @@
# Flow — addresses and map
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
(`customer_addresses`) · pin purpose: [06-evv-and-service-delivery.md](../../product/business/06-evv-and-service-delivery.md)
**Integration:** [addresses.md](../integration/domains/addresses.md) · [geography.md](../integration/domains/geography.md)
## What it does
The family's address book: where the nurse is asked to come. Each address is a province → city →
(optional) district choice plus a free-text street line and a map pin; exactly one address is primary,
and the primary one is preselected on the C4 booking-request form. The pin is what the nurse's EVV
check-in is later measured against, so it is the reason the map exists at all.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/addresses` | [`(customer)/addresses/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/addresses/page.tsx) — `'use client'`, no `generateMetadata`. List / skeleton / `ErrorState` / `EmptyState`, add-edit `FormDialogShell`, delete `ConfirmDialog` |
| 1a | — | [`AddressCard.tsx`](../../client/src/components/geography/AddressCard.tsx) — title, «شهر · منطقه», street line, «آدرس اصلی» badge, «پین ثبت شده» / «پین ندارد» |
| 2 | dialog | [`AddressForm.tsx`](../../client/src/components/geography/AddressForm.tsx) — react-hook-form. Required: title, city, **pin**, street line. District optional |
| 2a | dialog | [`CascadingRegionSelect.tsx`](../../client/src/components/geography/CascadingRegionSelect.tsx) — three `TextField select`s; the empty district option is the explicit «کل شهر» choice (`:152`) |
| 2b | dialog | [`AddressMapPicker.tsx`](../../client/src/components/geography/AddressMapPicker.tsx) — **branches on `NESHAN_WEB_KEY` at `:43`**. Key set → `NeshanMap` (Leaflet + Neshan tiles, search box, locate-me, reverse-geocoded preview). Key unset → `GridFallbackMap` (`:78`) |
| 3 | `/fa/bookings/request` | C4 reads the book (`page.tsx:116`) and preselects the primary (`:183-184`). **Read-only** — no inline create; the empty state CTA routes to `/fa/addresses` (`:383`) |
| 3a | `/fa/profile` | account-hub row «مدیریت آدرس‌ها» → `/fa/addresses` |
### What a tester actually sees on the map
`client/.env.development` does **not** define `NEXT_PUBLIC_NESHAN_KEY` (the variable appears only in
`.env.production`, commented out, and in `.env.sample`). `config.ts:25` therefore resolves
`NESHAN_WEB_KEY = undefined`, so `AddressMapPicker` renders the **grid stand-in**: a 220 px
`--bal-primary-soft` panel with a 28 px CSS grid, no tiles, no search box and no locate-me button. Tapping
or dragging places a teardrop pin and prints «عرض» / «طول» to 5 decimals underneath. The canvas spans
±0.06° (~±6 km) around the chosen city's centroid (`SPAN`, `:65`). It is a real coordinate emitter, not a
decoration — the value it produces is what `create`/`update` send.
## API
Shapes belong to [addresses.md](../integration/domains/addresses.md) and
[geography.md](../integration/domains/geography.md) — not repeated here.
| Call | Endpoint | Notes |
| --- | --- | --- |
| `useAddresses` | `GET /api/v1/customer_addresses/list` | [`clientApi.ts:46`](../../client/src/services/addresses/apis/clientApi.ts) → `ListMyAddressesQuery`. **Probed** with `T_09120000010`: `total: 1`, `provinceId: 1`, `districtId: 1003`, pin `35.7595 / 51.41`, `postalCode`/`recipientName`/`recipientPhone` all populated |
| `useCreateAddress` | `POST /api/v1/customer_addresses/create` | [`CreateAddressCommand.Handler.cs:50-62`](../../server/src/Core/Baya.Application/Features/Addresses/Commands/CreateAddress/CreateAddressCommand.Handler.cs) — a sent pin wins (`GeocodeSources.UserPin`); only a *missing* pin calls `IGeocoder` |
| `useUpdateAddress` | `POST /api/v1/customer_addresses/update/{id}` | `UpdateAddressCommand.Handler.cs:62-74` — pin wins; re-geocodes only when city/district/line changed |
| `useSetPrimaryAddress` | `POST /api/v1/customer_addresses/set_primary/{id}` | demote + promote in one transaction; the client invalidates the whole list key |
| `useDeleteAddress` | `DELETE /api/v1/customer_addresses/delete/{id}` | soft delete behind the global query filter |
| `useProvinces` / `useCities` / `useDistricts` | `GET /api/v1/geo/{provinces,cities,districts}` | anonymous, `staleTime: Infinity`. **Probed**: 31 provinces; `province_id=1` → 1 city (101 تهران); `city_id=101` → 22 districts |
| — | `GET /api/v1/geo/tree` | live (**probed: 200**) but **unwired** — the client fetches the three levels separately |
| map search / reverse | `https://api.neshan.org/v1/search`, `/v5/reverse` | [`geography/neshan.ts`](../../client/src/services/geography/neshan.ts) — a deliberate **direct third-party `fetch`**, bypassing `clientFetch` so our bearer is never sent to Neshan (`:8-9`). Both return `null` immediately while the key is unset (`:29`) |
Server seam: `IGeocoder` = `MockGeocoder` (no `Seams:Geocoding:Provider` is set anywhere) — deterministic
jitter near the city centroid; an address line containing `NO_GEO` resolves to null coordinates.
## Rules that must hold
| Rule | Source |
| --- | --- |
| **`districtId = null` on an address is optional metadata** — it widens nothing. The same null on a *nurse service area* means whole-city (INV-3). Never coerce it to `0`. | [geography.md](../integration/domains/geography.md) · [nurse-service-areas.md](nurse-service-areas.md) |
| **The client-picked pin is authoritative** — the server does not re-geocode over it (REQ-008, delivered). | [addresses.md](../integration/domains/addresses.md) |
| **`latitude`/`longitude` are nullable** — null means "saved without a map pin", a display state, never an error. | [addresses.md](../integration/domains/addresses.md) |
| **`address_snapshot_json` freezes the address onto the booking** — editing or deleting an address never rewrites history. | [product/business/05](../../product/business/05-booking-and-scheduling.md) §Snapshots |
| **A booking request sees a city/district-coarse mask, not the line** — two-stage disclosure (INV-6). | [booking-request.md](booking-request.md) |
| **The pin feeds the EVV check-in distance test at `evv_location_tolerance_meters` = 200 m, advisory only** — a mismatch raises a support alert, never blocks or cancels. | [product/business/06](../../product/business/06-evv-and-service-delivery.md) |
| **`addressLine`/`postalCode`/`recipientName`/`recipientPhone` are encrypted at rest** and decrypted only for the owner (INV-21). | [product/business/01](../../product/business/01-actors-and-onboarding.md) |
| **Exactly one primary**; the first address saved is forced primary (`Handler.cs:42-43`). | server handler |
## How to test
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Go to `/fa/profile` → «مدیریت آدرس‌ها», or straight to `/fa/addresses`.
**Expect:** exactly **one** card — «خانه», «تهران · منطقه ۳», the ملاصدرا street line, the «آدرس اصلی»
badge and «پین ثبت شده».
3. Tap «افزودن آدرس». Pick استان تهران → شهر تهران. **Expect:** the city select holds exactly one option;
the district select then offers «کل شهر» plus 22 «منطقه ۱…۲۲» rows.
4. Submit with the pin untouched. **Expect:** the form blocks with «روی نقشه یک پین بگذارید» — the pin is a
required field (`AddressForm.tsx:131`).
5. Tap anywhere on the grid panel, then Save. **Expect:** a «آدرس ذخیره شد» toast, the list refetches, and the
new card shows «پین ثبت شده». Confirm server-side:
`curl -s --noproxy '*' "http://localhost:5002/api/v1/customer_addresses/list?page=1&pageSize=10" -H "Authorization: Bearer $T_09120000010"`
`total: 2` and non-null `latitude`/`longitude` on the new row.
6. Toggle «انتخاب به‌عنوان آدرس اصلی» on the new card. **Expect:** «آدرس اصلی به‌روزرسانی شد» and the badge
moves — never two badges.
7. Delete the address you just created. **Expect:** «آدرس حذف شد» and `total` back to 1.
8. Pick استان اصفهان instead in step 3. **Expect (and this is the defect):** the map panel re-centres on the
**Iran centroid** (32.4279, 53.688 — empty desert), not on Isfahan, because real Isfahan is city id `103`
while `CITY_CENTROIDS` only knows `301` (see gaps).
**Do not edit the seeded address id 1** — the edit dialog silently wipes its postal code and recipient
fields (see gaps). Create a throwaway address and edit that instead. The world is shared and 7 days stale,
but nothing in this flow depends on the aged booking data.
## Known gaps
- `NEXT_PUBLIC_NESHAN_KEY` is unset in `client/.env.development` and commented out in `.env.production`, so **`NeshanMap` never renders anywhere today** — every environment gets the keyless grid stand-in. The real map path (Leaflet, tiles, address search, locate-me, reverse-geocode preview) is entirely unexercised.
- `NESHAN_TILE_URL_TEMPLATE` (`geography/constants.ts:51`) and every response field name in `geography/neshan.ts` (`items[].location.x/y`, `formatted_address`) are **UNVERIFIED against the live Neshan API** — both files say so in their own headers. Nobody has ever run this code against `platform.neshan.org`. Setting a key may yield a blank tile layer and a silently empty search box.
- `CITY_CENTROIDS` (`geography/constants.ts:27-36`) is keyed on the **mock** seed's city ids (201 Mashhad, 301 Isfahan, 401 Shiraz, …). Live probe: real cities are `101`+`provinceId`-adjacent — Tehran `101`, Karaj `102`, Isfahan `103`. Only Tehran matches. For **every other city** `cityCentroid()` falls back to the Iran centroid, so the picker opens ~400 km from the customer and the ±6 km canvas can never reach their street.
- **Editing an address through the UI destroys data.** `AddressForm.submit` (`:91-102`) never collects `postalCode`, `recipientName` or `recipientPhone`; `clientApi.toBody` (`:21-34`) coerces the missing values to `null`; `UpdateAddressCommand.Handler.cs:57-59` assigns them unconditionally. Editing seeded address 1 nulls `1991834511` / «بهرام محمدی» / `09121110010`. Silent, no warning, not recoverable from the UI.
- Those same three fields are **never displayed anywhere** either — `AddressCard` shows title/region/line only, so a customer cannot see or set a recipient name for a visit that is not for themselves.
- The pin is a **required** form field, so the client can never take the create/update path that has no pin — `IGeocoder` is unreachable from the web app, and the `latitude == null` state (and `AddressCard`'s «پین ندارد» badge) is unreachable except via a direct API call or seeded data.
- Geography is seeded **one city per province** (31 provinces, 31 cities; probed `province_id` 2/3/5 → 1 city each) and districts exist **only for Tehran** (`city_id=103` → 0). Outside Tehran the cascade is a two-step formality and «کل شهر» is the only district choice.
- `GET /api/v1/geo/tree` is live and returns 200 but no client consumes it; the cascade makes three round trips instead of one.
- `AddressMapPicker`'s pin geometry is applied via inline `style` specifically to dodge the RTL stylis plugin — correct, but **fragile and untested for RTL drift**; `AddressMapPicker.test.tsx` only asserts the keyless fallback path.
- No `product/` doc describes address entry, the map-pin picker, or the Neshan integration. `business/01` merely lists the `customer_addresses` table; geography is documented **supply-side only** (`business/04` service areas). This flow's UI rules have no business source to check against.
+174
View File
@@ -0,0 +1,174 @@
# Flow — admin-backoffice
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** admin (`super_admin` · `admin` · `support` · `finance` · `moderation`) · **Status:** mocked
**Client:** partial (19 of 21 consoles on mocked seams, 2 on real) · **Server:** partial (real handlers, **403 for every seeded admin**)
**Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md)
**Integration:** [docs/integration/domains/admin.md](../integration/domains/admin.md)
## What it does
The single operator console behind Balinyaar: approve nurses, moderate reviews, run the weekly payout batch,
issue refunds, triage support alerts and tickets, edit the platform's runtime rates, and read the append-only
audit trail. It is the only surface where a human authorises money movement — a scheduled job may *generate* a
draft payout batch, but `process` is always an explicit admin action.
## ⚠ Read this first — the console cannot reach the server
**Every `[Authorize(ConstantPolicies.DynamicPermission)]` endpoint returns `403` for both seeded admin
accounts** (`09120000020` super_admin, `09120000021` finance). 23 controllers carry that policy. The chain:
| Link | file:line | What it does |
| --- | --- | --- |
| the gate | [`DynamicPermissionService.cs:9`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/PermissionManager/DynamicPermissionService.cs) | `if (user.IsInRole("admin")) return true;` — the **literal** string, not "any admin sub-role" |
| the fallback | same file `:15-19` | else requires a `DynamicPermission` claim whose value is exactly `"{area}:{controller}:"` |
| the vocabulary | [`RoleNames.cs:13-17`](../../server/src/Core/Baya.Domain/Entities/User/RoleNames.cs) | `Admin` · `Support` · `Finance` · `Moderation` · `SuperAdmin` are five **sibling** roles — `super_admin` is not a superset of `admin` |
| the seeder | [`DemoWorldDefinitions.cs:144-145`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs) | grants `RoleNames.SuperAdmin` / `RoleNames.Finance`**never** `RoleNames.Admin` |
| the missing half | [`SeedDataBase.cs:50-71`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs) | the only code path that calls `AddToRoleAsync(user, "admin")`, and it returns early unless `Seed:AdminUsername` **and** `Seed:AdminPassword` are configured. Neither is in `appsettings.Development.json` |
No code anywhere writes a `DynamicPermission` claim, so branch 2 can never fire either. **No seeded account
satisfies either branch.**
**Why this is invisible in the UI:** `USE_ADMIN_MOCK = true`
([`admin/constants.ts:9`](../../client/src/services/admin/constants.ts)), plus `verification`, `payouts`,
`refunds` and `partnerCenter` are all mock-primary — so 19 of the 21 consoles render a complete, filterable,
mutable world out of in-browser fixtures and never call the API at all. The console *looks* built.
Live-probed 2026-08-02 (`super_admin` token unless noted):
| Probe | Result |
| --- | --- |
| `GET /api/v1/me` | `200` · `{"id":6,"roles":["super_admin"]}` — the token is fine |
| `GET /api/v1/tickets?page=1` (user scope, same token) | `200`, 9 tickets — non-admin endpoints work normally |
| `GET /api/v1/admin/tickets?page=1` | **`403`** `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}` |
| `GET /api/v1/admin/reviews/moderation_queue?page=1` | **`403`** |
| `POST /api/v1/platform_config/update_platform_config` (valid body) | **`403`** — writes are blocked at authz, *before* validation. Nothing mutates |
| `GET /api/v1/admin_payouts/batches` (**finance** token) | **`403`** |
## Screens
21 routes, all inside the 480 px phone frame. `RoleGuard expected=admin` admits any of the five codes;
`useAdminCapabilities()` ([`hooks/capabilities.ts:43`](../../client/src/hooks/capabilities.ts)) then hides
group tabs and rows per code — **a UI hint only**, the consoles stay URL-reachable.
| Group | Route | Client seam | Notes |
| --- | --- | --- | --- |
| — | `/fa/admin` | none | Overview hub; `NavHubList` filtered by capabilities |
| اعتماد | `/fa/admin/trust` | none | Group root |
| | `/fa/admin/verification` · `/…/[nurseId]` | `verification` **MOCK** | Queue folded per-nurse; case view with `DocumentViewer` re-signing its own URL |
| | `/fa/admin/reviews` | `reviews` **REAL** | 4 moderation tabs → **403s live** |
| مالی | `/fa/admin/finance` | none | Group root |
| | `/fa/admin/payouts` · `/…/[batchId]` | `payouts` **MOCK** | Dry-run preview → idempotency-keyed run; `gross clawback = net` |
| پشتیبانی | `/fa/admin/support` | none | Group root |
| | `/fa/admin/tickets` · `/…/[id]` | `tickets` **REAL** (+ `refunds` MOCK via `RefundPanel`) | Internal-notes composer → **403s live** |
| | `/fa/admin/alerts` | `admin` **MOCK** | `support_alerts` triage; assign-to-self, resolve with note |
| سیستم | `/fa/admin/system` | `auth` | Always shown — carries sign-out |
| | `/fa/admin/config` | `admin` **MOCK** | Typed editor per `data_type`; rate keys validated to `[0, 1)` |
| | `/fa/admin/holidays` | `admin` **MOCK** | `iranian_holidays` + the `is_bank_closed` flag |
| | `/fa/admin/audit` | `admin` **MOCK** | Read-only; rows expand to the `changed_fields` diff |
| | `/fa/admin/partners` · `/…/[id]` | `admin` + `partnerCenter` **MOCK** | IBAN write-then-masked, never displayed |
| | `/fa/admin/users` | `admin` **MOCK** | Backed by `admin_users/search`**route does not exist** |
| | `/fa/admin/roles` | `admin` **MOCK** | RBAC grid; in-page banner says it is deferred |
| | `/fa/admin/notifications` | none | `PlaceholderScreen` stub; **true orphan**, nothing links to it |
## API
Shapes belong to the integration tree — do not restate them here.
| Console | Endpoints | Contract |
| --- | --- | --- |
| config · holidays · audit · alerts | `platform_config/*`, `holidays/*`, `audit/get_audit_trail`, `support_alerts/*` | [admin.md](../integration/domains/admin.md) |
| verification queue + case | `admin_verifications/*` | [verification.md](../integration/domains/verification.md) |
| payout batches | `admin_payouts/*` | [payouts.md](../integration/domains/payouts.md) |
| refunds (in the ticket thread) | `admin_refunds/*` | [refunds.md](../integration/domains/refunds.md) |
| review moderation | `admin/reviews/moderation_queue` | [reviews.md](../integration/domains/reviews.md) |
| ticket queue + admin thread | `admin/tickets`, `admin/tickets/{id}` | [tickets.md](../integration/domains/tickets.md) |
| partner centers | `admin/partner-centers*` | [partner-center.md](../integration/domains/partner-center.md) |
| **roles · user directory** | `admin_roles/*`, `admin_users/*` | **phantom — not on the wire** (REQ-031 deferred; REQ-061 never filed) |
Client chain, verified link by link for the config console: `admin/config/page.tsx``usePlatformConfigs()`
([`services/admin/hooks/usePlatformConfigs.ts`](../../client/src/services/admin/hooks/usePlatformConfigs.ts))
`adminApi` ([`apis/index.ts:10`](../../client/src/services/admin/apis/index.ts), the one-line selector,
currently resolving to `adminMockApi`) → `adminClientApi.listPlatformConfigs`
([`apis/clientApi.ts:75`](../../client/src/services/admin/apis/clientApi.ts)) → `clientFetch`
`GET /api/v1/platform_config/get_platform_configs``PlatformConfigController``ListPlatformConfigs`
handler. Every link exists. Only the selector and the authz gate stand in the way.
## Rules that must hold
| Rule | Value | Source of truth | Product |
| --- | --- | --- | --- |
| Money movement stays human-approved | the scheduler opens a **draft** batch; `process` is an explicit admin action | `WeeklyPayoutGenerationJob` + `ExecutePayoutBatch` | [10-payouts.md](../../product/business/10-payouts.md) |
| Config is rows read at compute time, and a rate change is **never retroactive** | rates snapshotted onto the row (`Bookings.PlatformFeeRate`, `Invoices.VatRate`) | CONFIG via `IPlatformConfig` | [14-…-admin.md](../../product/business/14-notifications-and-admin.md) |
| Commission / VAT | `platform_fee_rate = 0.15`, `vat_rate = 0.10`, VAT on the **commission line only** | CONFIG keys (0.15 is a seeded default, not a product mandate) | [13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md) |
| Rate keys are in `[0, 1)` | console validates before writing | `RATE_CONFIG_KEYS`, `admin/constants.ts:27` | — |
| Holidays shift the payout date | server resolves the next business day; **the client never computes a shift** | `IHolidayCalendar` ROWS | [10-payouts.md](../../product/business/10-payouts.md) |
| Audit trail is append-only | no edit/delete affordance; retention `730` d general / `2555` d financial | CONFIG `audit_retention_*_days` | — |
| `is_internal` never leaves the query layer | admin thread is the *only* surface that carries internal notes | `TicketRepository` projections | [12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md) |
| Refunds are admin-only and ticket-anchored | no customer self-service; every refund splits across both fee legs | `AdminRefundsController`, `CK_Refunds_LegSplit` | [07-cancellation-and-refunds.md](../../product/business/07-cancellation-and-refunds.md) |
| Low-rating alert threshold | `≤ 2` | CONFIG `min_rating_for_support_alert` | [11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
| Publishing a review recomputes the nurse aggregate **server-side** | on every status transition | review moderation handlers | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
| Suspending a nurse flips `is_searchable = 0` on every one of her rows | rows are kept, never deleted | `SearchIndexMaintainer.cs:177,248` | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
## How to test
1. Log in as `09120000020` (نگار مدیری, super_admin) — see [testing-setup.md](testing-setup.md).
**Expect:** `RoleGuard` admits you and the app lands on `/fa/admin` with all four group tabs visible.
2. Walk `/fa/admin/config``/fa/admin/holidays``/fa/admin/audit``/fa/admin/alerts`.
**Expect:** each renders a populated, filterable console; edits appear to save.
**This proves nothing about the server** — these are `adminMockApi` fixtures held in module state, and they
reset on every page reload or HMR. Open devtools Network: **there is no request**.
3. Open `/fa/admin/tickets` and `/fa/admin/reviews` — the two consoles on real seams.
**Expect:** an error state, not a queue. `clientFetch` toasts the 403 itself.
4. Log in as `09120000021` (کامران مالی, finance) and open `/fa/admin`.
**Expect:** «اعتماد» and «پشتیبانی» tabs are gone; only «مالی» and «سیستم» remain. `/fa/admin/payouts`
renders a mock batch list. `/fa/admin/audit` is hidden (audit is `admin`/`super_admin` only) but still
URL-reachable.
5. Confirm the gap directly, without the browser:
`curl -s --noproxy '*' "http://localhost:5002/api/v1/platform_config/get_platform_configs?page=1" -H "Authorization: Bearer $T_09120000020"`
**Expect:** `403` `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}`, while
`GET /api/v1/me` with the same token returns `200`.
**Break-glass (UNVERIFIED — not executed for this stamp):** add `"Seed": { "AdminUsername": "…",
"AdminPassword": "…" }` to `appsettings.Development.json` **before boot**. `SeedDataBase.SeedBootstrapAdminAsync`
then mints a user in the literal `admin` role, which satisfies branch 1. That account is
**username/password, not phone-OTP**, so it cannot log in through the web UI — drive the API directly. It was
not tested here because it needs a server restart shared with other agents.
**Seeded-world caveat:** the demo world is 7 days stale (see
[testing-setup.md](testing-setup.md#-it-has-aged-out--and-this-is-not-cosmetic)). Even with the RBAC gap
fixed, the verification queue has no `in_review` step awaiting a decision beyond nurse 3's two blocking steps,
and every dispute window has closed — so a live payout preview would sweep bookings you did not stage.
## Known gaps
- **`DynamicPermissionService.CanAccess` grants only on the literal role `"admin"`.** The four sibling admin
roles — `super_admin`, `support`, `finance`, `moderation` — get 403 on all 23 `DynamicPermission`
controllers. `DynamicPermissionService.cs:9`. A `super_admin` has *less* access than an `admin`.
- **No seeded account holds the literal `admin` role.** `DemoWorldDefinitions.cs:144-145` grants
`super_admin`/`finance`; `SeedDataBase.cs:55-56` returns early because `Seed:AdminUsername`/`AdminPassword`
are unset. The entire backoffice is untestable end-to-end out of the box.
- **No code path ever writes a `DynamicPermission` claim**, so the per-controller fallback branch
(`DynamicPermissionService.cs:15-19`) is dead — the claim key `"{area}:{controller}:"` has no producer.
- **The 403 is invisible in the UI.** `USE_ADMIN_MOCK = true` plus mocked `verification`/`payouts`/`refunds`/
`partnerCenter` means 19 of 21 consoles never call the API. A reviewer clicking through the console concludes
it works.
- **`/fa/admin/tickets` and `/fa/admin/reviews` are broken for the operator right now** — real client seams
onto 403ing controllers. The only two consoles where the defect surfaces.
- **`admin/apis/clientApi.ts` `pageQuery()` sends `page_size`; the controllers declare `PageSize`.** Model
binding is case-insensitive, not separator-insensitive, so every admin list silently falls back to the
server default page size the moment `USE_ADMIN_MOCK` flips.
- **`/fa/admin/roles` has no server** — `admin_roles/list_roles|grant_role|revoke_role` are phantom (REQ-031
deferred). Admin roles are seeded, never managed.
- **`/fa/admin/users` has no server** — `admin_users/search|lookup` are phantom, and **REQ-061 was never filed
in the ledger** despite ten client files citing it. `AuditLogRow` shows `#id` instead of a name.
- **`/fa/admin/notifications` is a `PlaceholderScreen` stub and a true orphan** — no link reaches it, and
`AdminLayout` renders no `NotificationBell`.
- **`POST /api/v1/holidays/delete_holiday` is unwired** — the console offers no delete affordance.
- **`admin_cancellation_policies/list|upsert` are unwired** — no screen edits the cancellation tiers, so the
seeded `standard_24h`/`standard_inside_24h` rows are effectively read-only in production.
- **`admin_search/rebuild_index` and `admin_booking_requests/expire` have no UI** — ops one-shots reachable
only by curl (and both 403 for a seeded admin).
- **`useAdminCapabilities` hides tabs but does not block routes.** Every console stays URL-reachable for any
admin code; the enforcement is expected to be server-side, which is currently a blanket 403.
+148
View File
@@ -0,0 +1,148 @@
# Flow — auth-login-otp
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** everyone (guest → customer / nurse / admin / partner owner) · **Status:** built
**Client:** real · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [docs/integration/domains/auth.md](../integration/domains/auth.md) ·
[api-contract.md](../integration/api-contract.md)
## What it does
Phone number plus a six-digit SMS code is the only way into Balinyaar — there are no passwords. A verified
phone mints a rotating refresh-token session; `/me` then says who you are, and the client sends you to the
family app, the nurse app, the admin console, or a first-use role picker. A deep link you were bounced off
survives the round trip.
**This is the one flow verified end to end live.** Every status code below was observed on
`http://localhost:5002` at the stamp date, not inferred.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Bounce | any private route | [`middleware.ts:44-55`](../../client/middleware.ts) → `/{locale}/login?next=<path>` |
| Guest front door | `/fa` (exact) | `middleware.ts:35-37`**rewrite** to `/fa/welcome`, never a redirect; URL stays `/` |
| A1 / B1 phone | `/fa/login` | `login/page.tsx``LoginScreen` → [`LoginFlow`](../../client/src/components/auth/LoginFlow.tsx) → `PhoneStep`. `?role=nurse` seeds the intent; the switch link toggles copy only — **one login tree, not two** |
| A2 / B2 code | `/fa/login` | [`OtpStep.tsx`](../../client/src/components/auth/OtpStep.tsx) — 6 boxes, auto-verify on the last digit, resend countdown, `useWebOtp` autofill (REQ-039: never fires, template not conformant) |
| Routing splash | `/fa/login` | [`RoleRouter.tsx`](../../client/src/components/auth/RoleRouter.tsx) — renders `AuthSplash` while `/me` is in flight so the wrong shell never flashes |
| First-use role pick | `/fa/select-role` | [`SelectRole.tsx`](../../client/src/components/auth/SelectRole.tsx). Deliberately outside every actor group and carries **no `RoleGuard`** — this page is what resolves the role |
| Sign out | `/fa/profile`, `/fa/nurse/more`, `/fa/admin/system`, `/fa/partner/more` | `SignOutRow``useLogout` |
## API
Shapes live in [domains/auth.md](../integration/domains/auth.md) — not restated here. Client seam
[`services/auth/apis/index.ts:10`](../../client/src/services/auth/apis/index.ts), `USE_AUTH_MOCK = false`
([constants.ts:6](../../client/src/services/auth/constants.ts)) — **real**.
| Call | Endpoint | Observed | Notes |
| --- | --- | --- | --- |
| `useRequestOtp` | `POST /api/v1/auth/request_otp` | **200** | anon · `otp` policy. Returns `codeLength: 6`, `expiresInSeconds: 60`, `resendAvailableInSeconds: 120` |
| — (dev only) | `GET /api/v1/dev/last_otp/{phone}` | **200** | the only way to read a code — see [testing-setup.md](testing-setup.md#getting-an-otp) |
| `useVerifyOtp` | `POST /api/v1/auth/verify_otp` | **200** | anon · `otp` policy. Returns `accessToken` (5-part JWE), `refreshToken`, `accessExpiresAt`, `refreshExpiresAt`, `isNewUser`, `roles` |
| `useMe` | `GET /api/v1/me` | **200** | phone comes back **masked** — first four + last two, so `09120000010``0912*****10` (`IdentityDefaults.MaskPhone`); carries `roles`, `hasNurseProfile`, `nurseVerificationStatus` |
| `useSelectRole` | `POST /api/v1/me/select_role` | **200** (already held → idempotent) · **403** (`admin`) | 403 message: `"Only the customer or nurse role can be self-selected."` Returns **`Me`, not tokens** — see the rotation note below |
| `useRefresh` / fetch layer | `POST /api/v1/auth/refresh` | **200** rotated · **401** on replay | `auth` policy 10/min; called by both `services/auth` **and** [`lib/api/refresh.ts`](../../client/src/lib/api/refresh.ts) |
| `useLogout` | `POST /api/v1/auth/logout` | wired (not probed) | empty envelope — the client awaits it and never `unwrap()`s |
Chain confirmed link by link: hook → [`apis/clientApi.ts:24-62`](../../client/src/services/auth/apis/clientApi.ts)
→ [`lib/api/client.ts:34`](../../client/src/lib/api/client.ts) → `AuthController` / `MeController`
`Baya.Application/Features/Identity/**`.
> **`select_role` does not rotate anything server-side.** `SelectRoleCommandHandler` grants the role and
> returns `MeResult` — no token, no security-stamp bump (verified: the 200 body carries no `accessToken`).
> The rotation is a *client-side follow-up*: [`useSelectRole.ts`](../../client/src/services/auth/hooks/useSelectRole.ts)
> fires a second `POST /auth/refresh` on success, because role claims live inside the JWE. So the one
> user action is **two** calls, and a failed rotation is swallowed by design.
## Rules that must hold
| Rule | Value | Source |
| --- | --- | --- |
| OTP length / validity | 6 digits · **60 s** | `IdentityDefaults.cs:26,30` |
| Resend window | 120 s per phone | `platform_configs.auth_otp_resend_seconds` |
| Wrong attempts before lockout | **5**, then envelope `code: "otp_locked"` + `retryAfterSeconds` | a **config row**, not a constant: `platform_configs.auth_otp_max_attempts` (seeded `5``PlatformConfigConfig.cs:44`), read at compute time by `VerifyOtpCommand.Handler.cs:36-44` |
| Rate limit — **`request_otp` AND `verify_otp` share one policy** | 5 / 60 s **per IP** | `RateLimitingServiceExtension.cs:52`; `AuthController.cs:26,32` |
| Refresh rotation | every refresh revokes the presented session and mints a new pair | `RefreshTokenCommand.Handler.cs:61` |
| Reuse = theft | replaying a revoked token revokes **every** active session for that user and returns 401 | `RefreshTokenCommand.Handler.cs:35-47`; [business §as-built](../../product/business/01-actors-and-onboarding.md) |
| Self-assignable roles | `customer`, `nurse` only; any admin sub-role → **403** | `RoleNames.SelfAssignable`; `SelectRoleCommand.Handler.cs:23-24` |
| No account enumeration | wrong phone, wrong code and expired code all collapse to one message | `VerifyOtpCommand.Handler.cs:24,53-59` |
| `/me` is the only identity source | the JWE is opaque; never decode a claim client-side | [`token.ts:44-58`](../../client/src/lib/auth/token.ts) |
| `?next=` is not an open redirect | same-origin relative **and** role-owned, else fall through | [`routing.ts:49-82`](../../client/src/services/auth/routing.ts); `routing.test.ts` has **17** tests, **7** of them on this guard |
| Phone lookup is by HMAC hash | `users.PhoneHash` from `Seams:FieldEncryption:HashKey` — immutable | [testing-setup.md](testing-setup.md#the-four-crypto-values-and-why-they-are-load-bearing) |
## How to test
1. Log in as **`09120000010`** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
Boot the API with `Seams__Sms__Provider=mock` or step 3 will 500.
2. Open `http://localhost:3000/fa/nurse/visits` while signed out.
**Expect:** a 307 to `/fa/login?next=/nurse/visits`.
3. Enter the phone, submit. **Expect:** the six-box code screen with «۰۲:۰۰» counting down.
4. `curl --noproxy '*' http://localhost:5002/api/v1/dev/last_otp/09120000010` → paste the code.
**Expect:** auto-verify on the sixth digit, the branded splash, then `/fa` (the family home) — **not**
`/fa/nurse/visits`, because a customer does not own that path. This is the `?next=` guard working.
5. Repeat step 24 as **`09120000001`** (زهرا عزیزی, nurse) with `?role=nurse`. **Expect:** you land on
`/fa/nurse/visits` — the same deep link now survives.
6. Enter a wrong code five times. **Expect:** «کد وارد شده صحیح نیست» each time, then the lockout copy
(`otp_locked`) with resend still enabled — resend is the way out (`OtpStep.tsx:94-95`).
7. Role picker: no seeded account is role-less, so `/fa/select-role` cannot be reached naturally. Visit it
directly while signed in. **Expect:** two cards, «خانواده» pre-selected; «ادامه» calls `select_role`,
*then* a second `/auth/refresh` from the client to pick up the new claim, and routes. Selecting a role
you already hold is a safe no-op (verified live: `{"role":"customer"}` on `09120000010`**200**).
8. Admins: `09120000020` logs in fine and `/me` returns 200, but **every admin endpoint then 403s** — the
console renders on `USE_ADMIN_MOCK` data. See [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
9. `09120000030` (partner owner) has `/me` roles `["customer"]` only — you land on the family home and must
navigate to `/fa/partner` by hand.
**Live rotation/reuse walk (executed):** `refresh(RT1)`**200**, new token differs. Replay `RT1`
**401** `"Refresh token is no longer valid. Sign in again."` Then `refresh(RT2)` — the *legitimate* token
issued moments earlier — also → **401**. The whole session family dies, exactly as the business doc
specifies. The access token minted before all of that still returned **200** from `/me` afterwards.
## Known gaps
- `POST /auth/request_otp` **500s on a fresh clone**: the committed `Seams:Sms:Provider` is `telegram` with
nothing on `:5010`. Login is unreachable in the browser until you boot with `Seams__Sms__Provider=mock`.
- **Reuse detection cannot tell theft from two tabs.** The single-flight guard in
`lib/api/refresh.ts:17` is module-level, so it is per-tab. Two tabs 401ing at once both POST the same
cookie value; the second is classified as reuse and **every session dies** — a silent full logout with no
explanation. Verified live: the freshly-rotated `RT2` was already dead.
- **`useSelectRole`'s rotation bypasses the single-flight guard.** It calls `authApi.refresh` directly,
not `attemptTokenRefresh`, so its `/auth/refresh` is not coalesced with a concurrent 401-retry from
`clientFetch`. Two refreshes racing on the same cookie value is exactly the reuse pattern above — the
loser kills every session. Narrow window (first-run role pick only), but it is the same defect twice.
- **Session revocation does not kill access tokens.** `RefreshTokenCommandHandler` revokes sessions but
never rotates the security stamp (contrast `LogoutCommand.Handler.cs:42`, which does). Verified: after
reuse-detection revoked all sessions, `GET /me` with the pre-existing access token still returned **200**.
"Logged out everywhere" is up to 60 minutes late (`IdentitySettings:ExpirationMinutes: "60"` in
`appsettings.json` / `appsettings.Development.json` — a config value, not a code constant).
- **The access cookie expires 45 minutes before the token does.** `AUTH_ACCESS_COOKIE_OPTIONS.maxAge = 900`
(`lib/cookies/constants.ts:24`) vs a 60-minute server token. After 15 idle minutes any full page load hits
the middleware with no cookie and is bounced to `/login`, even though the 7-day refresh cookie could have
recovered the session. The API path self-heals via `clientFetch`'s 401 retry; the navigation gate does not.
- **A 429 on `verify_otp` is shown to the user as "wrong code."** `OtpStep.tsx:134-137` renders
`otp_invalid` for *any* verify error, and a rate-limited `verify_otp` returns an **empty body** so there is
no `code` to branch on. `PhoneStep.tsx:50` handles 429 correctly — `OtpStep` does not.
- **The client ignores the server's OTP metadata.** `request_otp` returns `codeLength` and
`expiresInSeconds` (REQ-002, delivered) but `RequestOtpResult` (`services/auth/types.ts:37-40`) declares
neither, and `OTP_CODE_LENGTH = 6` is hardcoded. The 60-second code expiry is never shown at all — the
only timer on screen is the 120-second resend cooldown, so an expired code just looks wrong.
- Stale comments in `services/auth/constants.ts:15-28` claim the live server exposes no code length and no
machine-readable failure code. It exposes both (`codeLength`, `otp_locked`, `otp_invalid`).
- `GET /api/v1/dev/last_otp/{phone}` is **live on `api.balinyaar.ir`** — anyone who knows a registered phone
can read its login code. Recorded in [DEPLOY.md](../../DEPLOY.md); the fix is the environment switch.
- **A signed-in user visiting `/fa/login` sees the phone form again.** `/login` is in `PUBLIC_PATHS`, so the
middleware skips the gate and never redirects an authenticated visitor away (contrast `/welcome`, which
does redirect — `middleware.ts:40-42`).
- **A `?next=` pointing at `/partner/...` never survives login.** `appRoleForPath` returns `null` for the
partner tree (`routing.ts:60`), so the deep link is always discarded. Related: REQ-038 — `/me` carries no
signal that the caller administers a partner center.
- **`useLogout` always signs out every device.** It posts `{}`, and an absent `refreshToken` means
"everywhere" (`LogoutCommand.Handler.cs:25-28`). Signing out on a phone kills the desktop session too;
no UI offers the choice.
- `resolveRoleDestination` checks admin before customer/nurse (`routing.ts:34`), so a user who holds both an
admin sub-role and `customer` can never reach the family app from login.
- REQ-039 open: the OTP SMS template is not WebOTP-conformant, so `useWebOtp` ships but never fires.
- No seeded account is role-less, so the `/fa/select-role` screen has **no natural path to it** in the demo
world — it can only be tested by navigating directly.
+118
View File
@@ -0,0 +1,118 @@
# Flow — BNPL installments
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer · **Status:** mocked
**Client:** mock · **Server:** partial
**Business source:** [product/business/09-installments-bnpl.md](../../product/business/09-installments-bnpl.md) · [product/payments/bnpl-landscape.md](../../product/payments/bnpl-landscape.md)
**Integration:** [docs/integration/domains/bnpl.md](../integration/domains/bnpl.md)
## What it does
The second checkout rail off C6: instead of paying by card, the family finances the booking through a BNPL
provider (SnappPay, Digipay, …). The provider pays Balinyaar **one full lump, net of its own commission**,
and carries the customer's installments itself — Balinyaar finances nothing and tracks no repayments. From
the booking's point of view a settled BNPL order is identical to a card payment that landed net-of-fee.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| CTA | `/fa/bookings/checkout` | «پرداخت اقساطی» button, [`checkout/page.tsx:198-204`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/page.tsx) — gated **only** on `BNPL_ENABLED` (`payment/constants.ts:23`), never on the mock flags |
| D1 provider | `/fa/bookings/checkout/bnpl` | `MethodStep` — one stateful wizard, `?request_id=`; provider list from `useBnplOptions`. **Unreachable today** — the status gate at [`page.tsx:88`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/bnpl/page.tsx) bails before it, see gap 3 |
| D2 plan | same route | `PlanStep` — per-plan monthly / down-payment / total |
| D3 eligibility | same route | `EligibilityStep` — national id + mobile + consent, then the provider's verdict |
| D4 schedule | same route | `ScheduleStep` — repayment table + contract, then `issueBnplToken` → handoff |
| handoff | `/fa/bookings/checkout/bnpl/gateway` | **Dev harness only** (`notFound()` outside `NODE_ENV=development`); only the mock points here |
| return | `/fa/bookings/checkout/bnpl/return` | `useAcceptBnplSchedule` fires once per mount, then a bounded settle poll → confirmation / retry / card fall-back / window-lapse |
| receipt | `/fa/bookings/checkout/confirmation?method=bnpl` | The **reused card confirmation**, relabelled «پرداخت‌شده با اقساط» |
| D5 wallet | `/fa/wallet` → «اقساط» tab | `WalletInstallments.tsx`**provider-reported** status, never ledger-derived |
## API
Shapes and enums live in [bnpl.md](../integration/domains/bnpl.md) — not restated here.
| Call | Endpoint | Verified |
| --- | --- | --- |
| D3 eligibility | `POST /api/v1/checkout_bnpl/eligibility` | live; **409** `"This request is not awaiting payment."` on request 19 |
| D4 initiate | `POST /api/v1/checkout_bnpl/initiate` · `Idempotency-Key` | live but **400** `"No active BNPL gateway is configured."` — see gap 1 |
| order by own id | `GET /api/v1/checkout_bnpl/{id}` | **200** for order 1 as `09120000011`; **404** as `09120000010` (tenancy) |
| order by request | `GET /api/v1/checkout_bnpl/by_request/{id}` | **200** for request 11 — the client comment calling it a phantom is **stale** |
| D1/D2 options | `GET /api/v1/checkout_bnpl/options/{id}` | **404** — phantom (REQ-022) |
| D4 schedule | `GET /api/v1/checkout_bnpl/schedule/{id}` | **404** — phantom (REQ-022) |
| D5 wallet list | `GET /api/v1/checkout_bnpl/wallet_installments` | **404** — phantom (REQ-024) |
| provider callback | `POST /api/v1/webhooks_bnpl/{provider}` | server-only; nothing fires it in dev |
| admin verify/settle/revert/get | `/api/v1/admin_bnpl/{id}*` | **403** for seeded `super_admin``GET /admin_bnpl/1` probed |
Chain traced for the two live customer calls: `bnpl/page.tsx:47``useBnplOptions``services/bnpl/apis/index.ts`
(ternary on `USE_BNPL_MOCK`) → `apis/clientApi.ts:53/76``clientFetch``CheckoutBnplController.cs:34/39`
`CheckBnplEligibilityQuery.Handler.cs` / `InitiateBnplOrderCommand.Handler.cs`. Every link exists; the seam
selector picks the **mock**, so the real chain is never exercised from the UI.
## Rules that must hold
| Rule | Value | Source |
| --- | --- | --- |
| BNPL is **full-upfront** — the provider bears 100% of default risk, Balinyaar tracks no installments | GT-3 | [overview/platform-summary.md](../../product/overview/platform-summary.md) |
| The card payment is recorded **net of the provider fee**: `settled + bnplCommission == orderAmount`, rejected otherwise | `SettleBnplOrderCommand.Handler.cs:72-74` + DB `CK_BnplTransactions_SettleSplit` | [business/09](../../product/business/09-installments-bnpl.md) §(b) |
| The commission is **read from the actual settlement, never from config** | `bnpl_provider_commission_rate` (`0.07`) is a baseline `platform_config` row (`PlatformConfigConfig.cs:40`, shipped in `InitialMarketplaceBaseline`), and the **only** reader in the repo is the demo seeder — no money path consults it | [payments/bnpl-landscape.md](../../product/payments/bnpl-landscape.md) §5 |
| The nurse payout is **invariant to payment method** — from the booking split, never from `settled_amount` | INV-13; `SettleBnplOrder…Handler.cs:91-92` passes `conversion.PayoutIrr` | [payments/cancellation-and-payout.md](../../product/payments/cancellation-and-payout.md) §7 |
| BNPL commission is a **platform expense** (`bnpl_fee_expense` leg), never the nurse's | `LedgerPosting.BnplSettle` | [payments/escrow-ledger.md](../../product/payments/escrow-ledger.md) |
| `BnplStatus` is **forward-only**: `eligible → token_issued → verified → settled → reverted` | `BnplTransitions.cs`; only cohesive domain methods mutate `status` | server hard rule 13 |
| Webhook idempotency **before** money moves — dedup on `(provider_code, external_event_id)` first | `HandleBnplCallbackCommand.Handler.cs:37-42,79-88` | INV-10 |
| Money flows only customer ↔ provider ↔ Balinyaar; the provider owns the unwind | INV-12 | [business/09](../../product/business/09-installments-bnpl.md) §(a) |
| `installment_count` (`4`) is **informational** and never drives money | | [business/09](../../product/business/09-installments-bnpl.md) §(a) |
| MoR is config (`bnpl_merchant_of_record` = `platform`) | read in both handlers | [business/13](../../product/business/13-tax-invoicing-and-legal.md) §(a) |
| A booking with an active refund is **held out of every payout batch** | INV-16 | [business/10](../../product/business/10-payouts.md) §(d1) |
## How to test
Log in as **09120000011** (customer Sara, the BNPL customer) — see [testing-setup.md](testing-setup.md).
**A. Inspect the seeded BNPL order (this is the only part that works today).**
1. `GET /api/v1/checkout_bnpl/by_request/11` with Sara's token.
**Expect:** `200`, order `id: 1`, `bookingId: 6`, `providerCode: "snapppay"`, `status: "reverted"`,
`orderAmountIrr "3200000"`, `settledAmountIrr "2976000"`, `bnplCommissionIrr "224000"`.
**The PASS is the arithmetic:** `2976000 + 224000 == 3200000` — the net-of-fee invariant, live.
`224000 / 3200000 = 0.07` because the seeder used `bnpl_provider_commission_rate`.
2. Same row shows the revert leg: `revertedAmountIrr "1600000"` (50% tier), `revertTransactionId
"demo-revert-txn-1"`, `refundChannel "bnpl_revert"`, `expectedCustomerRefundEta "2026-08-09"`, and
`providerCommissionReversedAmount: null`.
3. `GET /api/v1/checkout_bnpl/1` as **09120000010** → **404**, not 403. That is the tenancy rule (hard rule 20).
4. Cross-check the payout hold: nurse 1 (09120000001) has an outstanding clawback and booking 6 is excluded
from every batch — see [nurse-earnings-and-payouts.md](nurse-earnings-and-payouts.md).
**B. The wizard (D1→D5) — a dead end at every id.** Open `/fa/bookings/checkout/bnpl?request_id=1`.
**Expect: not the four steps** — you get the "pay with card" fallback card. `request_id=1` and `=2` are the
only rows the BNPL mock's request store holds, both seeded `pending_nurse_response` and swept to
`expired_no_response` minutes later, and `page.tsx:88` bails for anything ≠ `accepted_awaiting_payment`. Any
other id throws `404` inside the mock. **D1D5 cannot be walked today by any route** — so there is no
reproducible test of the BNPL UI, mock or real. Reaching it the intended way (the C6 CTA) passes a **real**
request id and 404s inside the mock — see gap 3.
**C. Prove the rail is dead server-side.** `POST /api/v1/checkout_bnpl/initiate` with any request id.
**Expect:** `400 "No active BNPL gateway is configured."` No workaround exists from the API — a
`payment_gateways` row with `Type = Bnpl, IsActive = 1` must be inserted first.
**The seeded world can no longer support a fresh BNPL checkout either.** Probed live: `booking_requests/list`
returns 13 rows for `09120000010` and 5 for `09120000011`, and **none** is `accepted_awaiting_payment` (a mix
of `converted`, `payment_deadline_expired`, `expired_no_response`, `cancelled_by_customer`,
`rejected_by_nurse`). So even with a gateway row every eligibility call returns `409` — request 11 answers
`"This booking has already been paid."`, request 19 `"This request is not awaiting payment."` Testing D1D4
end-to-end requires re-seeding, fixing gap 1, **and** giving the mock wizard a payable request.
## Known gaps
- No `payment_gateways` row of type `bnpl` is ever created — `SeedPaymentGatewaysAsync` (`ServiceCollectionExtensions.cs:140-151`) seeds only `Standard`, so `InitiateBnplOrderCommand.Handler.cs:42-44` and `CheckBnplEligibilityQuery.Handler.cs:45-47` always fail with `400 "No active BNPL gateway is configured."` The whole rail is unreachable on the live dev server. Verified live.
- `USE_BNPL_MOCK = true` (`bnpl/constants.ts:18`) — every D1D5 screen renders fixture data; no customer-visible BNPL number on screen comes from the server.
- **The wizard is a dead end for every id — H-07's cross-domain mock edge, but the symptom is deadness, not fabrication.** `checkout/page.tsx:198` renders the CTA on `BNPL_ENABLED` alone and pushes a **real** booking-request id into the mock wizard. `bnpl/apis/mockApi.ts:5-8` hard-imports `bookingRequestsMockApi` and `mockInsertConvertedBooking` (consumed at `:262, :289, :358, :378, :405, :424, :463, :484`) — bypassing the seam, so the flip `USE_BOOKING_REQUESTS_MOCK = false` never applies. That store seeds ids **1 and 2 only** (`nextId = 1`), both `pending_nurse_response`, and its `sweep()` ages them to `expired_no_response`; nothing in a real-primary UI ever accepts them. So a real id 404s **and** ids 1/2 hit the `page.tsx:88` guard — D1D5 never render. The `6001+` client-only booking H-07 warned about is currently **unreachable**, because the status gate fires before any settle; the guard is the only thing preventing it.
- Three client ops have no server route and 404 live: `getBnplOptions` (`clientApi.ts:53`), `getBnplSchedule` (`:69`), `getWalletInstallments` (`:107`) — REQ-022/024. D1/D2's plan list, D4's schedule and the wallet «اقساط» tab therefore have no real source at all.
- `bnpl/apis/clientApi.ts:101-103` and its header block `:38-41` both claim `GET checkout_bnpl/by_request/{id}` is a proposed slug that 404s. **It exists** (`CheckoutBnplController.ByRequest`, in the swagger) **and returns 200** — stale comments blocking a partial flip.
- `acceptBnplSchedule` (`clientApi.ts:93`) derives `requestStatus` client-side as `order.status === 'settled' ? 'converted' : 'accepted_awaiting_payment'`. A `failed`/`reverted`/`cancelled` order is mislabelled awaiting-payment, so the return page's `windowExpired` branch (`bnpl/return/page.tsx:77`) can never fire on the real path.
- All four `AdminBnplController` endpoints return **403** for the seeded `super_admin` (`09120000020`) and `finance` (`09120000021`) accounts — probed live on `GET /admin_bnpl/1` and `POST /admin_bnpl/1/verify` (RBAC gap) — and no admin console consumes them — `admin_bnpl/{id}/revert` is unreachable from any UI; the reversal is driven from [cancellation-and-refunds.md](cancellation-and-refunds.md) instead.
- `providerCommissionReversedAmount` is `null` on the seeded reverted order — the reconciliation figure most providers never send. Nothing in the UI or admin surfaces the resulting commission shortfall.
- `DemoLifecycleSeeder.Money.cs:42` writes `EligibilityStatus = "approved"`, which is **not** in the closed `BnplEligibilityStatus` set (`eligible`/`not_eligible`/`ceiling_exceeded`) nor in the client union `bnpl/types.ts:28`.
- The D3 KYC inputs are half-wired: the client sends `{ nationalId, mobile, consent }` (`clientApi.ts:62`) but `CheckBnplEligibilityQuery.Handler.cs:49-52` uses only the mobile. Legal consent is collected and discarded (REQ-023).
- Nothing fires `POST /webhooks_bnpl/{provider}` in dev, so even a successfully initiated real order would never reach `settled` — settlement is webhook-driven by design (`HandleBnplCallbackCommand.Handler.cs`).
- `/fa/bookings/checkout/bnpl/gateway` is an orphan on the real path — referenced only by `mockApi.ts:342` and `notFound()`-gated outside `NODE_ENV=development`.
+118
View File
@@ -0,0 +1,118 @@
# Flow — Booking lifecycle & EVV
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse (check-in/out, care instructions) · customer (watch the engagement) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/06-evv-and-service-delivery.md](../../product/business/06-evv-and-service-delivery.md) ·
[product/business/05-booking-and-scheduling.md](../../product/business/05-booking-and-scheduling.md)
**Integration:** [docs/integration/domains/bookings.md](../integration/domains/bookings.md)
## What it does
Once a booking request is paid, a `bookings` row exists with per-visit sessions. The assigned nurse works the
day: opens the visit, clocks in, reads the gated care instructions, clocks out. The customer watches the same
booking from the other side — a server-truth timeline, the money split, and the address. Check-out is the
event that makes a session payout-eligible; nothing else does.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Nurse day hero | `/fa/nurse` | [`NurseDashboardScreen.tsx:81-133`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/NurseDashboardScreen.tsx) `NextVisitCard` picks the first `scheduled`/`in_progress` row, CTA → `/nurse/visits` |
| Nurse «ویزیت امروز» | `/fa/nurse/visits` | [`visits/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/visits/page.tsx) — `SessionCard` per row, one `useEvvController`, 60 s poll, `EvvStatusBanner` (rendered inside `SessionCard`) once checked in |
| Nurse booking detail | `/fa/nurse/visits/[id]` | `BookingDetailView viewerRole="nurse"` + `NurseVisitNotesPanel` + `BookingSupportEntry`. EVV controls + `CareInstructionsCard` live here. **The notes panel is not this flow's data** — see gap 12 |
| Customer «رزروها» | `/fa/bookings` | `BookingsScreen` — 3 tabs; **فعال** = `pending_payment\|confirmed\|in_progress`, **گذشته** = `completed\|disputed\|closed\|cancelled` |
| Customer booking detail | `/fa/bookings/[id]` | The **same** `BookingDetailView`, `viewerRole="customer"` — no EVV controls, care query never fires |
`BookingDetailView` is one component in two shells (both rows appear in this atlas's route coverage,
[index.md](index.md)); the viewer role drives the UI gate only — the server gates independently.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| list bookings | `GET bookings/list?role=` | `clientApi.ts:36-43``ListBookings`. Thin DTO — no `patientId` |
| booking detail | `GET bookings/get/{id}` | `:33``GetBookingDetail`; nurse view nulls `addressSnapshotJson` (`BookingMapper.cs:28`) |
| today feed | `GET booking_sessions/today` | `:45-53``ListSessionsForNurseQuery``BookingRepository.cs:185-214`. **See gap 1** |
| session EVV | `GET booking_sessions/evv/{id}` | `:55``GetVisitVerification`; raw GPS gated to owning nurse + admin |
| care instructions | `GET bookings/care_instructions/{id}` | `:58``GetCareInstructionsQuery.Handler.cs:35-38` — the stage-2 gate |
| check in | `POST booking_sessions/check_in/{id}` | `:61-67``CheckInVisitCommand.Handler.cs`. Body is coordinates only; the server timestamps |
| check out | `POST booking_sessions/check_out/{id}` | `:69-75``CheckOutVisitCommand.Handler.cs`. `sensitive` 20/min |
Shapes live in [bookings.md](../integration/domains/bookings.md) — not restated here. Client seam
`USE_BOOKINGS_MOCK = false` ([`constants.ts:14`](../../client/src/services/bookings/constants.ts)); all 7 seam
ops map published routes.
**The independent EVV-GPS seam.** `NEXT_PUBLIC_EVV_MOCK_GPS` selects `ILocationProvider`
([`evv/locationProvider.ts:76`](../../client/src/services/bookings/evv/locationProvider.ts)). It resolves as
`?? (USE_BOOKINGS_MOCK ? 'in_range' : 'off')` — and `USE_BOOKINGS_MOCK` is now `false`, so **the default is
`off` = real browser GPS.** Flipping the bookings flag does not disable this seam and vice-versa.
## Rules that must hold
| Rule | Value / shape | Source |
| --- | --- | --- |
| Forward-only lifecycle | `pending_payment → confirmed → in_progress → completed → closed`, plus `disputed`/`cancelled`; illegal edge ⇒ clean `409` | INV-1, `BookingTransitions`. Live: `check_in` on a completed session → `409 "This session cannot be checked in."` |
| EVV is **advisory** | `checkInAddressMatch` tri-state `true`/`false`/**`null` = no reading, not a failure**. A mismatch raises `SupportAlertType.EvvLocationMismatch` + notifies the family; it never blocks and never cancels | INV-14, `CheckInVisitCommand.Handler.cs:64-110`; [business/06](../../product/business/06-evv-and-service-delivery.md) |
| Location tolerance | `evv_location_tolerance_meters` = **200 m** (CONFIG), Haversine against the frozen address snapshot | `…Handler.cs:71` |
| Check-**out** starts the payout clock | `session.SetPayoutEligible(now + dispute_window_hours)`**the only** thing that makes a session payout-eligible; never the `completed` status alone | INV-2, `CheckOutVisitCommand.Handler.cs:59-62` |
| Dispute window | `dispute_window_hours` = **72 h** (CONFIG), frozen per session *and* onto `Bookings.DisputeWindowEndsAt` | [business/10](../../product/business/10-payouts.md) |
| Two-stage clinical gate | Care instructions decrypt **only** post-confirmation, **only** for the assigned nurse or an admin. Never in a list, never logged. Any other caller gets `404`, not `403` | INV-6 / INV-7, `GetCareInstructionsQuery.Handler.cs:35-38` |
| Three-amount split | `gross = balinyaarCommission + nursePayout`, DB CHECK; `platformFeeRate` **snapshotted** at conversion | INV-4 / INV-8 |
| Per-session accrual | equal integer shares, remainder on the last session — Σ = `nursePayoutAmount` | INV-19, `BookingAmounts.SplitPayout:35-42` |
| No-show | `no_show_threshold_minutes` = **60** (CONFIG); the hourly `no_show_sweep` flags an un-checked-in session `missed` | [business/06](../../product/business/06-evv-and-service-delivery.md) |
| Emergency playbook | The nurse's escalation contact reaches them **only** inside the gated `CareInstructionsCard` (`emergencyContactName`/`Phone`); `LogEmergencyTicket` never dials and never exposes a number | INV-20, [business/12](../../product/business/12-messaging-and-emergencies.md) |
Live proof of the money + clock rules on booking 2: `4500000 = 675000 + 3825000`, `platformFeeRate 0.15`,
`visitPayoutAmount 765000 × 5`, and session 4's `payoutEligibleAt` is exactly `checkOutAt + 72 h`.
## How to test
1. Log in as **09120000001** (nurse زهرا عزیزی) — see [testing-setup.md](testing-setup.md).
**Before starting `npm run dev`, set `NEXT_PUBLIC_EVV_MOCK_GPS=in_range`** in `client/.env.development`, or
every check-in from your laptop is a mismatch (see gap 6).
2. Open `/fa/nurse/visits`.
**Expect:** a session list — **but not today's.** The seeded world has no session dated 2026-08-02, yet the
screen renders 11 rows from 2026-07-15 to 2026-08-12 (gap 1). Only `sessionId 14` (booking 10,
2026-08-12) is `scheduled`/`pending`; every other seeded row is `completed`, `missed` or `cancelled`.
3. Tap **مشاهدهٔ رزرو** on booking 2.
**Expect:** `BookingDetailView` in the nurse shell — status «در حال انجام», the care-instructions card
populated (`فشار خون بالا، دیابت نوع دو تحت کنترل.` / `متفورمین 500 (صبح و شب)…` / `پنی سیلین.`), 5 session
rows (3 `completed`, 2 `missed`), and the **quiet `address_pending_note` copy «آدرس پس از تأیید رزرو در
دسترس قرار می‌گیرد.» instead of the address** — REQ-051, not a crash.
4. **Walk a real check-in/out.** The seeded world can no longer support this: booking 2's sessions are all
terminal and booking 10's only session is dated 2026-08-12. Workaround — either (a) as **09120000010**
create a request for **today** → accept it as the nurse → convert it with the Development-only capture
simulator `POST /api/v1/bookings/convert` **inside the 30-minute payment window** (paying through the
browser cannot finish — `MockPaymentProvider` redirects to the non-existent `mock-psp.local`, see
[checkout-and-payment.md](checkout-and-payment.md)); or (b) probe the guards, which is what proves the
wiring:
`POST /api/v1/booking_sessions/check_in/4`**`409` "This session cannot be checked in."**;
`POST /api/v1/booking_sessions/check_out/5`**`400` "Check-out must follow an open check-in."**
5. Read the out-of-range EVV as **09120000002** (nurse علی کریمی):
`GET /api/v1/booking_sessions/evv/9`.
**Expect:** `checkInAddressMatch: false`, `checkInDistanceMeters: 1220.00` — this is the seeded
`evv_location_mismatch` case. Its admin queue is unreachable (gap 5).
6. Confirm the clinical gate from both sides:
`GET /api/v1/bookings/care_instructions/2` as the nurse → **`200`** with decrypted Persian text;
the same call as **09120000010** (the owning customer) → **`404` "Care instructions not found."**
7. Log in as **09120000010** and open `/fa/bookings` → tab **فعال**.
**Expect:** bookings 1, 2 and 10 (`confirmed`, `in_progress`, `confirmed`). Open booking 2 →
the address **is** present in the customer view, and no EVV control is rendered.
## Known gaps
- `booking_sessions/today` applies **no date filter when `date` is omitted** (`BookingRepository.cs:191-192`), and both callers — `visits/page.tsx:26` and `NurseDashboardScreen.tsx:85` — call `useTodaySessions()` with no argument. The «ویزیت امروز» screen therefore lists the nurse's whole session history, oldest first (live: 11 rows, 2026-07-15 → 2026-08-12). `useTodaySessions.ts:10` asserts "`date` omitted = the server's today"; that is false. The feed is also a fixed `pageSize: 20` with no "load more" (`useTodaySessions.ts:16`), so a nurse past 20 lifetime sessions can never reach the recent ones. The dashboard hero compounds it: `NextVisitCard` labels the 2026-08-12 session "next visit" ten days early.
- A booking whose remaining sessions are swept to `missed` **never completes**. `DetectNoShowSessionsCommand.Handler` transitions sessions only; the `allSettled → Completed` re-evaluation exists solely in `CheckOutVisitCommand.Handler.cs:64-70`. Live: booking 2 has all 5 sessions terminal (3 `completed`, 2 `missed`), is still `in_progress`, and `disputeWindowEndsAt` is `null` — so its 3 checked-out sessions can never enter a payout batch (`PayoutRepository.cs:31` requires `b.Status == BookingStatus.Completed`). Nurse money is stranded.
- `completed → closed` is unreachable. No job closes a booking and the only writer, `POST bookings/transition/{id}`, is admin-only, has no UI, and 403s for the seeded admins. Live: bookings 3, 4 and 8 have dispute windows that closed on 2026-07-18/19/28 and are still `completed`.
- No client path produces `disputed`. The status renders in `BOOKING_STATUS_KIND` but nothing raises a dispute — the customer's only booking write is cancel.
- The `evv_location_mismatch` alert queue is doubly unreachable: `GET admin_evv/list` has no client screen, and it 403s for `09120000020`/`09120000021` anyway. The seeded 1220 m mismatch on session 9 can only be seen through the nurse's own EVV read.
- `NEXT_PUBLIC_EVV_MOCK_GPS` now defaults to **`off`** (`constants.ts:60-61`) because it is derived from `USE_BOOKINGS_MOCK`, which was flipped to `false`. A tester on a laptop away from the seeded Tehran address therefore fires a real mismatch — and a `SupportAlert` — on every check-in. The constant's own doc-comment still describes the old `in_range` default.
- REQ-051 — the nurse never sees the address on a confirmed booking. `BookingMapper.ToDetailDto` nulls `addressSnapshotJson` for the nurse, so the nurse must navigate by patient name alone. Ironically the server *does* read that snapshot to compute the EVV match.
- REQ-052 — `BookingSessionListItemDto` (`BookingDtos.cs:71-80`) carries no service/variant field. The client models it as optional `variantLabel?` and it is always `undefined` on the real path, so every today-feed row says patient name + visit index only.
- REQ-057 — `BookingListItemDto` has no `patientId`, so a booking row cannot deep-link to the care record.
- `POST bookings/submit_care_instructions/{id}` is unwired — there is no customer form anywhere in `client/src`. Care instructions exist only because the seeder wrote them; on a booking a tester creates, the nurse's card will be empty.
- `POST booking_sessions/cancel/{id}` is unwired — a nurse cannot cancel a single visit from the UI.
- **The nurse booking detail is half real.** `Client: real` above is a verdict on the `bookings` seam only. The same screen mounts `NurseVisitNotesPanel`, whose task checklist, continuity history and note-save all come from `services/patientRecords``USE_PATIENT_RECORDS_MOCK = true`, module state, lost on navigation. The EVV/care/timeline half is server truth; the notes half is fabricated. → [patient-care-records.md](patient-care-records.md)
- `client/src/services/bookings/apis/serverApi.ts` has no importer anywhere in `client/src` — dead code, and its doc-block still claims the domain is mock-primary (as does `constants.ts:1-13` above the `false`).
+149
View File
@@ -0,0 +1,149 @@
# Flow — booking-request
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (C4/C5) + nurse (inbox) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/05-booking-and-scheduling.md](../../product/business/05-booking-and-scheduling.md)
**Integration:** [docs/integration/domains/booking-requests.md](../integration/domains/booking-requests.md)
## What it does
Stage one of booking, and the only stage with **no money in it**. A family picks a nurse from search, names
the patient, the service variant, the address, a date/time window and the caregiver gender they need, and
sends a request. The nurse has a frozen response window to accept or decline; an accept opens a 30-minute
payment window and hands the customer to [checkout](checkout-and-payment.md). A `booking_requests` row
becomes a `bookings` row only on payment capture.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| C4 — request form | `/fa/bookings/request?nurse_id=&variant_id=&required_gender=` | `client/src/app/[locale]/(private-routes)/(customer)/bookings/request/page.tsx` (727 ln). Waits for patients + addresses + nurse profile before mounting so `defaultValues` are the only values. `JalaliDateIntentPicker` + three preset time windows (`TIME_WINDOWS`, :55) or custom. Blocks a same-gender mismatch before the round-trip: `genderMismatch` at :216, submit guard :248, inline alert :544 |
| C5 — awaiting response | `/fa/bookings/request/[id]` | `…/bookings/request/[id]/page.tsx` (368 ln). Polls every 15 s while non-terminal, stops on terminal (`useBookingRequest`, hook :20-23). `StepperHeader` 3-step tracker, `BookingRequestSummaryCard`, `CountdownTimer`. Each of the five terminal statuses gets its own recovery card |
| customer inbox | `/fa/bookings` | `…/(customer)/bookings/BookingsScreen.tsx:53` mounts `useCustomerRequests()` and deep-links rows back to C5. There is **no dedicated customer requests page** — this list is the only entry point back into C5 |
| nurse inbox | `/fa/nurse/requests` | `…/nurse/requests/page.tsx` (198 ln). Three tabs «در انتظار» / «پاسخ‌داده» / «منقضی»; urgency-tinted countdown pill (teal >2 h, amber <2 h, terracotta <30 min, :17-18); shows `customerNotes` only — never an address |
| nurse detail | `/fa/nurse/requests/[id]` | `…/nurse/requests/[id]/page.tsx` (343 ln). Accept (confirm dialog) / reject (reason dialog, ≤500 chars). A stale action's `409` becomes the «action_stale» toast + refetch (:82-89) |
Client service seam is **real**: [`USE_BOOKING_REQUESTS_MOCK = false`](../../client/src/services/bookingRequests/constants.ts) (`constants.ts:14`).
Its doc-comment above that line still argues for the mock and is stale.
## API
The six client-facing ops are wired and were traced client → server; `checkout_summary` sits on this
controller but is consumed by the checkout flow, and `expire` has no client caller at all. Shapes live in
[docs/integration/domains/booking-requests.md](../integration/domains/booking-requests.md).
| Call | Endpoint | Notes |
| --- | --- | --- |
| C4 submit | `POST /booking_requests/create` | `clientApi.ts:27``BookingRequestsController.cs:34``CreateBookingRequestCommand.Handler.cs` |
| C5 / nurse detail | `GET /booking_requests/get/{id}` | `clientApi.ts:35` → controller `:59`. Role-scoped masking in the mapper, not the client |
| both inboxes | `GET /booking_requests/list?role=&status=&page=&pageSize=` | `clientApi.ts:37` → controller `:54`. Role resolution `ListBookingRequestsQuery.Handler.cs:45-58` |
| nurse accept | `POST /booking_requests/accept/{id}` | `clientApi.ts:48` → controller `:44` |
| nurse reject | `POST /booking_requests/reject/{id}` | `clientApi.ts:51` → controller `:49`; id comes from the **route**, reason from the body |
| customer cancel | `POST /booking_requests/cancel/{id}` | `clientApi.ts:59` → controller `:39` |
| checkout money | `GET /booking_requests/checkout_summary/{id}` | on this controller, consumed by [checkout-and-payment](checkout-and-payment.md) |
| expiry sweep | `POST /admin_booking_requests/expire` | `AdminBookingRequestsController.cs:22` is `[Authorize(DynamicPermission)]`**403 for both demo admins** (re-checked live: …020 and …030 both `403 "Authorization Error"`). The in-process `booking_request_expiry` job (`BookingRequestExpiryJob.cs`, 1-min interval) runs it unattended anyway |
## Rules that must hold
| Rule | Where enforced | Live evidence |
| --- | --- | --- |
| Response deadline frozen from config, never recomputed ([05-booking §a.2](../../product/business/05-booking-and-scheduling.md)) | `CreateBookingRequestCommand.Handler.cs:72,86``nurse_response_deadline_hours` | created `2026-08-02T12:56:14` → deadline `2026-08-03T12:56:14` = **24 h** |
| 30-minute payment window on accept ([05-booking §a.3](../../product/business/05-booking-and-scheduling.md)) | `AcceptBookingRequestCommand.Handler.cs:49-50``booking_payment_deadline_minutes` | accepted `12:56:42``paymentDeadlineAt 13:26:42` = **30 min** |
| Forward-only status; a sideways move is a clean `409`, never a 500 | `AcceptBookingRequestCommand.Handler.cs:38-39`; `BookingRequestTransitions` behind `BookingRequest.cs:66` | second accept on req 21 → `409 "This request can no longer be accepted."`; second reject on req 23 → `409 "This request can no longer be rejected."` |
| Stage-1 disclosure: nurse gets `customerNotes` + coarse city/district only | `BookingRequestMapper.cs:35-38` (`includeFullAddress`) | nurse view of req 21: `addressLine`/`postalCode`/`recipientName`/`recipientPhone` all `null`; `cityNameFa`/`districtNameFa` present. Customer's own view: full address |
| Tenancy — patient + address ∈ caller, variant ∈ nurse; a mismatch is `404` not a leak | `CreateBookingRequestCommand.Handler.cs:37-47`; `GetTrackedForNurseAsync` | nurse 2 reading nurse 1's request 21 → **404** |
| Same-gender care is decisive, never defaulted | client `page.tsx:216,248`, server `CreateBookingRequestCommand.Handler.cs:61-64` (`CaregiverGender.Matches`) | — |
| Nurse must be verified **and** accepting bookings | `CreateBookingRequestCommand.Handler.cs:56-57` | — |
| `customerNotes` ≤ 1000, `nurseRejectionReason` ≤ 500 | `CreateBookingRequestCommand.Validator.cs:25`, `RejectBookingRequestCommand.Validator.cs:10-12`, mirrored client-side in `constants.ts:31,34` | — |
| Countdown renders a **server-frozen** deadline, never a locally computed one | `CountdownTimer.tsx:84-85` | true, but see the timezone gap below |
## How to test
Log in as **09120000010** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
`Seams__Sms__Provider=mock` override, and `dev/last_otp`.
**The seeded world cannot exercise accept/reject.** It was seeded 2026-07-26; every *seeded* request has aged
into a terminal state. The seeded baseline is 11 rows for nurse 1 (`converted` 6, `payment_deadline_expired` 3,
`rejected_by_nurse` 1, `expired_no_response` 1) and 9 for customer …010 — **zero `pending_nurse_response`,
zero `accepted_awaiting_payment`**. So `/fa/nurse/requests` opens on an empty «در انتظار» tab and the
accept/reject buttons are unreachable on seeded data. (The live inbox now reads 14 / 12 because the probe rows
below — 21 accepted, 22 cancelled, 23 rejected — were left in place.) Create your own:
1. As …010 go to `/fa/search`, open نرس **زهرا عزیزی** (nurse 1, female, verified), press «درخواست رزرو».
2. On C4 pick patient حسن محمدی, service «مراقبت ساعتی سالمند» (variant 2, 250 000 IRR/hr), address «خانه»,
a **future** date, a time window, gender «زن». Submit.
**Expect:** redirect to `/fa/bookings/request/<newId>`, step 2 of 3 active, status «در انتظار تایید
پرستار», a countdown against a deadline ~24 h out.
3. In another browser profile log in as **09120000001** (nurse 1) and open `/fa/nurse/requests`.
**Expect:** the new request in «در انتظار» with the service headline, the patient name, the gender chip,
your note — **and no address**. Open it: the location line reads «تهران · منطقه ۳» only.
4. Press «پذیرش».
**Expect:** `200`, status flips to `accepted_awaiting_payment`, the row leaves «در انتظار», and the
customer's C5 (polling at 15 s) shows the «پذیرفته شد» badge, a 30-minute countdown and «ادامه پرداخت».
5. Press «پذیرش» again (or reload and retry): **expect a `409`** and the «action_stale» toast.
6. For the reject leg, create a second request and press «رد درخواست» with a reason (≤500 chars).
**Expect:** `200`, status `rejected_by_nurse`, the reason echoed back on the DTO, and C5 swapping to the
rejection recovery card. A repeat reject is `409 "This request can no longer be rejected."`
Verified by curl on 2026-08-02: request 21 created → nurse-1 pending inbox `total 1` → accepted
(`paymentDeadlineAt` +30 min) → second accept `409`; request 22 created then cancelled by the customer
(`cancelled_by_customer`, `200`); request 23 created → **rejected** by nurse 1 (`200`,
`rejected_by_nurse`, `nurseRejectionReason` echoed) → second reject `409`. Tenancy re-checked live: nurse 2
reading request 21 → `404`. Every leg of this flow is now live-walked, not code-traced.
Re-walked independently the same day on a **fresh** row: request **24** created by …010 against nurse 1
(`200`, `pending_nurse_response`, `nurseResponseDeadlineAt` = `createdAt + 24 h` exactly) → accepted by
nurse 1 (`200`, `accepted_awaiting_payment`, `paymentDeadlineAt` = accept instant + **30 min** exactly,
`addressLine`/`recipientPhone` `null` in the nurse's own accept response) → second accept `409` → accept by
nurse 2 `404`. Same results, so the four load-bearing behaviours above are reproducible, not one-off.
curl gotcha: sending non-ASCII `customerNotes` as a `-d` argument from Git Bash produced a spurious `400`.
Put the JSON in a UTF-8 file and use `--data-binary @file`; it then returns `200` with the note intact.
This is a shell encoding artifact, **not** a server defect.
## Known gaps
- **Deadline timestamps lose their timezone, so every countdown on this flow is wrong off-UTC.**
`BookingRequestDto.cs:39-40` and `BookingRequestListItemDto.cs:19-20` type the two deadlines as `DateTime`
(Kind unspecified after the EF round-trip), so the wire carries `"2026-08-03T12:56:14.1701328"` with no
`Z`. `CountdownTimer.tsx:84` calls `Date.parse` on it, which per spec reads an offset-less date-**time**
as *local*. In Tehran (UTC+3:30) the customer's 30-minute payment window renders as ~4 hours and the
request expires while the timer still shows time left. `createdAt` is a `DateTimeOffset` and *is* correct,
so `windowStart` and `deadlineIso` on the same progress ring disagree by the UTC offset.
- **Nothing seeded is actionable.** No `pending`/`accepted` request survives, so the nurse inbox, the accept
path, the reject path and the payment-window handoff are all unwalkable without creating a fresh request
(see above). Cause: `DemoLifecycleSeeder` anchors to first-run epoch, not wall clock.
- **The `expire` admin endpoint is unreachable for the demo admins** — `AdminBookingRequestsController.cs:22`
uses `DynamicPermission`, which `super_admin`/`finance` do not satisfy. Only the background
`booking_request_expiry` job can move a stale request; a tester cannot force expiry on demand.
- **The «پاسخ‌داده» tab is unpaged.** The API filters one status at a time and there is no `status`-group
filter, so `nurse/requests/page.tsx:37-39` fires three separate page-1 queries and concatenates them
(`Pager` is hidden for that tab). A nurse with >20 accepted/converted/rejected requests silently loses
rows. This is the surviving half of REQ-050.
- **No structured rejection-reason code (REQ-044).** The wire carries free-text `nurseRejectionReason`, so
C5 runs a keyword heuristic (`RETRY_BLOCK_KEYWORDS`, `bookings/request/[id]/page.tsx:23-28` — seven
strings: `gender`/`coverage`/`area`/«جنسیت»/«پوشش»/«منطقه»/«محدوده») to decide whether to offer «درخواست
دوباره از همین پرستار». Any other wording defeats it — the live probe's `"schedule conflict"` passed through.
- **`services/bookingRequests/types.ts` is behind the wire.** `variantLabel` is typed `string | null`
optional on the list item (`types.ts:137`) and documented as "client-augmented", but the server always
serves it — confirmed live. `nurse/requests/page.tsx:135` therefore keeps a `hasPricedService` fallback
branch that is dead on the real path.
- **The list row still lacks `variantPrice`/`variantPriceUnit`**, so the nurse inbox card cannot show the
money she is being asked to commit to without opening the detail.
- **`patientAge` is served but never modelled client-side.** `BookingRequestListItemDto.cs:25` returns it and
the wire carries it (live: `"patientAge": 78` on every nurse-inbox row), but `BookingRequestListItem`
(`client/src/services/bookingRequests/types.ts:118-140`) declares no such field — `patientAge` appears
nowhere in `client/src` except a comment in `apis/clientApi.ts:23`. REQ-014's triage age therefore never
reaches the inbox card, and widening the client type is a pure-client fix.
- **`useCustomerRequests` does not poll** (no `refetchInterval`, `useCustomerRequests.ts:21-27`) while the
detail hook does, so the pending-request countdowns on `/fa/bookings` go stale until a manual refresh.
- **`useBookingRequest` has no `useIsAuthenticated` gate** (unlike both list hooks), so a hard reload of C5
before hydration can fire an unauthenticated `GET` and flash the error card.
- **`nurseAvatarUrl` is served as a `file:///C:/Users/…` local disk path** (verified live on requests 13, 21,
22). C5 does not pass it to `BookingRequestSummaryCard`, so this flow is unaffected — but
[checkout](checkout-and-payment.md) renders it and will show a broken avatar.
- The nurse sees the patient's **display name** pre-accept (`patientName`, verified live). That is wider than
the "notes + coarse address only" wording in the integration doc; confirm it is the intended stage-1
boundary rather than a leak.
@@ -0,0 +1,114 @@
# Flow — cancellation-and-refunds
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (cancel + watch the refund) · admin (dispute refund, settlement) · **Status:** mocked
**Client:** mock · **Server:** partial
**Business source:** [product/business/07-cancellation-and-refunds.md](../../product/business/07-cancellation-and-refunds.md) · [product/payments/cancellation-and-payout.md](../../product/payments/cancellation-and-payout.md)
**Integration:** [docs/integration/domains/refunds.md](../integration/domains/refunds.md)
## What it does
A customer who can no longer take a booked visit cancels it, is told **before** confirming exactly how much
comes back and how much is kept, and then watches the money return on a status screen. An admin can also
issue a refund off a dispute ticket. The platform never edits a payment — it posts a **reversal**, and if the
nurse was already paid it raises a **clawback** the payout engine nets off her next batch.
**The trap this file exists to expose:** the server half is real, live and correct — every customer **read**
(preview · by-booking · status) was probed today and returns the full fee-split. The one **write**
(`POST bookings/{id}/cancel`) is trace-only, never fired (step 6). The **client seam is still mocked**
(`USE_REFUNDS_MOCK = true`, [refunds/constants.ts:18](../../client/src/services/refunds/constants.ts)), and
its real half is stale enough that flipping the flag today would render *10000%* refund. Details below.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Entry | `/fa/bookings/[id]` | «لغو رزرو» CTA — [bookings/[id]/page.tsx:96](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx) → `bookingCancelPath` ([routes.ts:159](../../client/src/constants/routes.ts)) |
| 1 — disclose | `/fa/bookings/[id]/cancel` | Two-step. Step 0 shows off-ramps (reschedule/support → a ticket, real reschedule is deferred), then `CancellationPolicyDisclosure`, reason select, and an explicit acknowledgement checkbox that gates «ادامه» ([cancel/page.tsx:146-217](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) |
| 2 — confirm | same route, `step=1` | Restates refund vs fee in Toman, then `useCancelBooking` → routes to refund status ([:124-136](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) |
| 3 — track | `/fa/bookings/[id]/refund_status` | `RefundStatusCard` off `useRefundStatus(bookingId)` ([refund_status/page.tsx:28](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx)); the poll gate lives in the hook — it runs only while non-terminal, at `REFUND_STATUS_POLL_INTERVAL_MS = 5s` ([constants.ts:38](../../client/src/services/refunds/constants.ts)) |
| Wallet tab | `/fa/wallet` → «استردادها» | `WalletRefunds.tsx:19` calls `useMyRefunds()`**no such route exists** (REQ-048); empty on the real path |
| Admin | `/fa/admin/tickets/[id]` | `RefundPanel` ([admin/tickets/[id]/page.tsx:207](../../client/src/app/[locale]/(private-routes)/admin/tickets/[id]/page.tsx)) — preview → initiate → approve/reject. **The only admin refund surface.** |
| Admin | `/fa/admin/finance` | Group hub only — links to payouts. **No refunds console, no clawback screen, no settlement-confirm screen.** |
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| Pre-cancel preview | `GET bookings/{id}/cancellation_policy` | [BookingsController.cs:74-77](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs) → `GetCancellationPolicyPreviewQuery`**live, probed** |
| Cancel + refund | `POST bookings/{id}/cancel` | [BookingsController.cs:67-71](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingsController.cs) → `CancelBookingAndRefundCommand` (cancel → freeze snapshot → `CreateRefundCommand` → return status) |
| Refund by booking | `GET refunds/by_booking/{bookingId}` | [RefundsController.cs:29](../../server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs) — **live, probed** |
| Refund by id | `GET refunds/{id}/status` | [RefundsController.cs:23](../../server/src/API/Baya.Web.Api/Controllers/V1/RefundsController.cs) |
| Admin create+execute | `POST admin_refunds` | [AdminRefundsController.cs:33](../../server/src/API/Baya.Web.Api/Controllers/V1/AdminRefundsController.cs) — `DynamicPermission`**403 for seeded admins** |
| Admin settle / fail | `POST admin_refunds/{id}/confirm_settlement` · `/mark_failed` | same controller, same 403 |
| Clawback write-off | `POST admin_clawbacks/{id}/write_off` | no client screen at all |
| Tier admin | `GET admin_cancellation_policies/list` · `POST /upsert` | the runtime-editable tier surface — exists in swagger, **403 for seeded admins**, no client screen |
| Phantom (client-only) | `admin_refunds/preview` · `/{id}/approve` · `/{id}/reject` · `refunds/my` | REQ-035 / REQ-048 — absent from swagger, 404 on flip |
Shapes: [docs/integration/domains/refunds.md](../integration/domains/refunds.md).
## Rules that must hold
| Rule | Value / source |
| --- | --- |
| **Cancellation tiers are DB rows, seeded by `HasData`** | [CancellationPolicyConfig.cs:30-58](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/BookingConfig/CancellationPolicyConfig.cs) — `standard_24h` (customer, ≥24h, **100%**) · `standard_inside_24h` (customer, open lower bound <24h, **50%**) · `nurse_no_show` (nurse, **100%**, `FeeRate=0`) · `admin_cancellation` (admin, **100%**). Editable at runtime, never retroactive |
| The applied rate is **snapshotted onto the booking**, then onto the refund | `CancellationHelper.ResolvePolicy``Bookings.CancellationPolicyCode` / `CancellationRefundPercentage``Refund.RefundPercentageApplied` ([CreateRefundCommand.Handler.cs:102-103](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
| **A refund is a reversal leg, never a mutation** | `LedgerPosting.RefundReversalPrePayout``DEBIT platform_revenue` + `DEBIT nurse_payable` / `CREDIT refund_payable` ([LedgerPosting.cs:95-114](../../server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs)) |
| **Pre-payout vs post-payout clawback fork** | `INursePayoutStatus.IsNursePaidForBookingAsync` picks `RefundReversalPrePayout` vs `ClawbackReversalPostPayout` (debits `nurse_clawback_receivable`) and writes a `nurse_clawbacks` row ([Handler.cs:124-143](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
| **Fee-leg decomposition is served, never split client-side** | `ResolveDecomposition` pro-rates each frozen leg ([Handler.cs:172-192](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)); the preview does the same at [GetCancellationPolicyPreviewQuery.Handler.cs:59-64](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs) |
| **VAT is on commission only** | `Invoice.VatIrr = round(PlatformCommissionIrr × VatRate)`, rate `0.10` ([Invoice.cs:38-41](../../server/src/Core/Baya.Domain/Entities/Invoices/Invoice.cs)). There is **no VAT ledger account** — VAT is an invoice line, so the reversal touches it only implicitly |
| `Σ refunded ≤ captured` | summed under `lock(booking:{id}:refund)`; a breach is a clean 409 ([Handler.cs:45,78-81](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
| Crash-window: persist the refund **before** the channel call | [Handler.cs:106-114](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs) |
| Only **un-started** sessions are refundable | preview marks `refundable = Status == Scheduled` ([Query.Handler.cs:44](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs)); MVP always cancels all of them |
| Channel decides mechanics | `manual` if a bank ref is supplied, else `bnpl_revert` for a BNPL gateway, else `psp_card` ([Handler.cs:194-199](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)); BNPL ETA = `bnpl_refund_eta_business_days` = **10** business days |
| Every admin refund hangs off a ticket | auto-opens a `refund` ticket when none is passed ([Handler.cs:54-62](../../server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs)) |
## How to test
1. Log in as **09120000010** (customer Sara) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/bookings/7/refund_status`. **Expect (UI, mocked):** the mock's own fixture, *not* booking 7's
real refund. **Expect (API truth, probed today):**
`GET /api/v1/refunds/by_booking/7``id 1 · status succeeded · psp_card · amount 2000000 ·
platformFeeRefundedIrr 300000 · nursePayoutRefundedIrr 1700000 · refundPercentageApplied 100.00 ·
cancellationPolicyCode standard_24h`. The masked reference renders as `••••••••••ef-1`.
3. Post-payout clawback case: `GET /api/v1/refunds/by_booking/8` → `id 3 · succeeded · amount 250000 ·
payout leg 212500`. That 212500 **is** nurse 1's outstanding clawback — cross-checked live:
`GET /api/v1/nurse_payouts/earnings_balance` (nurse 09120000001) → `clawbackOutstandingIrr "212500" ·
paidTotalIrr 3187500 · netPayableBalanceIrr 8500000`. `refundPercentageApplied` and
`cancellationPolicyCode` are **null** here because it was an admin refund, not a policy cancellation.
4. Pre-cancel preview on a **stale** booking: `GET /api/v1/bookings/7/cancellation_policy` →
`cancellable:false · standard_inside_24h · refundPercentageApplied 50.00 · all amounts "0" ·
sessions[0].reasonCode "cancelled"`. **PASS** = the screen shows the «قابل لغو نیست» card.
5. Pre-cancel preview on a **live** booking: booking **10** (confirmed, 2026-08-12) →
`cancellable:true · standard_24h · refundPercentageApplied 100.00 · feePercentage 0.00 ·
refundAmountIrr 250000 · platformFeeRefundedIrr 37500 · nursePayoutRefundedIrr 212500 ·
leadTimeLabel at_least_24h`. That 37500/212500 split is exactly the 15% commission rule.
**This is the one genuinely cancellable booking in the world today** — the 2026-07-26 seed is 7 days
stale and every other customer booking is `cancelled` or `completed`.
6. **UNVERIFIED: the actual `POST bookings/10/cancel` was not fired** — it is irreversibly destructive to the
only cancellable booking left and other agents share this world. Trace only.
7. Admin: log in as **09120000021** (finance). `/fa/admin/finance` shows only a payouts tile. Open
`/fa/admin/tickets/{id}` on a booking-linked ticket to reach `RefundPanel` — it renders off the **mock**.
`GET /api/v1/admin_refunds` returns **403** with the finance token *and* with the `super_admin` token
(09120000020); so does `GET /api/v1/admin_cancellation_policies/list`. All probed.
## Known gaps
- `USE_REFUNDS_MOCK = true` ([refunds/constants.ts:18](../../client/src/services/refunds/constants.ts)) — every cancel screen, refund status and admin refund panel shows fabricated data while a working server sits behind it.
- **H-06 CONFIRMED, still unticked** ([hardening/issues.md:64](../../archive/post-phase/hardening/issues.md)). [refunds/apis/mockApi.ts:3](../../client/src/services/refunds/apis/mockApi.ts) imports `mockGetBookingForRefund`/`mockMarkBookingCancelled` from the **bookings** mock (used at `:88`, `:312`), but `USE_BOOKINGS_MOCK = false`. The bookings mock store only ever holds its own seeds (50015005) plus ids it converts at runtime — a real booking id such as 7 or 10 never enters it, so `mockGetBookingForRefund` 404s inside the policy preview. This is a live cross-domain `mockApi` import and the flow is broken end-to-end today.
- `CHANNEL_BY_BOOKING` ([mockApi.ts:37](../../client/src/services/refunds/apis/mockApi.ts)) pins `bnpl_revert` to fixture id 5002, so every real booking silently demos as `psp_card`.
- **Percent-scale defect (flip-blocker).** The wire sends `refundPercentageApplied: 100.00` / `feePercentage: 50.00` (0100, probed), but `CancellationPolicyDisclosure.tsx:19-37` runs `toPercent(x) = round(x * 100)` on the documented 01 fraction → renders **10000%** refund the moment the flag flips.
- **The client throws away six fields the server now serves.** `RefundStatusWire` ([clientApi.ts:25-33](../../client/src/services/refunds/apis/clientApi.ts)) does not declare `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt`, and `toSummary` (`:46-51`) hardcodes all six to `null` — so the fee-split transparency section can never render. All six were returned live today. REQ-021 is delivered; the client does not know.
- **Enum drift, three fields.** Client `CancellationPolicyCode = free_24h | partial_under_24h | customer_no_show` ([types.ts:76](../../client/src/services/refunds/types.ts)) vs the seeded rows `standard_24h | standard_inside_24h | nurse_no_show | admin_cancellation`; `CancellationLeadTime = gt_24h | lt_24h | started` vs the wire's `at_least_24h | less_than_24h`; `appliesTo` is typed `CancellationScope` (`whole_booking | remaining_sessions`) but the wire sends the **actor** `"customer"`. Every one of these drives an i18n key lookup → missing-key on flip.
- `preview.refundableSessionIds` ([cancel/page.tsx:129](../../client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx)) does not exist on `CancellationPolicyPreviewDto` — the wire serves `sessions[]` only. On the real path it is `undefined`; harmless today because the server defaults to all un-started sessions, but the type lies.
- **The `clientApi.ts` doc-block is stale and misleading** (`:55-67`): it states REQ-019/020/021 are *contract gaps*. All three routes exist and returned 200 today. Only `getMyRefunds`, `getRefundPreview`, `approveRefund`, `rejectRefund` remain phantom.
- **Every admin refund endpoint is 403** for the seeded `super_admin`/`finance` accounts (`DynamicPermission` grants on the literal role `admin`). Confirmed live for `GET /api/v1/admin_refunds`. The whole admin half is unreachable even after the flag flips.
- **REQ-035 not delivered:** `POST admin_refunds` creates *and executes* in one call. There is no preview, approve or reject route, so `RefundPanel`'s three-step console has no real backend.
- **REQ-048 not delivered:** no `GET refunds/my`, so the wallet «استردادها» tab renders empty on the real path.
- **No admin console for settlement.** `confirm_settlement`, `mark_failed` and `admin_clawbacks/{id}/write_off` have server handlers but **zero client screens** — a `manual`-channel refund can be created and then never confirmed through the UI.
- `/fa/admin/finance` is a hub with a single payouts tile — no refunds, no clawbacks, no invoice issue.
- `expectedCustomerRefundEta` is hardcoded `null` in the preview ([Query.Handler.cs:83-84](../../server/src/Core/Baya.Application/Features/Refunds/Queries/GetCancellationPolicyPreview/GetCancellationPolicyPreviewQuery.Handler.cs)), so the BNPL 10-business-day window is invisible *before* confirming — exactly where the honesty matters most.
- **`customer_no_show` has no seeded row.** The product doc's "up to 100% charge" tier does not exist in `CancellationPolicies`; a customer no-show falls through to `standard_inside_24h` (50% back).
- **No credit note / invoice reversal on refund.** `Invoice` carries no `RefundId` and `CreateRefundCommandHandler` issues none, so a refunded booking's VAT-bearing commission invoice stays as issued.
- The client's in-memory mocks (22 `services/*/apis/mockApi.ts` modules) are module-scoped and reset on reload/HMR — a mocked cancel demo does not survive the navigation to `refund_status`.
+118
View File
@@ -0,0 +1,118 @@
# Flow — care-circle-patients
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer · **Status:** partial
**Client:** partial · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md) ·
data model: [product/data-model/10-reviews-and-records.md](../../product/data-model/10-reviews-and-records.md)
**Integration:** [docs/integration/domains/patients.md](../integration/domains/patients.md) ·
[patient-records.md](../integration/domains/patient-records.md)
## What it does
A customer keeps a list of the people they arrange care for — «حلقهٔ مراقبت», renamed from «بیماران» in
ui-phase-9. Each one is a first-class row, not the account holder: the payer is usually an adult child and
the care recipient an elderly parent or a newborn. Tapping a person opens their care record — the family's
own medication/routine/task plan, plus the read-only history of visit notes nurses wrote for them.
> **The seam splits mid-flow.** The **list** screen is real end to end and was probed live. The **record**
> screen is real UI on a mock service (`USE_PATIENT_RECORDS_MOCK = true`,
> [`patientRecords/constants.ts:13`](../../client/src/services/patientRecords/constants.ts)) — even though
> all five server endpoints exist and return `200`. Nothing you see on `/record` comes from the database.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| E1 — the care circle | `/fa/patients` | [`patients/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/patients/page.tsx) · `PatientCard` (uses `InitialsAvatar`) · add/edit in `FormDialogShell` + `PatientForm` · archive behind `ConfirmDialog` |
| — home teaser | `/fa` | `HomeScreen.tsx:87` calls the same `usePatients()` |
| E2 — care record | `/fa/patients/[id]/record` | [`record/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/patients/%5Bid%5D/record/page.tsx) · 4 tabs داروها / روتین / سوابق / وظایف · `PatientHeader`, `VisitNoteCard`, per-item bottom-sheet edit · `EmptyState` access-denied card gated **before** any clinical fetch |
| — nurse counterpart | `/fa/nurse/visits/[id]` | `NurseVisitNotesPanel.tsx:38-44` — same domain, append-only |
Age is derived client-side by [`patients/age.ts`](../../client/src/services/patients/age.ts); the wire
carries `birthDate`.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| `usePatients``patientsClientApi.list` | `GET /api/v1/patients/list` | **real** · `clientApi.ts:46``PatientsController.List` |
| `usePatient` | `GET /patients/get/{id}` | real |
| `useCreatePatient` / `useUpdatePatient` | `POST /patients/create` · `/update/{id}` | real · `relation`+`conditions` round-trip (REQ-005) |
| `useArchivePatient` | `POST /patients/archive/{id}` | real · **archive, never delete** |
| `useRecordAccess` | `GET /patients/{id}/record_access` | server real, **client mocked** |
| `usePatientCareRecord` | `GET /patients/{id}/care_record` | server real, **client mocked** |
| `useUpdateCareRecord` | `PUT /patients/{id}/care_record` | server real, **client mocked** — the only `PUT` in the API |
| `usePatientHistory` | `GET /patients/{id}/care_records` | server real, **client mocked** |
| `useCreateVisitNote` (nurse) | `POST /patients/{id}/care_records` | server real, **client mocked** |
Handlers: `PatientsController.cs` (Features/Identity/{Commands,Queries}) and
`PatientCareRecordsController.cs``Features/PatientCareRecords/**`. Request/response shapes live in the
integration docs linked above — not here.
## Rules that must hold
| Rule | Source |
| --- | --- |
| A patient is a **first-class row distinct from the customer**; `self` still creates its own row. Customer→patient is 1:N | [business/01](../../product/business/01-actors-and-onboarding.md) |
| **`gender` is load-bearing** — it drives same-gender caregiver matching. Never defaulted or inferred | [integration/patients.md](../integration/domains/patients.md) |
| **Archive, never delete.** A patient referenced by a booking must stay resolvable | [integration/patients.md](../integration/domains/patients.md) |
| **The plan is family-owned; visit records are nurse-owned and append-only.** No edit, no delete endpoint exists and none should | [integration/patient-records.md](../integration/domains/patient-records.md) |
| **Two-stage clinical disclosure** — clinical bodies encrypted at rest, decrypted only after the access check; nurse read is scoped by an active booking | INV-6, `server/CLAUDE.md` rule 18 |
| **Tenancy mismatch is a 404, never a 403** | [api-contract.md](../integration/api-contract.md#status-codes) |
| `initialMedicalNotes` is encrypted PII and is **not** the care record | [integration/patients.md](../integration/domains/patients.md) |
## How to test
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/patients`.
**Expect:** two cards — «فاطمه محمدی» and «حسن محمدی». Header reads «حلقهٔ مراقبت». Verified live:
`GET /patients/list?page=1&pageSize=10``total: 2`.
**Note:** both seeded rows carry `relation: null` and `conditions: []`, so the relation chip and the
condition chips render nothing. That is seed data, not a UI bug.
3. Tap «افزودن» and save a new person.
**Expect:** the card appears immediately (the list is invalidated on mutation) and a green «ذخیره شد».
A new `GET /patients/list` now returns `total: 3` — this writes to the **shared remote DB**.
4. Tap a card's archive action and confirm.
**Expect:** the card disappears, toast «بایگانی شد». The row is still resolvable server-side
(`isActive: false`), so any booking that references it keeps working.
5. Tap a card body to open `/fa/patients/1/record`.
**Expect:** the ownership banner «این پرونده متعلق به خانواده است…», then four tabs.
**This screen is mock-served.** What you see under داروها / روتین / وظایف is
`patientRecords/apis/mockApi.ts` fixtures, and سوابق shows mock notes — **not** the two real seeded
care records. Every edit you make is lost on reload (the mock store is module-level).
6. To see the real data the screen is hiding, call it directly:
`curl --noproxy '*' "http://localhost:5002/api/v1/patients/1/care_records?page=1&pageSize=20" -H "Authorization: Bearer $T_09120000010"`.
**Expect:** `total: 2`, two Persian notes by «زهرا عزیزی» dated 2026-07-16 and 2026-07-25 — and
`taskResults` entries whose `label` is `null` (see gaps).
7. Access control, verified live and **only** observable via curl today:
| Caller | `record_access` for patient 1 | Result |
| --- | --- | --- |
| `09120000010` (owner) | `canView: true, canEdit: true, canAppendNote: false` | ✅ |
| `09120000001` (nurse 1, has bookings for patient 1) | `canView: true, canEdit: false, canAppendNote: true` | ✅ |
| `09120000011` (other customer) | `canView: false, deniedReason: "not_authorized"` | ✅ denies; `GET care_record` for the same caller returns `403` |
In the browser the mock's denial path is only reachable via
`MOCK_FOREIGN_PATIENT_ID = 8888``/fa/patients/8888/record`.
The seeded world still supports every step of this flow — it does not depend on booking-request freshness.
## Known gaps
- `USE_PATIENT_RECORDS_MOCK = true` (`client/src/services/patientRecords/constants.ts:13`) while **all five** server endpoints are live and return `200`. The customer's care record and the nurse's visit-note panel both render fixtures instead of the database. The single highest-value flag flip in this flow.
- Flipping that flag today **breaks the record screen**: `getFamilyRecord` (`patientRecords/apis/clientApi.ts:68-69`) is a bare `unwrap()` with no mapper, and the wire shape does not match. Server `MedicationDto(long Id, string Name, string? Dosage, string Frequency, string? TimingNote)` vs client `Medication { id: string, doseAmount, doseUnit, frequencyCode, frequencyText, timeOfDay: TimeOfDayCode[], timingNote }` — dose/frequency/time-of-day all render blank.
- Same flip **crashes the روتین tab**: server `RoutineItemDto.TimeOfDay` is a single `string?`; the client types it `TimeOfDayCode[]` and calls `.map()` on it (`record/page.tsx:616`).
- Care-plan `id` type mismatch (hardening H-17, confirmed): server `long`, client `string`. `updateFamilyRecord` `PUT`s the whole record including client temp ids like `new-1754…` (`record/page.tsx:277`), so the write is unsafe against the real endpoint.
- **Server defect — `taskResults` labels are lost.** `GetPatientHistoryQuery.Handler.cs:78` deserializes `TaskResultsJson` with default (case-**sensitive**) `JsonSerializer` options, but `DemoLifecycleSeeder.Social.cs:324` seeds camelCase `{"label":…,"done":…}`. Live result for patient 1: three entries of `{"label": null, "done": false}`. Any externally-written JSON in that column silently degrades to blank labels.
- **Client discards a field the server does serve.** `toVisitNote` (`patientRecords/apis/clientApi.ts:37`) hardcodes `taskResults: []` although `CareRecordDto.taskResults` is on the wire. The «X از Y» task-summary chip on `VisitNoteCard` can never render on the real path.
- `deniedReason` enum mismatch: the server returns `"not_authorized"` (`PatientAccess.cs:20`), while `patientRecords/types.ts:117` and `docs/integration/domains/patient-records.md` both declare `no_access | not_found`. Latent today (the UI renders a generic card), a bug the moment anyone branches on it.
- **Stale code comments assert the opposite of reality.** `patientRecords/types.ts:11-13`, `constants.ts:1-12` and `apis/clientApi.ts:62-64` all say the family record and access check have "**no backend**" / are REQ-027 gaps. All three routes exist, are `[Authorize]`d, and were probed `200`. The integration doc is right and the code comments are wrong.
- `patients/constants.ts:1-7` still explains the domain "demos behind the mock" because REQ-005 is missing — REQ-005 is delivered and the flag is already `false`.
- **Editing a patient destroys their birth date.** `PatientForm.tsx:94` always submits `ageToBirthDate(age)` = `YYYY-01-01`. Saving seeded patient 1 (`birthDate: 1948-03-15`) with no changes rewrites it to `1948-01-01`. Age display survives; the date does not.
- Seeded care plans are **empty**`GET /patients/1/care_record` returns `{ medications: [], routine: [], tasks: [] }`, so three of the four record tabs would be blank the moment the flag flips. `DemoLifecycleSeeder` never seeds a `PatientCarePlan`.
- Seeded patients have `relation: null` and `conditions: []`, so the relation/condition chips the UI is built around never appear on demo data.
- REQ-057 open: `PatientDto` has no `lastVisitAt`/`visitCount` and `BookingListItemDto` has no `patientId`, so no booking card can show a care teaser.
- **No `product/business/` file covers patient care records at all** — no documented rule for the append-only constraint, the encryption, or the clinical-access gate. Only `product/data-model/10-reviews-and-records.md` mentions the table. Largest product-doc hole touching this flow.
- Archive is optimistic with no undo; a cross-tenant/stale id 404s and the card silently reappears with a generic «در دسترس نیست» toast (`patients/page.tsx:65-70`).
+167
View File
@@ -0,0 +1,167 @@
# Flow — Checkout & payment
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer · **Status:** partial
**Client:** partial · **Server:** partial
**Business source:** [product/business/08-payments-and-escrow.md](../../product/business/08-payments-and-escrow.md) ·
[product/payments/escrow-ledger.md](../../product/payments/escrow-ledger.md) ·
[product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md)
**Integration:** [docs/integration/domains/payment.md](../integration/domains/payment.md) ·
[docs/integration/api-contract.md](../integration/api-contract.md)
## What it does
The customer's nurse accepted, a 30-minute payment window is running, and the family pays the whole service
price on a card. Balinyaar never creates the booking on "the user tapped pay" — the booking, the balanced
escrow ledger group and the commission invoice all come into existence inside the PSP's webhook, after the
server re-verifies the capture with the acquirer. Everything before that is a redirect.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| C6 summary & pay | `/fa/bookings/checkout?request_id=` | [`checkout/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/bookings/checkout/page.tsx) — nurse identity card, served `PriceBreakdown`, `CountdownTimer` on the frozen `paymentDeadlineAt`, `EscrowExplainer`, `StickyActionBar`. Non-payable statuses render a `PaymentStateCard` instead of a CTA (`:92-124`) |
| Return from gateway | `/fa/bookings/checkout/return` | `useConfirmGatewayReturn` fires once per mount (`confirmFiredRef`), then the `StatusTimeline` pending-callback state backed by the backoff poll; terminal → `invalidateAfterPaymentSuccess` → confirmation |
| Receipt | `/fa/bookings/checkout/confirmation` | Paid total, copyable LTR کد پیگیری, Shamsi paid-at, method, booking deep-link, «دانلود فاکتور». Shared with the BNPL branch via `?method=bnpl` |
| Money hub | `/fa/wallet` | 4 tabs; «پرداخت‌ها»/«رسیدها» merge card + BNPL rows in `useWalletHistoryRows.ts` |
| Invoice | `/fa/bookings/[id]/invoice` | A4-printable commission invoice, VAT-on-commission line, مودیان status chip |
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| Checkout summary | `GET booking_requests/checkout_summary/{id}` | **Live — probed `200`.** `clientApi.ts:35-42` |
| Initiate | `POST bookings/{bookingRequestId}/payments` | `clientApi.ts:44-50`; **one of only two endpoints that read `Idempotency-Key`** (`PaymentsController.cs:34`; the other is `CheckoutBnplController.cs:41`) |
| Outcome poll | `GET booking_requests/get/{id}` | No transaction read exists; `clientApi.ts:56-80` maps request status → `succeeded`/`failed`/`pending` |
| Invoice | `GET invoices/{bookingId}` | `clientApi.ts:82-83` |
| PSP callback | `POST webhooks/payments/{provider}` | anonymous, server-only |
| Payment history | `GET bookings/payment_history` | **Phantom — probed `404`** (REQ-047) |
Shapes live in [payment.md](../integration/domains/payment.md); the request DTO in
[booking-requests.md](../integration/domains/booking-requests.md).
## Rules that must hold
| Rule | Value | Where |
| --- | --- | --- |
| Three-amount split | `gross = balinyaar_commission + nurse_payout`, all ≥ 0 | DB CHECK `CK_Bookings_AmountSplit` (`BookingConfig.cs:13-17`) — [business/08](../../product/business/08-payments-and-escrow.md) |
| Commission rate | `0.15` — a **seeded `platform_configs` default**, not a product mandate | key `platform_fee_rate`; read at conversion (`BookingConversion.cs:45`) |
| Rate is snapshotted | frozen onto `Bookings.PlatformFeeRate` at conversion, never re-read | `BookingFactory.Create`; a later rate change is never retroactive |
| VAT | `0.10`, **on Balinyaar's commission line only** — never the gross, never the payout | key `vat_rate`; [business/13](../../product/business/13-tax-invoicing-and-legal.md), [platform-summary GT-2](../../product/overview/platform-summary.md) |
| Payment window | `30` min, server-frozen onto `BookingRequests.PaymentDeadlineAt` | key `booking_payment_deadline_minutes`; [business/05](../../product/business/05-booking-and-scheduling.md) |
| Money on the wire | IRR integer — **digit string outbound, `int64` inbound**. Never `Number()`; parse with the BigInt helpers | `GetCheckoutSummaryQuery.Handler.cs:72` (`Str()`); client rule 18 |
| Ledger balances | `CardCapture` = DEBIT `escrow_held` gross / CREDIT `platform_revenue` commission + `nurse_payable` payout, one group | `LedgerPosting.cs:26-36` **throws** rather than persist an unbalanced group |
| Ledger is append-only | corrections are new balancing groups, never edits | `LedgerEntryConfig.cs`; [escrow-ledger.md](../../product/payments/escrow-ledger.md) |
| Escrow release | only after a **confirmed check-out** and a closed 72 h dispute window — never on `completed` alone | `Bookings/Commands/CheckOutVisit/CheckOutVisitCommand.Handler.cs` (the booking-lifecycle flow file is not written yet — see [flows index](index.md)) |
| The platform holds no cash | «escrow» is a ledger state over funds custodied at the licensed provider (GT-1) | [platform-summary](../../product/overview/platform-summary.md) |
| Webhook idempotency | upsert `payment_webhook_events` on `(provider, external_event_id)` **first**, then re-verify with the acquirer, then confirm | `HandlePaymentWebhookCommand.Handler.cs:31-36, 76-85, 88` |
| One succeeded payment | filtered `UNIQUE(booking_id) WHERE status='succeeded'`; a unique-violation on confirm is idempotent **success** | `ConfirmPaymentAndPostLedgerCommand.Handler.cs:69-75` |
| `409` on initiate is benign | "already paid / not awaiting payment / window lapsed" — converge, never toast | handler `:35-43`; client `checkout/page.tsx:145-149` |
### ⚠ The same commission is taxed two different ways
Probed live on **booking 8 / request 13**, gross `250000`, commission `37500`:
| Surface | VAT | Method |
| --- | --- | --- |
| `checkout_summary/13` | **`3409`** | `commissionNet = round(commission / (1 + vatRate))`, VAT carved **out** (`GetCheckoutSummaryQuery.Handler.cs:44-46`) |
| `invoices/8` | **`3750`** | `vat = round(commission × vatRate)`, VAT **additive** (`IssueInvoiceCommand.Handler.cs:37-38`) |
Both reconcile internally (checkout: `212500 + 34091 + 3409 = 250000`; invoice: `37500 + 3750 = 41250`) but
they disagree by 341 IRR on one booking. No product file resolves it. Never state "VAT is additive/inclusive"
without naming the surface.
## How to test
Log in as **`09120000010`** (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
OTP and the demo accounts.
> **The pristine seed has nothing to pay for.** Every seeded request has aged past its window: ids 6,7,8,9,12,13
> are `converted`, 2 is `payment_deadline_expired`, 1 is `expired_no_response`, 3 is `rejected_by_nurse`. The
> seed contains **no** `accepted_awaiting_payment` request — you must make one (step 4) to reach a payable C6.
> **The live DB is not pristine:** this session's `booking_requests/list` for `…010` also returned **21**
> (`accepted_awaiting_payment`, live deadline) and **22** (`cancelled_by_customer`), both left by earlier
> walk-throughs. Re-list before assuming an id's state.
**Walk the read-only half (works today):**
1. Open `/fa/bookings/checkout?request_id=13`. **Expect:** the already-paid convergence card («این رزرو
پرداخت شده است»), not a pay button — the summary returns `requestStatus: "converted"`.
2. Open `/fa/bookings/8/invoice`. **Expect:** `INV-0000000008`, VAT row labelled `۱۰٪`, مودیان chip
«در انتظار» (`moadianStatus: "pending"` — the mock `IMoadianClient` returns `registered` **only** when
`Seams:Moadian:ForceRegistered` is on, and it is off by default), no PDF button (`pdfUrl: null`).
3. Open `/fa/wallet` → «پرداخت‌ها». **Expect:** the empty state, *not* an error — `bookings/payment_history`
404s and the BNPL half is mock-empty (`useWalletHistoryRows.ts`).
**Create something payable (the workaround):**
4. As `09120000010`: search → C4 → submit a booking request. As **`09120000001`** (زهرا عزیزی): accept it from
the nurse inbox. **Expect:** status `accepted_awaiting_payment` and a 30-minute countdown. (If a leftover
payable request is still inside its window — id `21` at this stamp — reuse it and skip to step 5.)
5. Return to `/fa/bookings/checkout?request_id=<new>`. **Expect:** the money breakdown, the live countdown,
and an enabled «پرداخت».
6. **Do not tap «پرداخت» in the browser** — see the first gap. Instead capture from the CLI:
`POST /api/v1/bookings/convert {"bookingRequestId": <new>}` with the customer's bearer.
**Expect:** `200` with the booking detail, request → `converted`, and two notifications of *different*
types (`booking_confirmed` to the customer, `booking_confirmed_nurse` to the nurse).
**But:** this path posts **no ledger group, no `payment_transactions` row, no invoice and no coordination
ticket** (`ConvertRequestToBookingCommand.Handler.cs` — it stops at `CommitAsync` + notify), so
`GET /invoices/{newBookingId}` will `404`. Only the webhook path (`ConfirmPaymentAndPostLedger…:58-98`)
produces the full money record.
**Live probe results at this stamp** (customer `…010`, every curl `--noproxy '*'`):
- `checkout_summary/13``200`; `vatIrr "3409"`, `grossPriceIrr "250000"`, `balinyaarCommissionIrr "37500"`.
- `checkout_summary/21` (a genuinely payable request) → `200`, `requestStatus "accepted_awaiting_payment"`.
- **`POST bookings/21/payments` + `Idempotency-Key``200`, `redirectUrl
"https://mock-psp.local/pay/mock-ref-21-<key>"`.** The dead end below is probed on a payable request, not
inferred from the 409 path.
- `POST bookings/13/payments` + `Idempotency-Key` → `409 "This booking has already been paid."`
- `bookings/payment_history` → `404` · `booking_requests/get/13` → `200` with `bookingId: 8` ·
`invoices/8` → `200`; `vatIrr "3750"`, `totalIrr "41250"`, `moadianStatus "pending"`, `pdfUrl null`.
## Known gaps
- **Tapping «پرداخت» dead-ends the browser.** `MockPaymentProvider.cs:20` returns
`https://mock-psp.local/pay/{ref}` — a non-existent host — and `checkout/page.tsx:137-140` does
`window.location.assign` on any absolute URL. The customer lands on a DNS error and never returns.
**Probed:** `POST bookings/21/payments` really does return that URL with a `200`. The local card-gateway
harness page (`checkout/gateway/page.tsx`) was deleted in `64f6aa4`, so nothing catches the hop.
- **Nothing fires the PSP webhook locally**, so no *new* card payment reaches `ConfirmPaymentAndPostLedger` in
dev: the `payment_transactions` row stays `pending`, no ledger group is posted, no invoice is issued.
`bookings/convert` substitutes for the *booking*, not for the money. The succeeded transactions and invoices
you can read today (booking 8) are **seeded**, not reproducible from the UI — so the flow's core invariant is
observable only as data, never as behaviour.
- **A verified nurse is shown as unverified on the payment screen.** `CheckoutSummaryDto` (client
`types.ts:46-47`) declares `nurseAvatarUrl` and `nurseVerified`; the server DTO constructs neither
(`GetCheckoutSummaryQuery.Handler.cs:48-67`, absent from the probed payload), so `nurseVerified` is
`undefined` → `<TrustBadge state="unverified">` at `checkout/page.tsx:326`. REQ-046.
- **`sessionCount` is served but nullable, and the client types it as non-null.** The server *does* emit the
field (`"sessionCount": null` on requests 13 and 21 — `ctx.SessionCount` is `int?`, defaulted to `1` only
for the internal `gross` maths); the client declares `sessionCount: number` (`types.ts:52`) and feeds it
straight into the ICU label `row_service_cost_with_count` at `checkout/page.tsx:265`. UNVERIFIED how
next-intl renders a null `count` — not reproduced in a browser this session.
- **The confirmation screen cannot deep-link to the booking.** `booking_requests/get/{id}` **does** serve
`bookingId` (probed `8`), but `clientApi.ts:58` types the response as `Omit<…,'bookingId'>` and `:73`
hardcodes `bookingId: null`. Hardening issue H-10. The receipt also always hides کد پیگیری and paid-at
(`:76-77`).
- **The client's payment constants and comments are stale and mislead the reader:** `clientApi.ts:36-37` says
`checkout_summary` "404s until the backend delivers it" (it returns `200`); `constants.ts:5-16` still says
"Mock is primary this phase" and "the contract serves no checkout summary" while `USE_PAYMENT_MOCK = false`;
and `constants.ts:53` hardcodes `MOCK_PLATFORM_FEE_RATE = 0.12` against the server's seeded `0.15`. Only the
last is money-shaped, and it is unreachable while the mock is off.
- **The invoice screen derives a money row.** `invoice/page.tsx:135` computes
`serviceIrr = gross commission vat` = `208750` for booking 8, but the real nurse payout is `212500` —
wrong by exactly the VAT, because VAT is additive and not part of gross. It also labels `invoice.grossIrr`
as the invoice total while the served `totalIrr` (`41250`, the commission invoice) is never shown. Violates
client rule 18 ("the client displays money; it never computes it").
- **The wallet «پرداخت‌ها» tab is permanently empty** for a card-paying customer — `bookings/payment_history`
is a live `404` on every visit (REQ-047).
- **`InvoiceDto` serves no payment method, transaction reference or seller fiscal identity** (REQ-049), so
those rows never render on the real path.
- **The escrow ledger has no read surface.** `ledger_entries` is exposed only through
`GetNursePayableBalance`; a customer, an admin and a tester have no way to see the balanced capture group
the flow's core invariant depends on.
- **`GET invoices/{bookingId}` is `[Authorize]` only** (`InvoicesController.cs:17`); tenancy is enforced inside
`GetInvoiceQuery`. Not re-probed cross-tenant this session — UNVERIFIED that a foreign customer gets `404`.
+212
View File
@@ -0,0 +1,212 @@
# Flows — what is implemented, and how to test it
One file per **user-meaningful journey**. Each answers three questions and no others: what it does, what is
real versus mocked, and the exact steps to walk it against a running stack.
**Start here → [testing-setup.md](testing-setup.md)** for bring-up, the demo accounts and the OTP. Then pick
a row below.
> Last verified: 2026-08-02 against commit `c841bde`, against a **booted stack** — API on
> `http://localhost:5002` against the shared remote SQL Server, client on `http://localhost:3000` (and a
> production build on `:3001`), all 8 demo accounts logged in over the real phone-OTP round-trip.
---
## Status table
`Client` / `Server` are independent on purpose. **A real UI on a mocked service is the trap this atlas
exists to expose** — six flows below are exactly that.
| Flow | Actor | Status | Client | Server | Gaps | File |
| --- | --- | --- | --- | --- | --- | --- |
| auth-login-otp | all | **built** | real | real | 15 | [](auth-login-otp.md) |
| public-front-door | guest | partial | real | real | 10 | [](public-front-door.md) |
| onboarding-customer | customer | partial | real | real | 9 | [](onboarding-customer.md) |
| care-circle-patients | customer | partial | partial | real | 15 | [](care-circle-patients.md) |
| addresses-and-map | customer | partial | real | real | 10 | [](addresses-and-map.md) |
| onboarding-nurse | nurse | partial | partial | real | 11 | [](onboarding-nurse.md) |
| nurse-service-areas | nurse | partial | real | real | 8 | [](nurse-service-areas.md) |
| nurse-verification | nurse + admin | **mocked** | mock | partial | 12 | [](nurse-verification.md) |
| nurse-catalog-and-pricing | nurse | partial | real | real | 9 | [](nurse-catalog-and-pricing.md) |
| search-and-discovery | customer | partial | partial | real | 14 | [](search-and-discovery.md) |
| booking-request | customer + nurse | partial | real | real | 12 | [](booking-request.md) |
| checkout-and-payment | customer | partial | partial | partial | 11 | [](checkout-and-payment.md) |
| bnpl-installments | customer | **mocked** | mock | partial | 12 | [](bnpl-installments.md) |
| booking-lifecycle-evv | nurse + customer | partial | real | real | 13 | [](booking-lifecycle-evv.md) |
| cancellation-and-refunds | customer + admin | **mocked** | mock | partial | 17 | [](cancellation-and-refunds.md) |
| reviews | customer + admin | partial | real | real | 11 | [](reviews.md) |
| patient-care-records | nurse + customer | **mocked** | mock | real | 12 | [](patient-care-records.md) |
| nurse-earnings-and-payouts | nurse + admin | **mocked** | mock | partial | 18 | [](nurse-earnings-and-payouts.md) |
| messaging-tickets | all | partial | partial | real | 14 | [](messaging-tickets.md) |
| notifications | all | partial | real | real | 13 | [](notifications.md) |
| admin-backoffice | admin | **mocked** | partial | partial | 13 | [](admin-backoffice.md) |
| partner-center | partner | **mocked** | mock | partial | 12 | [](partner-center.md) |
| account-and-settings | all | partial | partial | real | 12 | [](account-and-settings.md) |
**1 built · 15 partial · 7 mocked · 0 not started · 0 UNVERIFIED.** 283 gaps recorded — Phase 4's input.
`built` = end-to-end real and observed working · `partial` = real end to end with named gaps · `mocked` =
the UI is real, the data is fake · `not started` = no implementation. Every row is backed by a code trace,
and every money flow plus auth was additionally walked against the running API.
---
## The six things that surprise everyone
1. **No seeded admin can reach any admin endpoint.** All 16 admin GET operations return `403` for both
`09120000020` (super_admin) and `09120000021` (finance). `DynamicPermissionService.CanAccess` grants on
the **literal** role `"admin"` or a per-controller `DynamicPermission` claim; the demo admins hold
`super_admin`/`finance`, and **no code anywhere writes that claim**. The client hides it completely
because `USE_ADMIN_MOCK = true`. → [admin-backoffice.md](admin-backoffice.md)
2. **A `false` mock flag does not mean the domain is honest.** `payment` is flag-real with only 2 of 6
operations working; `search` hardcodes `isVerified: true` and `nurseGender: 'female'`;
`tickets.getUnreadTotal` is literally `async () => null`.
3. **A `true` mock flag does not mean the real half is missing.** `verification` has 10 of 14 operations
live and probed, `admin` 9 of 14, `payouts` 5 of 10 — all suppressed by one shared flag. The nurse's
verification status answers correctly to `curl` right now; nothing in the browser calls it.
4. **The seeded demo world is dated 2026-07-26 and has aged out.** No `pending` or `accepted` booking
request survived the 60-second expiry job, so the nurse inbox and checkout have nothing seeded to act
on. → [testing-setup.md](testing-setup.md#the-seeded-world-and-how-stale-it-is)
5. **No card payment can complete from a browser.** `MockPaymentProvider` redirects to
`https://mock-psp.local/pay/…`, a host that does not exist. The server money path is correct and
idempotent; only the last hop is unreachable. → [checkout-and-payment.md](checkout-and-payment.md)
6. **The real BNPL rail is dead on the live stack.** `SeedPaymentGatewaysAsync` seeds only a `Standard`
gateway, never a `Bnpl` one, so `POST /checkout_bnpl/initiate` returns `400 "No active BNPL gateway is
configured."` for every request — verified live. The UI never notices because it runs on a mock.
→ [bnpl-installments.md](bnpl-installments.md)
---
## Mock-vs-real map — client
Derived from code across all 22 `client/src/services/` domains, **153 seam operations**. `Real-half verdict`
is about the `clientApi.ts` implementation, independent of the flag: `COMPLETE` = every op maps a published
route with no fabrication · `PARTIAL` = ≥1 op fabricates or derives a value · `GAPPED` = ≥1 op targets a
route that does not exist server-side.
| Domain | Flag | Ops | Real-half verdict | live / proposed / fabricated |
| --- | --- | --- | --- | --- |
| `addresses` | real | 5 | COMPLETE | 5 / 0 / 0 |
| `admin` | **mock** | 14 | GAPPED | 9 / 5 / 0 |
| `auth` | real | 6 | COMPLETE | 6 / 0 / 0 |
| `bnpl` | **mock** | 7 | GAPPED | 2 / 4 / 1 |
| `bookingRequests` | real | 6 | COMPLETE | 6 / 0 / 0 |
| `bookings` | real | 7 | COMPLETE | 7 / 0 / 0 |
| `catalog` | real | 7 | COMPLETE | 7 / 0 / 0 |
| `geography` | real | 3 | COMPLETE | 3 / 0 / 0 |
| `notifications` | real | 4 | COMPLETE | 4 / 0 / 0 |
| `nurse` (bank) | real | 4 | COMPLETE | 4 / 0 / 0 |
| `partnerCenter` | **mock** | 13 | GAPPED | 6 / 7 / 0 |
| `patientRecords` | **mock** | 5 | GAPPED | 2 / 3 / 0 |
| `patients` | real | 5 | COMPLETE | 5 / 0 / 0 |
| `payment` | real | 6 | **GAPPED + PARTIAL — worst real domain** | 2 / 2 / 2 |
| `payouts` | **mock** | 10 | GAPPED + PARTIAL | 5 / 4 / 1 |
| `profiles` | real | 6 | COMPLETE | 6 / 0 / 0 |
| `refunds` | **mock** | 9 | GAPPED | 2 / 7 / 0 |
| `reviews` | real | 6 | COMPLETE | 6 / 0 / 0 |
| `search` | real | 2 | PARTIAL | 2 / 0 / 3 fields |
| `serviceAreas` | real | 3 | COMPLETE | 3 / 0 / 0 |
| `tickets` | real | 11 | PARTIAL + GAPPED (gated) | 7 / 3 / 1 |
| `verification` | **mock** | 14 | GAPPED + PARTIAL | 10 / 3 / 1 |
Totals: **15 real / 7 mock** · ~107 ops map published routes · **34 would 404 if the flag were flipped
today** · 12 fabricate or client-derive a value.
Three further facts a reader needs:
- **20 of 22 mocks hold mutable module state** — a mocked demo that crosses a full page navigation restarts
from its seed.
- **Two cross-domain mock edges break live paths**: the `refunds` and `bnpl` mocks read the `bookings` mock
store, which is seeded only with ids `5001-5005`, and `findBooking` throws `404` for anything else. Real
booking ids therefore `404` inside those wizards.
- **One production seam breach**:
[`nurse/verification/page.tsx:12`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx)
imports `__mockApproveAll`/`__mockRejectStep` from `apis/mockApi` unconditionally. The render is gated;
the module edge is not, so the mock ships in every build.
## Mock-vs-real map — server seams
**Exactly one `Seams:*:Provider` is set anywhere in the repo — `Seams:Sms:Provider = telegram`.** Every
other rail runs on its mock, in development *and* in the deployed stack: `docker-compose.yml` overrides only
the relay's `BaseUrl` and the object-storage `RootPath`, never a selector.
| Rail | Mock | Real adapter available | Selected today |
| --- | --- | --- | --- |
| SMS / OTP | `LoggingSmsSender` | `KavenegarSmsSender` · `TelegramSmsSender` | **telegram** |
| Card PSP | `MockPaymentProvider` | `ZarinPalPaymentProvider` | mock |
| Settlement split (تسهیم) | `MockSettlementSplitProvider` | `ProviderSettlementSplitProvider` | mock |
| BNPL | `MockBnplProvider` | `SnappPayBnplProvider` · `DigipayBnplProvider` | mock |
| Object storage | `LocalDiskObjectStorage` | `S3ObjectStorage` | mock |
| Geocoder | `MockGeocoder` | `NeshanGeocoder` | mock |
| Bank transfer (payouts) | `MockBankTransferProvider` | none | mock — **moves no money** |
| e-invoicing (مودیان) | `MockMoadianClient` | `MoadianClient` | mock |
| Shahkar · e-KYC · IBAN ownership | mocks | Finnotech adapters | mock |
| Credential (MoH/INO) · eNamad · review moderation | mocks | **none — deliberate** | mock, always |
| Search | *(no mock)* | `SqlNurseSearch` | real |
Two rails **throw at startup** instead of falling back to the mock: `Seams:Sms:Provider = smsir|ghasedak`,
and `Search:Backend` set to anything but `sql`. And the PSP selector is not a token match —
`ServiceCollectionExtension.cs:174` treats *any* non-`mock` string as ZarinPal, so a typo silently selects a
real gateway.
## Corrections to `mocks-registry.md`
[`archive/build-chain/working-context/reports/mocks-registry.md`](../../archive/build-chain/working-context/reports/mocks-registry.md)
was cross-checked row by row against the code: **the whole frontend section — 26 rows plus its prose header
— and 17 disagreements were found.** The registry now carries a banner pointing here.
The systemic cause: its **"Config flag … default `true`"** column was never updated after the
refinement-phase-4 de-mock, although the same file's prose header and Status column were.
| Kind | Count | Detail |
| --- | --- | --- |
| Stale flag default | 8 | `patients`, `profiles`, `nurse` (bank), `geography`, `addresses`, `serviceAreas`, `catalog`, `reviews`, `notifications` rows all still say "default `true`"; the code says `false` |
| Actively wrong about behaviour | 3 | `profilesClientApi.uploadAvatar` "throws 501" — it does a real multipart upload · `AddressMapPicker` "not a real map, no Neshan tiles, no network" — it renders real Leaflet Neshan tiles when the key is set · the `payment` row records the flip as clean and never records the 4 broken ops it produced |
| Wrong in both directions at once | 1 | One row's flag column says all-mock and its own Status column says all-real; reality is 2 real / 3 mock |
| Missing entirely | 3 | `SearchApi`, `BookingRequestsApi` (the exact store behind the BNPL breakage), and `geography/neshan.ts`'s direct third-party `fetch` calls |
| Internal contradiction | 1 | The registry says the REQ-027 endpoints exist; `patientRecords/apis/clientApi.ts` says none exist. Needs a server-side ruling |
| Verified and **holding** | 15+ | Including all 7 remaining flag values, the EVV GPS default, and the deleted card mock-gateway harness |
Backend seam rows were **not** re-audited row-by-row; the current server picture is the seam table above.
---
## Coverage accounting
**All 14 business areas are covered.** Each maps to a primary flow; none is orphaned.
| Area | Primary flow | Area | Primary flow |
| --- | --- | --- | --- |
| 01 Actors & Onboarding | `auth-login-otp`, `onboarding-*` | 08 Payments & Escrow | `checkout-and-payment` |
| 02 Nurse Verification | `nurse-verification` | 09 Installments / BNPL | `bnpl-installments` |
| 03 Catalog & Pricing | `nurse-catalog-and-pricing` | 10 Payouts | `nurse-earnings-and-payouts` |
| 04 Search & Matching | `search-and-discovery` | 11 Reviews, Trust & Safety | `reviews` |
| 05 Booking & Scheduling | `booking-request`, `booking-lifecycle-evv` | 12 Messaging & Emergencies | `messaging-tickets` |
| 06 EVV / Service Delivery | `booking-lifecycle-evv` | 13 Tax, Invoicing & Legal | `partner-center`**weakest** |
| 07 Cancellation & Refunds | `cancellation-and-refunds` | 14 Notifications & Admin | `notifications`, `admin-backoffice` |
**Area 13 is the thin one.** Invoicing and VAT are split across `checkout-and-payment` (the per-booking
invoice) and `admin-backoffice` (the مودیان reconciliation); no flow owns the legal/tax story end to end.
Phase 4 backlog item.
**All 22 client service domains are covered** by at least one flow (see the map above). **All 83 client
routes** are reachable from a flow file's Screens table; the 9 routes with no service domain are static
pages, and the orphans (nothing links to them) are recorded in the flow that owns their area.
**One flow has no product source:** `account-and-settings` is a UI-phase-9 decision with no
`product/business/` file behind it. `public-front-door`'s depth decision lives in
[`product/notes/open-questions.md`](../../product/notes/open-questions.md) rather than a business area.
---
## Conventions these files follow
1. Every file carries `> Last verified: <date> against <commit>`. A file without one is a claim, not a fact.
2. Every status is backed by a code trace (route → hook → seam → endpoint → controller → handler) with
`file:line` evidence. The money flows and auth were additionally walked against the running API.
3. What could not be checked is written with an explicit `UNVERIFIED:` prefix and a reason.
4. **API shapes are not restated here** — [docs/integration/](../integration/index.md) owns them, and every
flow links to its domain file. **Business rules are not restated** — [product/](../../product/index.md)
owns them; a flow states the number and links to its source.
5. Each file stays under 200 lines. `testing-setup.md` is a deliberate exception: it is the single page you
hand someone, and splitting it would defeat that.
+121
View File
@@ -0,0 +1,121 @@
# Flow — messaging & support tickets
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer · nurse · admin/staff · **Status:** partial
**Client:** partial (real seam, two fabricated summary fields) · **Server:** real
**Business source:** [product/business/12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md)
**Integration:** [docs/integration/domains/tickets.md](../integration/domains/tickets.md)
## What it does
Tickets are the **only** sanctioned post-booking channel — there is no live chat and no direct
nurse↔customer messaging, because every conversation must be admin-readable. A booking-coordination
thread is auto-created when a booking is paid for; users also open support threads themselves, and
refunds hang off a ticket. Staff read the same threads plus **internal notes** users can never see.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Customer inbox | `/fa/support/tickets` | [`TicketInboxScreen`](../../client/src/components/messaging/TicketInboxScreen.tsx) `role="customer"`; its header CTA (`:63-70`) opens `ContactSupportDialog` with `defaultCategory="support"` |
| Customer thread | `/fa/support/tickets/[id]` | [`TicketThreadScreen`](../../client/src/components/messaging/TicketThreadScreen.tsx) — reference code + category + status header, bubble stream, sticky composer. **No internal-note affordance anywhere** |
| Nurse inbox / thread | `/fa/nurse/support/tickets`, `/…/[id]` | The **same two components**, `role="nurse"`, in the nurse shell |
| Open from a booking | `/fa/bookings/[id]`, `/fa/nurse/visits/[id]` | [`BookingSupportEntry`](../../client/src/components/messaging/BookingSupportEntry.tsx) — also renders the nurse's emergency banner (`tel:` + playbook) |
| Open from cancel | `/fa/bookings/[id]/cancel` | `ContactSupportDialog` (`cancel/page.tsx:219`) |
| Admin hub | `/fa/admin/support` | Group root, no data reads |
| Admin queue | `/fa/admin/tickets` | `useAdminTickets` + `useAdminListState` (draft-vs-applied filters, URL-synced) |
| Admin thread | `/fa/admin/tickets/[id]` | Full conversation **including internal notes**; composer toggles `isInternal` and goes amber; hosts `RefundPanel`; close/reopen/assign hidden behind `TICKET_LIFECYCLE_ENABLED` |
Thread polling: `TICKET_THREAD_REFETCH_INTERVAL = 15 s` while the screen is mounted
([`constants.ts:34`](../../client/src/services/tickets/constants.ts)); no background refetch.
## API
Shapes belong to [docs/integration/domains/tickets.md](../integration/domains/tickets.md) — not repeated here.
| Call | Endpoint | Notes |
| --- | --- | --- |
| Inbox | `GET /api/v1/tickets` | ✅ live. `clientApi.ts:181`. Participant-scoped at the query layer (`TicketRepository.ListMyTicketsAsync:114-118`) |
| Thread (user) | `GET /api/v1/tickets/{id}` | ✅ live. **Stamps `last_read_at`** (`GetTicketThreadQuery.Handler.cs:48-56`) — opening the thread is what clears the badge |
| Open | `POST /api/v1/tickets` | ✅ live. Only caller is `ContactSupportDialog` via `useOpenTicket` |
| Post message | `POST /api/v1/tickets/{id}/messages` | ✅ live, both user and staff composers (`clientApi.ts:212,253`) |
| Admin queue | `GET /api/v1/admin/tickets` | ⛔ **403 for every seeded admin** — see gaps |
| Admin thread | `GET /api/v1/admin/tickets/{id}` | ⛔ **403** (probed live) |
| Close / reopen | `POST /tickets/{id}/close`, `/reopen` | Server-live, **client-unreachable** (`TICKET_LIFECYCLE_ENABLED = false`, `constants.ts:52`) |
| Assign | `POST /tickets/{id}/assign` | ❌ **phantom** — no server route (REQ-063) |
| Emergency | `POST /api/v1/tickets/emergency` | ❌ **unwired** — zero client callers; `EmergencyBanner` only dials `tel:` |
| Participants | `POST`/`DELETE /tickets/{id}/participants…` | ❌ unwired — no UI |
| Unread total | *(none)* | `getUnreadTotal` is literally `async () => null` (`clientApi.ts:223`, REQ-059) |
Coordination tickets are auto-created server-side, not by the client — `ConfirmPaymentAndPostLedger…Handler.cs:94`
and `SettleBnplOrderCommand.Handler.cs:128` both send `AutoCreateCoordinationTicketCommand`.
## Rules that must hold
| Rule | Where it lives | Verified |
| --- | --- | --- |
| **INV-5 — `is_internal` is a query-layer boundary.** A non-staff read never *receives* an internal row | `TicketRepository.GetMessagesAsync:99-101` (`if (!includeInternal) query.Where(m => !m.IsInternal)`); `includeInternal = request.AsAdmin && isStaff` (`GetTicketThreadQuery.Handler.cs:41`) | ✅ live — see below |
| …and a non-staff caller can never **set** one | `PostMessageCommand.Handler.cs``403 "Only staff can post an internal note."` | ✅ live `403` |
| …and `isInternal` is **absent from every user-facing client type** | `TicketMessage`/`TicketDetail` (`types.ts:82-106`) carry no such field; only `AdminTicketMessage:164` and `PostAdminMessageRequest:205` do | ✅ read |
| …UI filtering is defence-in-depth only, never the gate | `clientApi.ts:89` re-drops any flagged row and the comment names it a backend defect to file | ✅ read |
| Ticket-only, admin-readable communication; no live chat; the emergency playbook **never dials and never exposes a number** | [business/12 §(a)](../../product/business/12-messaging-and-emergencies.md) (INV-20) | partial (see gaps) |
| Ticket **bodies are encrypted at rest** (refinement-phase-9) | `TicketMessageConfig.cs:19-21`; `TicketMessageEncryptionTests.cs` | ✅ Persian bodies round-tripped correctly on every probe |
| `referenceCode` is the stable, unique human id (`TKT-…`) | `TicketReferenceCode.cs`; UNIQUE-indexed | ✅ |
| Body ≤ **4000** chars; subject ≤ **300** | `OpenTicketCommand.Validator.cs:15-16`, `PostMessageCommand.Validator.cs:10` | ✅ read |
| `clientMessageId` is optimistic-send idempotency — a retry returns the **original** message | `PostMessageCommand.Handler.cs`; UNIQUE `(TicketId, ClientMessageId)` | ✅ live |
**Live proof of the boundary (2026-08-02).** Seeded ticket `TKT-DEMOSUP1` (id 12) holds four messages,
one of them an admin internal note («یادداشت داخلی: …», `DemoLifecycleSeeder.Social.cs:197-203`).
`GET /tickets/12` as `09120000010` returned message ids **7, 9, 10** — id **8 is missing from the
sequence**, and that gap is the internal note. No `isInternal: true` row reached the wire.
**The one cross-side enum mismatch** the integration doc records: **`TicketAuthorRole`**. The server's
`TicketParticipantRole` (`TicketCodes.cs`) defines `customer` · `nurse` · `admin`; the client
(`types.ts:37`) adds a fourth member **`system`** that the server never emits. Widening on the reading
side is safe, but a reader of the client types would wrongly conclude `system` is a wire value.
## How to test
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Go to `/fa/support/tickets` (linked from the `/fa/profile` account hub).
**Expect:** **9 tickets**`TKT-DEMOSUP1` (support, open, 1 unread), `TKT-DEMORF01`/`TKT-DEMORF03`
(refund), and six `coordination` rows for bookings 1, 2, 3, 4, 7, 8.
3. Open `TKT-DEMOSUP1`.
**Expect:** exactly **three** bubbles (customer → support → customer, ending «ممنون، حل شد. 🌸»).
**The internal note must not appear.** Its unread badge clears on return to the inbox (the GET stamped
`last_read_at`). Persian text renders correctly = decryption works.
4. Type a reply and send. **Expect:** the bubble appears instantly as `sending`, then settles to `sent`.
Send the sentinel `/fail` only under the mock — on the real path, kill the API to see the bubble go
`failed` **with your text preserved** and a retry-in-place control (`usePostMessage.ts:60-69`).
5. Log in as `09120000001` (زهرا عزیزی, nurse) → `/fa/nurse/support/tickets` (linked from `/fa/nurse/more`).
**Expect:** **7 tickets** — the same six coordination threads plus `TKT-DEMOEMG1` (emergency).
Same components, nurse chrome.
6. Open any `coordination` ticket. **Expect:** an **empty** thread — `lastMessageAt` is `null` on all six;
the auto-created ticket carries no opening message.
7. Admin half: `/fa/admin/tickets` as `09120000020`. **Expect: it fails.** Every `/api/v1/admin/tickets*`
call returns `403` for the seeded admins. Unlike the other admin consoles this domain is **not**
mock-covered (`USE_TICKETS_MOCK = false`), so the queue renders its error state, not fake data.
**Workaround:** none in the browser. Read a thread out of band as staff —
`GET /api/v1/tickets/12` with the super_admin token returns **`200`** (staff bypass the participation
check, `GetTicketThreadQuery.Handler.cs:36-38`), still with internal notes stripped.
The seeded world supports this flow fully on the user side; unlike the booking flows, **nothing here has
aged out** (tickets carry no deadline).
## Known gaps
- `GET /api/v1/admin/tickets` and `/admin/tickets/{id}` return **403 for `09120000020` and `09120000021`** (`DynamicPermission` grants only on the literal role `admin`). The whole staff console — queue, internal notes, `RefundPanel` — is untestable end to end.
- The admin ticket console is the **only admin console with no mock fallback** (`USE_TICKETS_MOCK = false`), so the 403 surfaces as a broken screen rather than being hidden.
- Staff authorization is inconsistent by layer: `StaffRoles.All` **includes** `super_admin`/`finance`, so the handlers would happily serve them — the controller attribute is what blocks. Two different definitions of "staff" in one request path.
- `getUnreadTotal` returns a hardcoded `null` (`tickets/apis/clientApi.ts:223`) — the chrome support badge can never light up (REQ-059).
- `mapSummary` hardcodes `lastMessagePreview: null` and `lastAuthorRole: null` (`clientApi.ts:77-78`) — the inbox card shows no message preview.
- `POST /api/v1/tickets/emergency` has **zero client callers**. The nurse's emergency banner dials `tel:` but the "then opens a ticket" half of the business/12 playbook is manual.
- `POST`/`DELETE /tickets/{id}/participants…` are unwired — no UI can attach a third party to a thread.
- `close` / `reopen` are live server-side but unreachable: `TICKET_LIFECYCLE_ENABLED = false` (`constants.ts:52`). `tickets/constants.ts` still justifies the gate with "the backend has no close/reopen/assign routes yet" — only `assign` is actually missing.
- `POST /tickets/{id}/assign` is a **phantom**`clientApi.ts:273` targets a route that does not exist (REQ-063).
- Client `PostMessageResult` (`types.ts:141-145`) omits `clientMessageId`, which the server **does** echo (probed). The doc block at `types.ts:130-133` claims "the server has no field for it today… the real client does not send it" — `clientApi.ts:218` sends it. Stale comment inside the shipping domain.
- Client `TicketAuthorRole` declares `system`; the server's `TicketParticipantRole` does not. One side must move.
- Auto-created coordination tickets are opened with **no first message**, so both actors see an empty thread with nothing explaining what it is for.
- `TicketDetail.messages` is unpaginated by contract — a long thread has no incremental read.
- Attachments are designed but gated off (`TICKETS_ATTACHMENTS_ENABLED = false`, REQ-060).
+137
View File
@@ -0,0 +1,137 @@
# Flow — notifications
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer · nurse (admin/partner have no bell) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md)
**Integration:** [docs/integration/domains/notifications.md](../integration/domains/notifications.md)
## What it does
Every state change another domain makes — a request arrives, a payment captures, a refund settles, a ticket
gets a reply — leaves a record the user can find later. A bell in the header carries the unread count; tapping
it opens a day-grouped feed; tapping a row marks it read and, **when the type is one the client recognises**,
jumps to the thing it is about. In-app only: there is no push, SMS or email channel, and nothing is dispatched
client-side — every notification is server-raised.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Bell (customer) | any `(customer)` route | [`NotificationBell role="customer"`](../../client/src/components/notifications/NotificationBell.tsx) mounted in [`CustomerLayout.tsx:34`](../../client/src/layout/CustomerLayout.tsx) — badge from the polled count; **always navigates**, never opens a popover |
| Bell (nurse) | any `/nurse/*` route | same, [`NurseLayout.tsx:74`](../../client/src/layout/NurseLayout.tsx) |
| Feed (customer) | `/fa/notifications` | [`NotificationCenter role="customer"`](../../client/src/components/notifications/NotificationCenter.tsx) — day groups «امروز» / «دیروز» / «این هفته» then Shamsi dates, «نمایش بیشتر» grows page 1, «علامت‌گذاری همه…» bulk-clears |
| Feed (nurse) | `/fa/nurse/notifications` | same component, `role="nurse"` |
| Feed (admin) | `/fa/admin/notifications` | **`PlaceholderScreen` stub** — icon + generic body, no data ([`page.tsx`](../../client/src/app/[locale]/(private-routes)/admin/notifications/page.tsx)). Unreachable: `AdminLayout` renders no bell and nothing links to it |
| Row | — | [`NotificationRow.tsx`](../../client/src/components/notifications/NotificationRow.tsx) — navigable rows are a `ButtonBase` with a chevron; `kind: 'none'` rows render as a static surface (no ripple, no chevron) but still mark read |
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| list | `GET /notifications/get_notifications` | unread-first then newest-first ([`NotificationService.cs:24`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Notifications/NotificationService.cs)); client grows `pageSize` instead of paging ([`useNotifications.ts`](../../client/src/services/notifications/hooks/useNotifications.ts)) |
| badge | `GET /notifications/get_unread_count` | own cheap query; polled every 60 s, stale 45 s ([`constants.ts:25-27`](../../client/src/services/notifications/constants.ts)), gated on auth |
| mark one | `POST /notifications/mark_notification_read` | optimistic — flips the row and decrements the badge, rolls back on error ([`useMarkNotificationRead.ts`](../../client/src/services/notifications/hooks/useMarkNotificationRead.ts)) |
| mark all | `POST /notifications/mark_all_read` | optimistic, zeroes the badge; bulk `ExecuteUpdate` server-side |
Chain traced end to end: [`NotificationCenter.tsx`](../../client/src/components/notifications/NotificationCenter.tsx)
→ hooks → [`apis/index.ts`](../../client/src/services/notifications/apis/index.ts) (`USE_NOTIFICATIONS_MOCK = false`)
→ [`clientApi.ts:45-73`](../../client/src/services/notifications/apis/clientApi.ts)
→ [`NotificationsController.cs`](../../server/src/API/Baya.Web.Api/Controllers/V1/NotificationsController.cs)
`ListMyNotifications` / `GetUnreadCount` / `MarkNotificationRead` / `MarkAllRead` handlers. No phantom, no gap.
Shapes live in the [integration doc](../integration/domains/notifications.md).
## The type vocabulary — probed live
The server emits **14** type codes. `parse.ts` recognises **3 of them**; the other 11 fall to `{ kind: 'none' }`
and render as untappable rows.
| Server `type` | Raised by | To | `dataJson` | Client verdict |
| --- | --- | --- | --- | --- |
| `booking_confirmed` | `ConfirmPaymentAndPostLedger.Handler.cs:109`, `SettleBnplOrder.Handler.cs:142` | customer | `booking_id` | ✅ → `/bookings/{id}` |
| `refund_completed` | `ConfirmRefundSettlement.Handler.cs:70` | customer | `booking_id`, `refund_id` | ✅ → `/bookings/{id}/refund` |
| `ticket_message` | `PostMessage.Handler.cs:73` | ticket participants | `ticketId` | ✅ → the thread |
| `booking_confirmed_nurse` | `ConfirmPaymentAndPostLedger.Handler.cs:117` | **nurse** | `booking_id` | ❌ unknown type — the nurse's own "you have a paid booking" is dead |
| `booking_request_received` | `CreateBookingRequest.Handler.cs:97` | nurse | `booking_request_id` | ❌ unknown type **and** unknown key |
| `booking_request_accepted` | `AcceptBookingRequest.Handler.cs:57` | customer | `booking_request_id`, `payment_deadline_at` | ❌ — the pay-now prompt does not link to checkout |
| `booking_request_rejected` | `RejectBookingRequest.Handler.cs:45` | customer | `booking_request_id` | ❌ |
| `booking_request_expired_no_response` | `ExpireBookingRequests.Handler.cs:26` | customer | `booking_request_id` | ❌ |
| `booking_request_payment_window_expired` | `ExpireBookingRequests.Handler.cs:35` | customer | `booking_request_id` | ❌ |
| `evv_location_mismatch` | `CheckInVisit.Handler.cs:105` | customer | `booking_id`, `session_id` | ❌ type unknown (the id would have resolved) |
| `evv_no_show` | `DetectNoShowSessions.Handler.cs:67` | customer | `booking_id`, `session_id` | ❌ same |
| `refund_issued` | `CreateRefund.Handler.cs:256` | customer | `booking_id`, `refund_id` | ❌ client knows `refund_processed`, not `refund_issued` |
| `review_moderated` | `ModerateReview.Handler.cs:54` | customer | `reviewId` | ❌ client knows `review_published`, and keys off `nurse_profile_id` |
| `verification_expiry_prompt` | `ScanExpiringCredentials.Handler.cs:81` | nurse | *(none)* | ❌ — no payload at all, so nothing to link to |
Ten codes go the other way: `parse.ts:21-43` handles `booking_reminder`, `session_reminder`,
`payment_captured`, `booking_cancelled`, `refund_processed`, `payout_paid`, `payout_failed`, `ticket_opened`,
`review_published`, `ticket_closed`**no server code emits any of them.** Consequently the `payout` and
`nurse_profile` branches of [`deepLink.ts`](../../client/src/services/notifications/deepLink.ts) are
unreachable, and a nurse never gets a payout notification at all.
## Rules that must hold
| Rule | Value | Source |
| --- | --- | --- |
| In-app only; no push at launch | `NotificationChannel.Sms`/`Push` exist in the enum, nothing implements them | [business/14 §MVP](../../product/business/14-notifications-and-admin.md) · `INotificationDispatcher.cs` |
| Read notifications hard-deleted after **90 days**; unread never deleted | `RetentionDays = 90`, swept every 24 h — a **hardcoded constant, not a `platform_configs` row** | `NotificationRetentionJob.cs`; business/14 §(a) |
| A notification never exists for a rolled-back transaction | the dispatcher self-commits its own row and is called **after** the handler's `CommitAsync` | `InAppNotificationDispatcher.cs`; e.g. `ExpireBookingRequests.Handler.cs:76-85` |
| Every read is scoped to the caller | `userId` from `ICurrentUser`; no by-id fetch exists | `NotificationService.cs:24,38` |
| Unread count is its own query, never derived from a page | badge must be right without a list fetch | integration doc |
| Adding a type is safe; changing a type's `dataJson` is not | the client keys deep-links off the payload and degrades to non-tappable | integration doc |
## How to test
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Look at the header bell. **Expect** a numeric badge. Live at this stamp: `GET /notifications/get_unread_count`
`{"count":10}` (the seeded 6 plus requests other testers raised today, so treat the number as ≥ 6, not
exactly 6).
3. Tap the bell → `/fa/notifications`. **Expect** `total: 12`, unread-first, grouped under «امروز» / «دیروز» /
«این هفته» / Shamsi day headers, each unread row with a dot and a tinted icon.
4. Find the top row — `booking_confirmed`, `{"booking_id":10}`. **Expect** a chevron; tapping it navigates to
`/fa/bookings/10` and the badge drops by one immediately (optimistic).
5. Find any `booking_request_accepted` / `evv_no_show` row. **Expect the opposite**: neutral grey icon, no
chevron, tap does nothing but mark read. That is the type-vocabulary gap, working as coded.
6. Tap «علامت‌گذاری همه به‌عنوان خوانده‌شده». **Expect** the badge to hit 0 instantly and stay 0 after a
refresh. **This is destructive to the shared demo DB** — the unread count does not come back. Skip it
unless you own the world.
7. Nurse side: log in as `09120000001` and open `/fa/nurse/notifications`. **Expect** 9 rows, mostly
`booking_request_received`**all of them untappable**, and the two `booking_confirmed_nurse` rows
untappable too.
8. Admin: `/fa/admin/notifications` renders a placeholder. There is no link to it and no bell in the admin
shell; reaching it means typing the URL.
Everything above except step 6 was executed live against `:5002` with pre-minted bearer tokens on 2026-08-02.
## Known gaps
- 11 of the 14 server notification types are unknown to `parse.ts`, so their rows are untappable; only
`booking_confirmed`, `refund_completed` and `ticket_message` deep-link.
- `booking_confirmed_nurse` carries a valid `booking_id` but no client branch — the nurse's most important
notification cannot be opened (`parse.ts:21-26`).
- All five `booking_request_*` types carry `booking_request_id`, a key `parse.ts` never reads and
`NotificationData` has no `kind` for — the whole request lifecycle is non-navigable, including the
"pay within the window" prompt.
- `refund_issued` vs the client's `refund_processed`, and `review_moderated` vs `review_published`: near-miss
names on both sides of the same contract.
- `verification_expiry_prompt` is dispatched with `DataJson = null` — even a matching client branch could not
route it (`ScanExpiringCredentials.Handler.cs:81`).
- The client handles 10 types no server code emits; the `payout` and `nurse_profile` deep-link classes in
`deepLink.ts` (and their icons/tints) are dead code. No payout notification is ever raised, so the nurse is
never told a payout paid or failed.
- **Every `title` and `body` is a server-side English literal** (`"Booking confirmed"`, `"The nurse declined
your request."`) rendered verbatim into the Persian RTL feed — `NotificationRow.tsx:62,67`, no i18n path.
Only the chrome (headers, buttons, empty state) is translated.
- `/fa/admin/notifications` is a `PlaceholderScreen`, is not linked from anywhere, and no admin/partner shell
mounts a bell — admins have no notification surface at all.
- `NotificationBellPopover.tsx` (4.4 KB, full mark-read/deep-link logic) is exported but mounted nowhere —
dead UI since the desktop branch was removed.
- The list is not real pagination: `useNotifications` grows `pageSize` on page 1, so "load more" refetches the
whole feed each time.
- Notification retention (90 d / 24 h) is a hardcoded constant, not a `platform_configs` row, contrary to the
repo's config-is-rows convention.
- The two notification `page.tsx` files are `'use client'` with no `generateMetadata`, departing from the
thin-RSC-page convention in [client/CLAUDE.md](../../client/CLAUDE.md).
- No unread-notification cap or archive: the feed grows until the 90-day sweep, and unread rows are never
swept.
@@ -0,0 +1,127 @@
# Flow — nurse catalog & pricing
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse (browse side: customer, anonymous) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/03-service-catalog-and-pricing.md](../../product/business/03-service-catalog-and-pricing.md)
**Integration:** [docs/integration/domains/catalog.md](../integration/domains/catalog.md)
## What it does
A nurse turns the platform's service catalogue into her own price list. She picks a category, answers the
dimensions the catalogue defines for it, and puts a price on that exact configuration. The result — a
**variant** — is the atomic bookable unit: it is what a customer searches for, taps, and pays for. The
catalogue skeleton itself (categories → option groups → option values) is admin-owned reference data;
the nurse only composes on top of it.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Offerings list | `/fa/nurse/services` | [`MyServicesList.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/MyServicesList.tsx) — `useMyVariants()`, per-row activate/deactivate via `useSetVariantActive`, `EmptyState` when none |
| Go-live gate | same page (top card) | [`PublishGate.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/PublishGate.tsx) — reads `useActivationChecklist`, fires the real `profiles` `set_accepting_bookings`; a listed variant is not a *visible* variant |
| Build / edit | same page, in-place | [`VariantBuilder.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/services/VariantBuilder.tsx) — 3-step stepper (category → options → price). No extra route; `page.tsx:18-28` swaps the body |
| Practice hub | `/fa/nurse/practice` | `NursePracticeScreen.tsx:25` reads `useMyVariants()` only for the "how many offerings" count |
| Public preview | `/fa/nurse/profile/preview` | `preview/page.tsx:47,68` — renders **only `isActive` variants** through `ServicePriceRow`, entirely from the nurse's own cache (never the search index) |
The builder's middle step is **derived, not fixed**: `VariantBuilder.tsx:140-144` drops the options step for a
category that provably has zero option groups, and blocks *Next* while a required group is unanswered
(`:134`), naming the missing dimension instead of erroring after the tap.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| Categories | `GET /api/v1/catalog/categories` | anonymous; `clientApi.ts:28-35``CatalogController.Categories` |
| Option groups | `GET /api/v1/catalog/option_groups?category_id=` | anonymous; `clientApi.ts:37-40``CatalogController.OptionGroups` (`category_id` is snake_case, the only one) |
| My offerings | `GET /api/v1/nurse_variants/list` | `[Authorize]`, self-scoped; `clientApi.ts:42-49` |
| One variant | `GET /api/v1/nurse_variants/get/{id}` | **`[AllowAnonymous]`** by design (`NurseVariantsController.cs:46`) — a public profile deep-links a variant |
| Create | `POST /api/v1/nurse_variants/create` | `CreateVariantCommand.Handler.cs` |
| Edit price | `POST /api/v1/nurse_variants/update/{id}` | option-set is immutable on update (`UpdateVariantCommand.Handler.cs:33-37`) |
| Retire / restore | `POST /api/v1/nurse_variants/set_active/{id}` | soft only — there is no delete |
Shapes live in [catalog.md](../integration/domains/catalog.md). The seven `admin_catalog/*` authoring routes
exist on the server and have **no client caller at all** (`grep -rn admin_catalog client/src` → 0 hits).
## Rules that must hold
| Rule | Where it is enforced |
| --- | --- |
| **The catalogue is EAV**: categories → option groups → option values. Option groups are **not** seeded with the migration — `CatalogSeed.cs:7` says so explicitly ("those are admin-authored data per category"). A group with `serviceCategoryId = null` is **cross-category**. | `CatalogSeed.cs`, `GetApplicableGroupsAsync` |
| **Duplicate guard = `option_set_hash`.** SHA-256 over the sorted `(groupId:valueId)` pairs → one comparable column, so "same set of choices" becomes expressible as `UNIQUE(NurseId, ServiceCategoryId, OptionSetHash)` filtered on `DeletedAt IS NULL`. Handler pre-checks and returns a clean `409`; the index is the race backstop. | `OptionSetHash.cs:16-25`, `NurseServiceVariantConfig.cs:28-31`, `CreateVariantCommand.Handler.cs:65-69` |
| **Every required dimension must be answered, exactly once**, and every value must belong to an applicable group. | `CreateVariantCommand.Handler.cs:40-62` |
| **`PriceUnit` is a closed set of 5** (`per_hour` `per_session` `per_half_day` `per_day` `per_24h`) and is a **label, never a multiplier** — the client must not derive a total from it. | `Domain/Entities/Catalog/PriceUnits.cs`; §2b of the business-rule map |
| **Money is IRR integer.** The wire carries a digit string; the DB column is `bigint`. The **only** Toman↔Rial boundary is the price field: `tomanToRial` on submit, `rialToToman` to pre-fill an edit. | `utils/money.ts:38` (`tomanToRial`, `×10` via `BigInt`), used at `VariantBuilder.tsx:113,183`; `NurseServiceVariantConfig.cs:13` |
| **The variant snapshot freezes a variant onto a booking** so a later edit or deactivation never mutates a past booking, dispute or invoice. | `IVariantSnapshotSerializer`, `BookingFactory.cs:53``Bookings.VariantSnapshotJson` (`Booking.cs:41`) |
| **Search visibility is a separate gate.** Every create/update/set_active reindexes in the same unit of work; a row is searchable only when `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17). Deactivated rows stay with `is_searchable = 0`, never deleted. | `CreateVariantCommand.Handler.cs:99`, `UpdateVariant…:40`, `SetVariantActive…:34` |
| **Tenancy is 404, not 403** — another nurse's variant id resolves to "not found". | `UpdateVariantCommand.Handler.cs:28-31` |
## How to test
1. Log in as `09120000001` (nurse زهرا عزیزی, verified) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/nurse/practice`. **Expect:** the services row shows a non-zero offerings count.
3. Open `/fa/nurse/services`. **Expect:** a `PublishGate` card at the top and **5** offering cards.
The task brief and the seeder say *3* — that is stale: live `nurse_variants/list` returned
`total: 5` (ids 1, 2, 3 seeded + 7, 8 created by earlier manual testing on the real path). Their
existence is itself proof the create path works end to end.
4. Tap *add*, choose «مراقبت از سالمند», then *Next*. **Expect:** a middle step titled with the
required dimension «نوع شیفت» offering روزانه / شبانه / شبانه روزی. It is required, so *Next* stays
disabled until one is picked.
5. Pick **شبانه**, price `2000000` Toman, unit «نیم‌روز», duration `1`, submit. **Expect:** an inline
duplicate warning (not a toast) offering «ویرایش همان مورد» — this collides with variant `8`
(`cat 1 / Night / 20000000 IRR / per_half_day`). Server returns exactly
`409 · "You already offer this exact configuration in this category."`;
`VariantBuilder.tsx:258-260` maps it to the inline state.
6. Go back, pick **روزانه** instead, price `300000` Toman, unit «ساعتی». **Expect:** success; the list
now shows the new card, display name auto-built as «مراقبت از سالمند · روزانه»
(`CreateVariantCommand.Handler.cs:131-134`).
7. Deactivate that card. **Expect:** it stays in the list marked inactive, and disappears from
`/fa/nurse/profile/preview` (which filters `isActive`, `preview/page.tsx:68`).
Live probes run for this stamp. Anonymous: `GET /catalog/categories`**5** active categories
(ids 15, `sortOrder = id`); `GET /catalog/option_groups?category_id=1`**1** group (`id 1`,
`serviceCategoryId: null`, `isRequired: true`, 3 values); `GET /nurse_variants/get/{id}``200`
without a token. Nurse-token: nurse 1 `total: 5`, nurse 2 `total: 2`.
All three write-path guards were exercised live against the running API and **CONFIRMED**:
| Probe | Result |
| --- | --- |
| `create` with nurse 1's existing `cat 1 / valueId 2` set | `409 · "You already offer this exact configuration in this category."` — and no row was added (`total` stayed 5) |
| `create` on `cat 3` with `options: []` | `400 · {"Options":["Required dimension(s) not answered: نوع شیفت."]}` — the cross-category group is enforced on a category that never declared it |
| `create` with a **customer** token (09120000010) | `403 · "Only a nurse can create a variant."` — the role check is in the handler, not just the attribute |
The 409 fires from the handler pre-check (`:68-69`), ahead of the index; the filtered UNIQUE index is
the race backstop, not the message source.
## Known gaps
- **No catalogue-authoring UI exists.** All seven `admin_catalog/*` routes (create/update category,
option group, option value) have zero client callers. An admin cannot add a dimension without SQL.
- **Even with a UI, no seeded admin could use it.** `admin_catalog` is `[Authorize(DynamicPermission)]`,
which `super_admin`/`finance` fail — see [testing-setup.md](testing-setup.md#-the-seeded-admins-cannot-reach-any-admin-endpoint).
- **A production database has zero option groups.** `CatalogSeed.cs` seeds categories only. The single
«نوع شیفت» group exists because `DemoWorldSeeder.EnsureShiftTypeGroupAsync` runs **in Development only**
(`DemoWorldDefinitions.cs:36-40` says so). Deployed, every builder collapses to two steps and every
variant in a category is a duplicate of every other — one price per nurse per category, forever.
- **ZWNJ is stripped from every stored Persian string.** `StringExtensions.FixPersianChars` line 90
(`.Replace("", " ")`), applied to every string property of every entity at
`ApplicationDbContext.cs:79`. Live proof: the seeder writes «شبانه‌روزی» and the API returns
«شبانه روزی» (`ش…ه ر…` — a literal U+0020). This contradicts the repo naming rule
("with a ZWNJ, always") and silently mangles nurse-typed display names. The API test at
`NurseVariantsApiTests.cs:50-51` documents the behaviour rather than fixing it.
- **A price edit is not shielded from an in-flight request.** `UpdateVariantCommand.Handler.cs:33` writes
the new price unconditionally, and `BookingRequestRepository.GetConversionSourceAsync:212-229` reads
`r.Variant.Price` **live** at conversion. The snapshot freezes at booking creation (post-payment), not
at request creation — so a nurse editing price inside the 30-min payment window changes what the
customer pays. Code-traced, not exercised live.
- **`IVariantSnapshotSerializer`'s doc-comment names the wrong table** — it says the JSON is frozen onto
a `booking_requests` row; the only field is `Bookings.VariantSnapshotJson` (`Booking.cs:41`).
- **Category `iconKey` and both description fields are `null` for all 5 categories** (live probe), so the
Home A5 grid and the builder's category tiles fall back to a generic icon and show no explainer copy.
- **The «همراهی و مراقبت روزمره» (Companionship) category is data-only** — `CatalogSeed.cs:11` notes it
"ships only as a seeded category, not a pricing path", but the builder offers it like any other.
- **`sessionCount` is free-typed and unvalidated against `priceUnit`** — nothing stops
`per_24h` + `sessionCount: 5`; seeded variant `7` is `per_session` + `5`, variant `8` is
`per_half_day` + `1`, both legal.
@@ -0,0 +1,118 @@
# Flow — Nurse earnings and payouts
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse (read-only) · admin/finance (runs the batch) · the weekly scheduler · **Status:** mocked
**Client:** mock · **Server:** partial (all 4 nurse reads verified live; every admin op 403s)
**Business source:** [product/business/10-payouts.md](../../product/business/10-payouts.md)
**Integration:** [docs/integration/domains/payouts.md](../integration/domains/payouts.md)
## What it does
A nurse wants one answer: *how much am I owed, and when does it land?* Money accrues per completed booking,
becomes eligible once the 72-hour dispute window closes, and is swept weekly into a payout batch against the
nurse's verified primary IBAN. Admin/finance opens the batch and — as the one irreversible, human-approved
step on the platform — submits it to the bank rail.
**This is the sharpest "real server, mocked client" case in the product.** All four nurse endpoints are live
and return real data (probed below); `USE_PAYOUTS_MOCK = true` (`client/src/services/payouts/constants.ts:13`)
means all six routed screens plus the nurse-dashboard widget render module-level fixtures instead. This confirms
hardening issue [H-09](../../archive/post-phase/hardening/issues.md). **"Live" is not "correct":** two of the four
reads are substantively wrong even at 200 — a booking with an unpaid payout is reported `paid`, and the headline
net does not reconcile with the four buckets (see gaps). Flipping the flag exposes both.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/nurse/finance` | Group root. The single headline number: signed net payable, never clamped (`NurseFinanceScreen.tsx:66-68,76``parseIrr``BigInt`, then `isOwed ? -net : net` for magnitude + an `error` tone. **BigInt negation, not `Math.abs`** — IRR never touches a float) |
| 2 | `/fa/nurse/earnings` | `EarningsBalanceHeader` four buckets + weekly/dispute explainer + state-segmented list (`all`/`pending`/`eligible`/`paid`/`clawback_applied`), `PAYOUTS_PAGE_SIZE = 10` |
| 3 | `/fa/nurse/earnings/payouts` | `PayoutHistoryRow` list, newest first. No retry control — deliberate; retry is an admin action |
| 4 | `/fa/nurse/earnings/payouts/[id]` | Reconciliation detail: `gross clawback = net`, amount transferred, masked IBAN, transfer reference, `failureReasonLabelKey()`, and the bookings covered |
| 5 | `/fa/admin/payouts` | Batch list + a Jalali period picker → preview dialog → confirm "run". **The confirm calls `POST admin_payouts/batches` (generate), not process** |
| 6 | `/fa/admin/payouts/[batchId]` | Paginated payout rows, retry a `failed` payout, record a transfer reference |
| — | (none) | The nurse dashboard `/fa/nurse` also reads the balance (`NurseDashboardScreen.tsx:220`) |
Admin screens are additionally hidden behind `canPayout` (`super_admin`/`admin`/`finance`) — a UI hint only.
## API
Shapes, enums and verdicts live in [payouts.md](../integration/domains/payouts.md) — not restated here.
| Call | Endpoint | Live probe (2026-08-02, tokens from `tokens.env`) |
| --- | --- | --- |
| balance | `GET /api/v1/nurse_payouts/earnings_balance` | **200** nurse 1 → `0 / 0 / 3187500 / 212500`, net **`8500000`**; nurse 3 → all zeros |
| earnings list | `GET /api/v1/nurse_payouts/earnings` | **200** nurse 1 → 3 items (bookings 3, 4, 8), states `paid`, `paid`, `clawback_applied` |
| history | `GET /api/v1/nurse_payouts/history` | **200** nurse 1 → 2 payouts; **`failureReason` IS on the wire** (null here) |
| payout detail | `GET /api/v1/nurse_payouts/{id}` | **200** payout 3 → `pending`, net `1487500`, batch 3 `draft`, `initiatedByAdminId: null` |
| tenancy | same, nurse 2 (`09120000002`) reading payout 1 | **404** "Payout not found." — correct (a 403 would confirm the row). A **non-nurse** caller instead gets **403** "Only a nurse can read their payout." (`GetNursePayoutDetailQuery.Handler.cs:22,26,30`) |
| eligible / batches / process / retry / mark_failed | `admin_payouts/*` | **403** "Authorization Error" for **both** `09120000020` (super_admin) and `09120000021` (finance) — see the RBAC gap below |
| ledger balance | `GET /api/v1/nurses/{id}/payable_balance` | **200**`8500000`. Not consumed by the client; tenancy is enforced in the handler (`GetNursePayableBalanceQuery.Handler.cs:22-29`) |
| webhook | `POST /api/v1/webhooks/payouts/{provider}` | server-only reconciliation callback; not client-reachable |
Client chain, verified link by link: page → `services/payouts` barrel (`index.ts`) → `hooks/useNurseEarningsBalance.ts:12`
`apis/index.ts:10` (the one seam, `USE_PAYOUTS_MOCK ? mock : client`) → `apis/clientApi.ts:163``clientFetch`
`NursePayoutsController.cs:33``GetNurseEarningsBalanceQueryHandler`. Every link exists. **The seam selector
picks the mock**, so the real link is never traversed at runtime.
## Rules that must hold
| Rule | Value | Source |
| --- | --- | --- |
| Payout cadence | weekly, config `nurse_payout_interval_days = 7` | [business/10](../../product/business/10-payouts.md) §(d1) |
| Eligibility gate | `completed` **and** `dispute_window_ends_at < now` (72 h) **and** no active refund on the booking | [business/10](../../product/business/10-payouts.md) §(a) + §(d1) |
| One payout per booking | `UNIQUE(booking_id)` on `nurse_payout_booking_links` — the DB is the authority, not an `if` | [business/10](../../product/business/10-payouts.md) §(a), §(d) |
| **Two different "net"s — do not conflate** | a *payout's* `net_amount = gross clawback_applied` is clamped **≥ 0**; the *nurse's* `netPayableBalanceIrr` is the ledger sum and is **SIGNED, never clamped** — showing 0 for a debt lies | clamped: §(d1) · signed: [business/10](../../product/business/10-payouts.md) §(a) ("derived from the ledger — it may go negative") |
| Clawback netting | **whole clawbacks only**, oldest-first, capped at the batch gross; a clawback bigger than the batch stays fully `pending` | [business/10](../../product/business/10-payouts.md) §(d1) |
| IBAN gate | verified **primary** IBAN gates *payment*, not accrual; a nurse without one is skipped with a recorded reason (`EligibleNurseEarningsDto.hasVerifiedPrimaryIban`, `GeneratePayoutBatchResult.skipped`) | [business/02](../../product/business/02-nurse-verification.md) step 6 |
| PAYA vs SATNA | net ≥ `1,000,000,000` IRR ⇒ SATNA, config `payout_satna_threshold_irr` | the **seed** (`PlatformConfigConfig.cs:53`); [business/10](../../product/business/10-payouts.md) §(d1) names the config but carries no number |
| Holiday shifting | period end + processing date shift server-side via `IHolidayCalendar`; **the client never computes one** | [business/10](../../product/business/10-payouts.md) §(a) |
| **Generation is automatic, processing is not** | `WeeklyPayoutGenerationJob` opens a `draft`; the irreversible `process` is always an explicit admin action | [server/CLAUDE.md](../../server/CLAUDE.md) hard rule 12 |
The scheduler half is **verified working in the live DB**: batch 3 was created `2026-07-29T02:58:32Z` with
`initiatedByAdminId: null` and status `draft` — a system-initiated batch, exactly as `WeeklyPayoutGenerationJob.cs:48`
sends `SystemInitiated = true` and `AdminPayoutsController.cs:47` forces it to `false` for API callers.
**Note the business doc is stale here:** §(d1) still says "the weekly cron trigger is DEFERRED — batches are
admin-triggered". Refinement phase 7 shipped it; the running server is the authority, not that line.
## How to test
1. Log in as **09120000001** (nurse 1, the seeded nurse with real payout rows) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/nurse/finance`. **Expect (today):** the mock fixture balance, not `8,500,000` IRR. The masked IBAN
on the following screens reads `IR••••••••••••••••••4821` (`payouts/apis/mockApi.ts:51`) — the live one ends `9012`.
Seeing `4821` is the fastest way to prove you are looking at fixtures.
3. Open `/fa/nurse/earnings`, cycle the five tabs. **Expect (today):** four fixture rows for bookings 50015004.
Tapping "view booking" on one 404s — those ids do not exist in the DB.
4. Open `/fa/nurse/earnings/payouts` → a row → the detail. **Expect (today):** fixture reconciliation, including a
`failed` payout with a reason the real path would have discarded (see gaps).
5. **To see the truth instead**, curl the four nurse endpoints with nurse 1's bearer (§API above). **Expect:**
balance `0 / 0 / 3187500 / 212500`, net `8500000`; payout 3 `pending` in `draft` batch 3.
6. Admin half, as **09120000021** (finance): `/fa/admin/payouts`. **Expect:** the mock batch list renders fine, but
the same call against the API is **403**. Flipping the flag turns this console into a permission wall.
7. Set `MOCK_SCENARIO = 'clawback_heavy'` (`payouts/constants.ts:23`) and reload to exercise the negative-balance
("owed back") treatment. **Expect:** a red/`error`-toned magnitude on `/fa/nurse/finance`, never `0`.
**Seeded-world caveat:** the world is 7 days stale, so nothing is `pending` or `eligible` — nurse 1's buckets are
`0 / 0`. There is no way to observe a fresh accrual without re-seeding. Batch 3 stays `draft` forever because the
process step both 403s and has no UI.
## Known gaps
- `USE_PAYOUTS_MOCK = true` (`payouts/constants.ts:13`) suppresses four working nurse endpoints — H-09 confirmed, six screens + the dashboard widget show fixtures.
- The mock's booking ids `50015004` (`payouts/apis/mockApi.ts:54`) deep-link into the now-real bookings screens; "view booking" 404s.
- `toHistoryItem` hardcodes `failureReason: null` (`payouts/apis/clientApi.ts:60`) although the live wire carries it — on flip, a failed payout loses its reason in the nurse's history.
- `previewPayoutBatch` (`payouts/apis/clientApi.ts:205-227`) sums net amounts client-side and fabricates `processingDate = periodEnd`, `holidayShifted: false`, `skipped: []` — the client computing money and a payout date, against client hard rule 18.
- **No `process` operation exists anywhere in `PayoutsApi`.** The irreversible step has no UI on either path; `/fa/admin/payouts`'s confirm button calls `POST admin_payouts/batches` (generate).
- The mock's `runPayoutBatch` returns batch `status: 'processing'` with payouts `submitted` (`payouts/apis/mockApi.ts:637,653`) while the real generate returns `draft` — the demo shows money moving that the real path never moves, collapsing generate and process into one click.
- Every `admin_payouts/*` op returns **403** for the seeded `super_admin`/`finance` accounts (`DynamicPermissionService.CanAccess` grants on the literal role `admin`) — no one can process a batch today.
- Draft batch 3 (system-generated, 1 payout, **1,487,500 IRR** to nurse 1) is therefore unpayable, indefinitely.
- `DeriveEarningsState` (`PayoutRepository.cs:275-284`) returns `paid` for any un-clawed-back booking that merely **has a payout link**, regardless of that payout's status (the checks are `clawback → payout → dispute window`, and the payout branch never reads `Status`) — booking 3 renders as «پرداخت‌شده» while `paidAt: null`, `transferReference: null` and payout 3 is `pending`. The nurse is told they were paid when no money moved.
- Consequence of the above: booking 3's `1,700,000` IRR falls into **no** bucket — not `pending`, not `eligible` (`GetNurseEarningsBalanceQuery.Handler.cs:33-39`), and not `paidTotal`, which sums only `Status == Paid` payouts (`PayoutRepository.cs:286-289`).
- The four buckets and the headline `netPayableBalanceIrr` come from different sources (booking projection vs. the `nurse_payable` ledger sum) and do **not** reconcile: net `8,500,000` against buckets of `0` pending / `0` eligible / `3,187,500` paid / `212,500` clawback outstanding. Which is authoritative is undocumented, and the unreconciled number is the one on `/fa/nurse/finance` and the nurse dashboard.
- `recordTransferReference` targets `admin_payouts/{id}/transfer_reference` (`payouts/apis/clientApi.ts:266`), which does not exist — the batch-detail reconcile field 404s on flip (REQ-036).
- The client sends `Idempotency-Key` on generate/retry (`payouts/apis/clientApi.ts:233,259`); `AdminPayoutsController` never reads it — decorative, and misleading to a reader.
- `POST admin_payouts/{id}/mark_failed` exists server-side but has no client op — a reconciled bank rejection cannot be recorded from the console.
- `holidayShifted` is always `false` (`payouts/apis/clientApi.ts:123`) because `PayoutBatchDto` carries no flag — the batch detail cannot say a date was shifted off a bank-closed day.
- No payout forecast (REQ-053) — the nurse dashboard's "next batch" line renders nothing on the real path.
- `failureReasons.ts:7` maps exactly one code (`invalid_sheba`); every other bank-rail reason falls back to the generic label.
- Stale doc-blocks assert the opposite of reality: `payouts/constants.ts:5-11` and `payouts/apis/clientApi.ts:149-161` both claim `earnings_balance`, `earnings` and `{id}` do not exist server-side. All three returned 200.
+98
View File
@@ -0,0 +1,98 @@
# Flow — nurse-service-areas
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/04-search-and-matching.md](../../product/business/04-search-and-matching.md) §(a)
**Integration:** [docs/integration/domains/service-areas.md](../integration/domains/service-areas.md) ·
[geography.md](../integration/domains/geography.md)
## What it does
A nurse declares where she will travel: one or more cities, each either **whole-city** or a named district.
Coverage is the geographic half of being findable — with zero areas the nurse has zero rows in
`nurse_search_index` and no family can reach her, however verified and priced she is. It is add/remove only;
there is no edit.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/nurse/practice` | «حرفهٔ من» hub. The «مناطق تحت پوشش» row carries a live count off `areas.total` — [`NursePracticeScreen.tsx:49`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/practice/NursePracticeScreen.tsx) |
| 2 | `/fa/nurse/coverage` | The whole editor in one `'use client'` `page.tsx` (no thin-RSC split) — [`coverage/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/nurse/coverage/page.tsx) |
| 2a | ″ | Existing areas as MUI `Chip`s. A whole-city row renders «تهران · کل شهر»; a district row «تهران · منطقه ۳» (`chipLabel`, `page.tsx:42-47`) |
| 2b | ″ | Zero areas ⇒ an amber warning card, **not** an empty state: «تا زمانی که حداقل یک منطقهٔ تحت پوشش اضافه نکنید، در جستجو نمایش داده نمی‌شوید.» |
| 2c | ″ | Add form = [`CascadingRegionSelect`](../../client/src/components/geography/CascadingRegionSelect.tsx) (province → city → district). **City is the only required field.** |
| 2d | ″ | Remove = chip `onDelete` → MUI confirm `Dialog` («حذف منطقهٔ تحت پوشش؟») |
| — | `/fa/nurse` · `/fa/nurse/profile/preview` | Same `useServiceAreas` query, read-only: the activation checklist's "≥1 coverage area" row ([`useActivationChecklist.ts:46,63`](../../client/src/components/ActivationChecklist/useActivationChecklist.ts)) and the pre-publish dossier |
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| list | `GET /api/v1/nurse_service_areas/list?page&pageSize` | `pageSize` 100 by default; whole-city rows sorted first (`NurseServiceAreaRepository.cs:34`) |
| add | `POST /api/v1/nurse_service_areas/add` | duplicate ⇒ `409`; also fans the area out into the search index in the same transaction |
| remove | `DELETE /api/v1/nurse_service_areas/remove/{id}` | soft-delete + drop this nurse×area's index rows, same transaction |
| geo | `GET /api/v1/geo/{provinces,cities,districts}` | **anonymous**; drives the three cascading selects |
Shapes: [service-areas.md](../integration/domains/service-areas.md) and
[geography.md](../integration/domains/geography.md).
**Chain, verified link by link:** `coverage/page.tsx:9``services/serviceAreas/hooks/*`
[`apis/index.ts:10`](../../client/src/services/serviceAreas/apis/index.ts) (`USE_SERVICE_AREAS_MOCK = false`,
[`constants.ts:8`](../../client/src/services/serviceAreas/constants.ts)) →
[`apis/clientApi.ts:6`](../../client/src/services/serviceAreas/apis/clientApi.ts) → `clientFetch`
[`NurseServiceAreasController.cs:23-36`](../../server/src/API/Baya.Web.Api/Controllers/V1/NurseServiceAreasController.cs)
`Features/ServiceAreas/{Commands,Queries}``NurseServiceAreaRepository`. Every link exists.
## Rules that must hold
| Rule | Where it is enforced |
| --- | --- |
| **`districtId = null` means WHOLE CITY** — an affirmative coverage claim, not missing data. [product/business/04](../../product/business/04-search-and-matching.md) §(a): *"a city-level row (no district) means the whole city"*. INV-3. | Client: the district select's empty «کل شهر» option **is** the choice (`CascadingRegionSelect.tsx:141`); `page.tsx:63` submits `region.districtId` verbatim. Server: `NurseServiceArea.DistrictId` nullable, `isWholeCity = DistrictId is null` (`AddNurseServiceArea…Handler.cs:72`). |
| **…in both directions.** A district search matches that district's rows **plus** every whole-city row; a city-only search matches all of them. | [`SqlNurseSearch.cs:30-31`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs): `r.DistrictId == districtId \|\| r.DistrictId == null`. The projection keeps the null verbatim — [`NurseSearchIndex.cs:44-47`](../../server/src/Core/Baya.Domain/Entities/Search/NurseSearchIndex.cs), written by `SearchIndexMaintainer.NewRow` (`:222`). **Live-verified below.** |
| **One control owns the whole-city choice.** The ui-phase-8 scope toggle was deliberately deleted; an empty district select is a complete, valid submission and never an error. | `coverage/page.tsx:14-24` (the comment records why), `:55-69` — only `cityId == null` is validated |
| **A duplicate `(nurseId, cityId, districtId)` is `409`**, treating `null` as a real value. | Server pre-check `DuplicateExistsAsync``ConflictResult` (`AddNurseServiceArea…Handler.cs:43-47`); DB backstop is a **filtered unique-index pair** (`NurseServiceAreaConfig.cs:21-29`) because SQL Server treats NULLs as distinct. Client fast-path `areaExists` (`types.ts:46-52`) plus a 409 fallback (`page.tsx:80`). |
| **Coverage edits hit the search index in the same transaction** — no lag, no reconciliation job. | `FanOutServiceAreaAsync` / `RemoveServiceAreaRowsAsync` called before `CommitAsync` (`AddNurseServiceArea…Handler.cs:61-62`, `RemoveNurseServiceArea…Handler.cs:39-40`) |
| **Coverage is not part of `is_searchable`.** The gate is `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17). Coverage decides whether a row **exists at all** — the effect is the same (invisible), the mechanism is not. | `SearchIndexMaintainer.cs:177,248-249`. Note [service-areas.md](../integration/domains/service-areas.md) calls coverage "one of its four conditions" — imprecise. |
| **404-not-403 on a foreign area id** (INV-7). | `GetOwnedAsync(id, nurseId)``NotFoundResult` (`RemoveNurseServiceArea…Handler.cs:31-33`) |
| Coverage is independent of `isAcceptingBookings`; a nurse can pause bookings and keep coverage. | separate fields; both feed `NurseBookable` |
## How to test
Log in as **`09120000001`** (زهرا عزیزی, verified nurse) — see [testing-setup.md](testing-setup.md).
1. Go to `/fa/nurse/practice` → tap «مناطق تحت پوشش». **Expect:** the row's count badge matches the number of
chips on the next screen.
2. On `/fa/nurse/coverage`, read the chips. **Expect** (live, 2026-08-02): `تهران · کل شهر`, `اهواز · کل شهر`,
`تهران · منطقه ۱`, `تهران · منطقه ۳`, `تهران · منطقه ۶`**5 areas, whole-city rows first**.
⚠ The seed defines only 3 (whole-city Tehran + districts 1 and 3). Ahvaz (`id 8`) is pre-existing drift on
the shared remote DB and district 6 (`id 9`) was created by **this** verification and could not be removed
(the bearer token expired mid-probe). Treat "3 seeded areas" as no longer true.
3. Add a duplicate: province تهران → city تهران → district left at «کل شهر» → «افزودن منطقه».
**Expect:** the inline red «این منطقه از قبل تحت پوشش شماست.», no request fired (client fast path).
The server agrees — probed directly: `POST add {cityId:101,districtId:null}`**`409 "You already cover
this whole city."`**
4. Add a genuinely new district in Tehran. **Expect:** `200`, a success snackbar «منطقهٔ تحت پوشش اضافه شد»,
the form resets, a new chip appears. Probed: `{cityId:101,districtId:1006}``200`, area `id 9`.
5. Verify the both-directions rule as a guest (no token needed):
`GET /api/v1/search/nurses?service_category_id=1&city_id=101&district_id=1006`.
**Expect:** nurse 1's rows even though — before step 4 — she had **no** district-6 area. Measured:
`total 3`, every row `districtId: null` (the whole-city rows). After step 4 it became `total 6`
3 whole-city rows **plus** 3 district-6 rows for the same three variants (see gap 1).
With no `district_id` at all: `total 9` (3 variants × 3 Tehran areas), matching the boot log's 27 rows / 3 nurses.
6. Remove a chip → confirm in the dialog. **Expect:** «منطقهٔ تحت پوشش حذف شد» and the nurse stops appearing
in a search for that district. **UNVERIFIED live** — the token expired before the `DELETE` landed
(`401 Token is Not Valid`). Code-traced only: soft-delete + `RemoveServiceAreaRowsAsync`.
## Known gaps
- **Whole-city + a specific district in the same city are both accepted, and search then returns the nurse twice.** `DuplicateExistsAsync` (`NurseServiceAreaRepository.cs:22-25`) compares `DistrictId` for exact equality, so `(101, null)` and `(101, 1006)` are distinct rows; `SqlNurseSearch.cs:30-31` then ORs them and does no `Distinct` on `(nurseId, variantId)`. Live-measured: district-6 search went `3 → 6` results, the same three variants listed twice. The customer sees duplicate cards.
- **Duplicate React keys on the search results list.** `search/results/page.tsx:151` keys rows `${nurseId}-${variantId}`, which is not unique once the row above happens. React logs a duplicate-key warning and both cards render.
- **The coverage screen has no error state.** `page.tsx:31` destructures only `{ data, isLoading }`; `isError` is dropped. A failed `list` renders the amber «هنوز منطقه‌ای ثبت نشده» card, telling a nurse with real coverage that she is invisible in search. This violates the phase-1 "error is never empty" convention that `useActivationChecklist` follows (`:51,55`).
- **[docs/integration/domains/service-areas.md](../integration/domains/service-areas.md) is wrong on the conflict rule.** It states that adding `(city, null)` when district rows exist "or the reverse" is a conflict. It is not — probed `200`. Only an exact `(nurse, city, district)` repeat conflicts.
- **No edit and no deactivate.** `NurseServiceAreaDto.isActive` is returned and typed client-side but nothing reads or toggles it — there is no endpoint. Changing a district means remove + add, which silently drops the nurse from search between the two calls.
- **No warning that removing the last area de-lists the nurse.** The confirm dialog says «دیگر برای ویزیت در این منطقه انتخاب نمی‌شوید» regardless of whether it is the nurse's last area, which is a much bigger consequence.
- **`remove` is unverified end-to-end** — see step 6.
- **The shared demo DB has drifted from `DemoWorldDefinitions`.** Nurse 1 now has 5 service areas, not the 3 the seeder defines, and the seeders are idempotent so they will never correct it.
+118
View File
@@ -0,0 +1,118 @@
# Flow — nurse-verification
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse · admin · (public, read-only trust badge) · **Status:** mocked
**Client:** mock · **Server:** partial (nurse half real and live; admin half real code, unreachable — 403)
**Business source:** [product/business/02-nurse-verification.md](../../product/business/02-nurse-verification.md)
**Integration:** [docs/integration/domains/verification.md](../integration/domains/verification.md)
## What it does
A nurse proves who she is and what she is licensed to do — identity, phone binding, professional
credentials, bank ownership — and an admin reviews the manual parts. Until the pipeline says `approved`
the nurse is not bookable and does not appear in search. This is the flow the whole marketplace's trust
claim rests on.
**This is the atlas's clearest "real UI on a mocked service in front of a live server" trap.** Every nurse
screen renders in-browser fixtures from
[`verification/apis/mockApi.ts`](../../client/src/services/verification/apis/mockApi.ts) because
`USE_VERIFICATION_MOCK = true` ([`constants.ts:9`](../../client/src/services/verification/constants.ts)) —
while the server's nurse half answers correctly, right now, to a `curl`. Nothing in the browser ever calls
it. The one flag also holds the working nurse half hostage to the broken admin half.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| B3 status hub | `/fa/nurse/verification` | [`page.tsx`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx) — the canonical view of the **one cached status query** (`useVerificationStatus`). `not_started` CTA → grouped checklist → terminal approved panel. Renders mock-only approve/reject buttons (`:84-95`) |
| checklist | (same) | `VerificationChecklist.tsx` + [`verificationSteps.ts`](../../client/src/app/[locale]/(private-routes)/nurse/verification/verificationSteps.ts) — data-driven; groups server steps into هویت / مدارک حرفه‌ای / بانک. Prepends a **synthetic `mobile_verified` step** (`MOBILE_STEP`, id `0`, always `passed`) that is not a server step — it is satisfied at phone-OTP login |
| B4 identity | `/fa/nurse/verification/identity` | national id (10-digit + checksum) + ID-card image + liveness selfie → `identity_kyc` run, chained `shahkar_match`. The two images are **local captures**, never uploaded |
| B5 credentials | `/fa/nurse/verification/credentials` | one `DocumentUpload` per manual step **in the status** (`MANUAL_CREDENTIAL_CODES`, `page.tsx:32`) + INO number, specialties, credential dates |
| B6 under review | `/fa/nurse/verification/review` | a second focused view of the **same cached query** — never a second fetch |
| admin queue | `/fa/admin/verification` | `useVerificationQueue`; per-step wire rows folded to one row per nurse |
| admin case | `/fa/admin/verification/[nurseId]` | per-step pass/reject + `DocumentViewer`; prev/next off the queue's cached page order |
| admin group root | `/fa/admin/trust` | nav only, no data |
| public | `/fa/search/nurse/:nurseId` | `TrustBadge` + `VerificationPanel` read `getTrustBadge` — see [search-and-discovery.md](search-and-discovery.md) |
`DocumentUpload` resolves its resting state as `state === 'success' || (state === 'idle' && existingDoc != null)`
([`DocumentUpload.tsx:132`](../../client/src/components/DocumentUpload/DocumentUpload.tsx)) — a just-finished
upload wins over stale server metadata, so a re-upload never snaps back to the old file name.
## API
Shapes, enums and the two vocabularies live in
[docs/integration/domains/verification.md](../integration/domains/verification.md). All 20 paths below were
confirmed present in the live OpenAPI doc (178 paths).
| Call | Endpoint | Notes |
| --- | --- | --- |
| status (B3/B5/B6) | `GET /api/v1/nurse_verification` | **live, probed 200.** `clientApi.ts:157``NurseVerificationController.cs:32` |
| start | `POST …/submit` | wired |
| presign / confirm | `POST …/steps/{stepId}/upload_url` · `POST …/steps/{stepId}/documents` | wired; XHR PUT direct to storage + SHA-256 integrity hash (`clientApi.ts:180-208`) |
| automated runs | `POST …/steps/{identity_kyc\|shahkar_match\|bank_account_verification}/run` | wired; each is a separately switchable seam |
| credential details | `POST …/credential_details` | **endpoint EXISTS** (`NurseVerificationController.cs:49-52`) but `clientApi.ts:212` is an empty no-op — see gaps |
| public badge | `GET /api/v1/nurses/{nurseId}/trust_badge` | **live, anonymous, probed 200** |
| admin queue | `GET /api/v1/admin_verifications` | **403 for every seeded admin** |
| admin case | `GET /api/v1/admin_verifications/{nurseVerificationId}` | 403 |
| admin decide | `POST /api/v1/admin_verifications/steps/{stepId}/decide` | 403. **Per-step only** — approval emerges when the last required step passes |
| suspend · scan_expiring | `POST …/{id}/suspend` · `POST …/scan_expiring` | exist server-side, **no client caller** |
| step-type catalog | `GET/POST /api/v1/admin_verification_step_types`, `DELETE …/{id}` | exist, **no client caller**; probed **403** |
| approve / reject / document URL | `POST …/{id}/approve`, `…/reject`, `GET …/documents/{id}/url` | **proposed, do not exist** (REQ-034) — the admin case page wires CTAs to all three |
## Rules that must hold
| Rule | Source |
| --- | --- |
| `status` is the source of truth; `nurse_profiles.is_verified` is **derived** and flips only inside the transaction that confirms every required step passed (INV-18) | [business/02](../../product/business/02-nurse-verification.md); `AdminReviewStepCommand.Handler.cs:74-78` |
| The step catalog is **data, not code**`verification_step_types` rows. 6 seeded, all `is_required`. Adding a regulatory step is one INSERT | [business/02](../../product/business/02-nurse-verification.md) §(a); `VerificationStepTypeSeed.cs` |
| The server computes `isBookable` and names `blockingSteps`; the client must **never** derive bookability from the step array | [integration/verification.md](../integration/domains/verification.md) |
| Search visibility is one gate: `is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active` (INV-17) | `SearchIndexMaintainer.cs:177,248` |
| `expired` is a normal step state, not an error — the credential-expiry scan runs every **24 h** (`verification_expiry_scan_cadence_hours`) and re-gates bookability | [business/02](../../product/business/02-nurse-verification.md) |
| The trust badge exposes credential **types** only; `credential_number` is encrypted and never serialized | [business/02](../../product/business/02-nurse-verification.md) §(c-bis) |
| INO membership locks once submitted (it feeds the public badge) | [integration/verification.md](../integration/domains/verification.md) |
## How to test
1. Log in as **09120000003** (مریم احمدی — the deliberately unverified nurse) — see
[testing-setup.md](testing-setup.md). Boot the API with `Seams__Sms__Provider=mock` or `request_otp` 500s.
2. Open `/fa/nurse/verification`. **Expect:** the B3 grouped checklist renders and a «شبیه‌سازی بررسی ادمین»
block with approve/reject buttons appears at the bottom. That block is the mock tell — it only renders
when `USE_VERIFICATION_MOCK` is true.
3. Walk B4 → B5, then press the mock «تأیید» button. **Expect:** the hub flips to the green approved panel
with a «انتشار خدمات» CTA. **This changed nothing on the server** — reload the page and the mock store
resets to its seed.
4. Now read the server truth for the same nurse:
`curl --noproxy '*' http://localhost:5002/api/v1/nurse_verification -H "Authorization: Bearer $T_09120000003"`.
**Expect (probed 2026-08-02, HTTP 200):** `status: "in_review"`, `isBookable: false`,
`blockingSteps: ["moh_competency_license","criminal_record"]`, and 4 steps — `identity_kyc` passed,
`shahkar_match` passed, `moh_competency_license` in_review, `criminal_record` pending. Compare it with
what step 3 showed you; they are unrelated.
5. Public badge, no auth: `curl --noproxy '*' http://localhost:5002/api/v1/nurses/1/trust_badge`.
**Expect:** `{"nurseId":1,"isVerified":true,"approvedAt":"2026-07-26T10:52:18…","credentialTypes":["criminal_record","moh_competency_license"]}`.
Nurse 3 returns `isVerified:false`, `credentialTypes:[]`.
6. Admin half: open `/fa/admin/verification` as **09120000020**. **Expect:** a populated queue — all of it
in-browser fixtures. The live endpoint is **403**:
`curl --noproxy '*' "http://localhost:5002/api/v1/admin_verifications?page=1&page_size=5" -H "Authorization: Bearer $T_09120000020"`
`403` (probed). Same for `/api/v1/admin_verification_step_types`.
**Seeded-world limits.** No admin can approve anything on the real path (the RBAC gap). Nurse 1 is already
`approved` but returns `steps: []`, so there is no approved-with-checklist case to look at. Nurse 3 carries
only 4 of the 6 seeded step types — `ino_membership` and `bank_account_verification` have no step rows at
all, so the bank branch of the journey cannot be walked against real data. Workaround for the whole flow:
none today; the honest demo is the mock, and the honest server check is `curl`.
## Known gaps
- `USE_VERIFICATION_MOCK = true` ([`verification/constants.ts:9`](../../client/src/services/verification/constants.ts)) suppresses a working real nurse half — 7 nurse-side ops map live, probed-200 routes. One flag covers nurse + public badge + 6 admin ops, so the nurse half cannot be flipped independently.
- `submitCredentialDetails` is an empty no-op (`verification/apis/clientApi.ts:212`) even though `POST /api/v1/nurse_verification/credential_details` exists and `SubmitCredentialDetailsCommand` matches `CredentialDetailsInput` field-for-field. **Flipping the flag today would silently drop every nurse's INO number and specialties.** The client comment, `useSubmitCredentials.ts` and [integration/verification.md](../integration/domains/verification.md) all still say "no nurse-facing endpoint" — stale.
- Every `admin_verifications` and `admin_verification_step_types` route returns **403** for `super_admin`/`finance` (`DynamicPermissionService.CanAccess`). The admin review half of this flow is untestable end to end; the mock hides it completely.
- [`nurse/verification/page.tsx:12`](../../client/src/app/[locale]/(private-routes)/nurse/verification/page.tsx) imports `__mockApproveAll`/`__mockRejectStep` from `apis/mockApi` **unconditionally**. The render is gated but the module edge is not — the mock is bundled into the nurse verification route in every build. This is the one production seam breach in the client.
- With the flag flipped there is **no** way for a nurse to observe the approved flip in a demo: the mock admin controls disappear and the real admin queue 403s.
- `foldQueueRows` (`clientApi.ts:74-96`) folds a per-step wire page to per-nurse items and leaves `stepsPassed: 0`, `stepsTotal: 0`, `hasExpiringCredential: false`, `counts` undefined; `total` stays the per-step count, so the queue's pager is nominal. A nurse's steps can straddle a page boundary (REQ-034, REQ-062).
- `approveVerification`, `rejectVerification` and `getDocumentSignedUrl` target routes that do not exist (REQ-034). The admin case page wires visible approve/reject CTAs to two of them — they would 404 on the real path.
- `AdminVerificationsController` `suspend` and `scan_expiring`, and all three `admin_verification_step_types` routes, have **no client caller** — no suspension UI and no step-catalog editor, so the "data-driven catalog" rule has no admin surface.
- The wire serves `isRequired` per step (confirmed live) but `VerificationStep` (`verification/types.ts:62-71`) omits it, and `progressCounts` assumes every step is required. Adding one optional step type would make the "X از Y" meter wrong.
- No `submittedAt` on `VerificationStatusDto` (REQ-055) → B6 omits the submitted-at line. `TrustBadgeDto` carries no per-step detail (REQ-043) → the public `VerificationPanel` is a summary only. Both confirmed against the live responses.
- The admin case route folder is `[nurseId]` but the value it passes is a **`nurseVerificationId`** (`[nurseId]/page.tsx:74`). Typing `/fa/admin/verification/3` opens verification-case 3, not nurse 3.
- Seeded data: nurse 1 (`approved`) returns `steps: []`, and nurse 3 has no `ino_membership` or `bank_account_verification` step rows — two of the six catalog steps are unexercisable in the demo world.
+128
View File
@@ -0,0 +1,128 @@
# Flow — onboarding-customer
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (a family member / payer) · **Status:** partial
**Client:** real · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [profiles.md](../integration/domains/profiles.md) · [auth.md](../integration/domains/auth.md) · [patients.md](../integration/domains/patients.md)
## What it does
A phone that has just verified an OTP holds a session with **no public role**. This flow is everything
between that moment and a usable family app: pick "I need care" vs "I am a nurse", register the first
person you arrange care for, and — later, prompted by a nudge on Home — fill in your own payer details
and the emergency contact a booking falls back on.
The customer's own identity KYC is deliberately **not** part of it: a customer registers and browses on a
verified phone alone ([business/01 §(a)](../../product/business/01-actors-and-onboarding.md)).
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 0 — route | *(none)* | [`RoleRouter`](../../client/src/components/auth/RoleRouter.tsx) runs after `verify_otp`. `resolveRoleDestination` ([`routing.ts:33`](../../client/src/services/auth/routing.ts)) sends `roles == []` to `/select-role`, carrying `?role=nurse` when that was the login intent |
| 1 — role | `/fa/select-role` | [`SelectRole.tsx`](../../client/src/components/auth/SelectRole.tsx). Two radio cards, «خانواده» / «پرستار»; admin is never offered. `FocusedLayout`, **no `RoleGuard`** — resolving the role is the page's job |
| 2 — gate | `/fa` | [`HomeScreen.tsx:90-94`](../../client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx) — `total === 0``router.replace('/onboarding')`. Waits for a *settled* list so a post-create refetch can't bounce back |
| 3 — welcome | `/fa/onboarding` | [`OnboardingScreen.tsx`](../../client/src/app/[locale]/(private-routes)/(customer-focused)/onboarding/OnboardingScreen.tsx) phase `welcome` — brand moment, one CTA. Not a stepper step |
| 4 — relation | `/fa/onboarding` | phase `relation` — «مراقبت برای چه کسی است؟» `RelationSelect` over `parent`/`spouse`/`child`/`self`, one glyph each |
| 5 — first patient | `/fa/onboarding` | phase `patient``PatientForm` with `relation` pre-set and hidden. On success → `router.replace('/')` |
| 6 — payer details | `/fa/profile` | Customer **account hub**. Reached from Home's `nudge_profile` card ([`HomeScreen.tsx:155-163`](../../client/src/app/[locale]/(private-routes)/(customer)/HomeScreen.tsx)), shown while `me.hasCustomerProfile === false`. Three `FormDialogShell` sheets over **one** `react-hook-form` — personal (نام/نام خانوادگی), language, emergency contact |
`(customer-focused)` is a chrome-free route group: same URL space, `FocusedLayout` strips the bottom nav so
the user cannot tab away mid-setup. It still carries `RoleGuard expected=customer`; `/select-role` does not.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| commit role | `POST /api/v1/me/select_role` | [`clientApi.ts:56`](../../client/src/services/auth/apis/clientApi.ts) → [`MeController.cs:30`](../../server/src/API/Baya.Web.Api/Controllers/V1/MeController.cs) → `SelectRoleCommandHandler`. **Live: 200** re-selecting `customer`; **403** for `super_admin` |
| token rotation | `POST /api/v1/auth/refresh` | [`useSelectRole.ts:26-33`](../../client/src/services/auth/hooks/useSelectRole.ts) rotates immediately after — role claims live **inside** the JWE, so without it the next gated call carries the stale claim. A failed rotation is swallowed by design |
| identity | `GET /api/v1/me` | the only identity source. Live for `…010`: `roles:["customer"]`, `hasCustomerProfile:true`, masked `0912*****10` |
| first patient | `POST /api/v1/patients/create` | [`useCreatePatient.ts`](../../client/src/services/patients/hooks/useCreatePatient.ts) splices the row into every cached list before invalidating, so there is no transient "0 patients" window |
| read profile | `GET /api/v1/customer_profiles/me` | **404 ⇒ `null`, not an error** ([`clientApi.ts:19-26`](../../client/src/services/profiles/apis/clientApi.ts)) — a first-run customer sees an empty form. Live: 404 for a nurse token, 200 for `…010` |
| write profile | `POST /api/v1/customer_profiles/upsert` | **403 for a non-customer** (verified live). Creates on first call, updates after |
Shapes belong to [docs/integration/domains/](../integration/domains/profiles.md); do not restate them here.
`USE_AUTH_MOCK`, `USE_PROFILES_MOCK` and `USE_PATIENTS_MOCK` are all `false` — every call above is real HTTP.
## Rules that must hold
| Rule | Value | Source |
| --- | --- | --- |
| Self-selectable roles | `customer`, `nurse` **only**; any admin sub-role ⇒ **403** | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md); `RoleNames.SelfAssignable`, `SelectRoleCommand.Handler.cs:23-24` |
| A user may hold both public roles | grants are audited via `granted_by`/`granted_at`; a revoked grant is **re-activated**, never duplicated | `SelectRoleCommand.Handler.cs:44-50` |
| Customer KYC is deferred | a customer registers and browses on a verified phone alone; `national_id` is nurse-only | [business/01 §(a)/(c)](../../product/business/01-actors-and-onboarding.md) |
| Patient ≠ customer | the `self` relation still creates a **distinct** patient row; the customer is never collapsed into the patient | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md) |
| Relation enum | `parent` \| `spouse` \| `child` \| `self` (REQ-005, delivered) | [business/01 §(a)](../../product/business/01-actors-and-onboarding.md) |
| PII at rest | phone, name and the emergency contact are encrypted; `/me` masks the phone to `0912*****10` | INV-21; `IFieldEncryptor`, `IdentityDefaults.MaskPhone` |
| Tenancy | the profile is resolved from `ICurrentUser`, never the body | `UpsertCustomerProfileCommand.Handler.cs:17-21` |
No money, no config rate, no deadline is involved in this flow.
## How to test
Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md) for boot, the
OTP and the account list.
**All 8 demo accounts are already onboarded**, so steps 15 cannot be walked with a seeded account. Two
options:
**A — inspect the finished state (any demo customer).**
1. Log in as `09120000010`, land on `/fa`.
**Expect:** Home renders (2 patients seeded), **not** a redirect to `/fa/onboarding`.
2. Open `/fa/profile`.
**Expect:** header «سارا محمدی» + `0912*****10`; the emergency card shows «بهرام محمدی» / `09121110010`.
No `nudge_profile` card on Home, because `hasCustomerProfile` is `true`.
3. Type `/fa/select-role` directly.
**Expect:** the picker renders (it has no `RoleGuard`). Choosing «پرستار» **adds** the nurse role to
this account — a real, permanent write to the shared demo DB. Do not do it casually.
**B — see the genuine first-run state (a fresh phone).**
1. `POST /api/v1/auth/request_otp` with an unused Iranian mobile, e.g. `09129990001`. The handler creates
an **inactive shell `users` row** with no role and no PII beyond the encrypted phone
([`RequestOtpCommand.Handler.cs:39-54`](../../server/src/Core/Baya.Application/Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs)) —
creation happens **before** the SMS send, so a `500` from the telegram relay still leaves the account.
2. `GET /api/v1/dev/last_otp/09129990001`, then log in at `/fa/login`.
**Expect:** `/me` returns `roles: []``RoleRouter` replaces to `/fa/select-role`.
3. Pick «خانواده» → **expect** a 200 from `select_role`, a silent token rotation, then `/fa`, which
immediately replaces to `/fa/onboarding` (zero patients).
4. Welcome → relation → patient. On save **expect** the «ذخیره شد» toast and a landing on `/fa`, now with
the `nudge_profile` card visible because `hasCustomerProfile` is still `false`.
5. Tap the nudge → `/fa/profile` → «اطلاعات شخصی» and the emergency sheet. On save **expect**
`GET /me` to flip `hasCustomerProfile` to `true` and the nudge to disappear.
> This creates a permanent shell account on the **shared** remote demo DB. Prefer a local instance
> ([testing-setup.md](testing-setup.md#the-local-alternative--unverified)) if that matters.
>
> **`curl` on Git Bash mangles Persian in `-d`.** A round-trip upsert sent inline wrote `?????` into the
> DB during this verification (restored). Put the JSON in a UTF-8 file and use `--data-binary @file`.
## Known gaps
- Onboarding never creates a `customer_profile` — it creates a patient and stops. `hasCustomerProfile`
stays `false` and the **only** path to the payer details is Home's dismissible-free `nudge_profile` card
(`HomeScreen.tsx:155-163`). Nothing blocks a customer from booking without an emergency contact.
- `POST /api/v1/customer_profiles/avatar` is live in swagger and implemented
(`CustomerProfilesController.cs:37`) but **no client code calls it**`useUploadAvatar` is imported only
by `nurse/profile/page.tsx:21`. A customer can never set a photo; `ProfileSummary` always shows initials.
- The customer's own `gender` is **unsettable through the app**. `auth/types.ts:20` says "null until the
profile flow (b3) sets it", but `UpsertCustomerProfileCommand` carries no gender field (confirmed against
the live swagger) and no client call sends one. A fresh account's `/me.gender` stays `null` forever.
- `preferredLanguage` is **write-only**. It round-trips (verified live) but nothing on either side reads it
— the UI locale comes from the `/fa|/en` URL prefix and `LocaleSwitcher`. The language sheet at
`profile/page.tsx:91-93` therefore persists a preference the app ignores.
- The upsert has **no PATCH semantics** (`profile/page.tsx:76-79`): every sheet save rewrites the whole
profile *and* `users.Name`/`FamilyName`. Saving only the emergency contact re-sends the name; a stale form
can silently overwrite a name changed on another device.
- Saving any sheet writes `preferredLanguage: values.language`, whose default is `'fa'` when the served
value is `null` (`profile/page.tsx:85`). A customer who opens the language sheet and saves silently
commits `'fa'` they never chose.
- `/fa/select-role` is reachable by any authenticated user at any time and has no `RoleGuard` by design, so
an onboarded customer can permanently add themselves the `nurse` role by typing the URL. Intentional
(`role_add_later_note`), but there is no confirmation step for an irreversible grant.
- The seeded demo world contains **no un-onboarded account**, so this flow's first-run half is untestable
without minting a shell user against the shared DB. See [testing-setup.md](testing-setup.md).
- `useSelectRole` swallows a failed post-select token rotation
(`useSelectRole.ts:30-32`). The role is persisted server-side, but the client keeps a JWE without the new
claim until the fetch layer's silent refresh happens to fire — a role-gated call in between 403s.
+124
View File
@@ -0,0 +1,124 @@
# Flow — onboarding-nurse
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** nurse · **Status:** partial
**Client:** partial · **Server:** real
**Business source:** [product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [nurse.md](../integration/domains/nurse.md) · [profiles.md](../integration/domains/profiles.md)
## What it does
A phone-verified account becomes a listable nurse: pick the nurse role, write the profile families will
read, register the IBAN that earnings land in, and watch one checklist say what is still missing before
the listing goes live. Verification itself is a separate journey — [nurse-verification.md](nurse-verification.md);
this flow is everything around it.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 0 | `/fa/select-role` | [`SelectRole`](../../client/src/components/auth/SelectRole.tsx) — only `customer`/`nurse` offered. Reached programmatically (`resolveRoleDestination` on an empty `roles`), never linked |
| 1 | `/fa/nurse` | thin RSC → `NurseDashboardScreen` → [`DashboardActivationSlot`](../../client/src/app/[locale]/(private-routes)/nurse/DashboardActivationSlot.tsx) → the shared `ActivationChecklist` |
| 2 | `/fa/nurse/profile` | client `page.tsx`; react-hook-form, three `FormSection`s (معرفی / تجربه و تحصیلات / تخصص‌ها) + avatar upload + a `beforeunload` guard on a staged-but-unsaved avatar |
| 3 | `/fa/nurse/profile/preview` | «نمایهٔ عمومی من» — the C3 dossier composed from the nurse's **own** cached queries, so it renders pre-publish with no search-index dependency |
| 4 | `/fa/nurse/bank` | an accounts *section*: submit شبا + holder, watch pending → verified/mismatch, «افزودن حساب دیگر», make-primary |
| — | `/fa/nurse/practice`, `/fa/nurse/services` | the checklist is also mounted above the offerings list (`MyServicesList.tsx:92`) next to `PublishGate` |
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| read profile | `GET /api/v1/nurse_profiles/me` | 404 → `null` (empty form) via `orNull` (`profiles/apis/clientApi.ts:19-26`) |
| save profile | `POST /api/v1/nurse_profiles/upsert` | body carries 5 fields only — see the `avatarUrl` gap |
| avatar | `POST /api/v1/nurse_profiles/avatar` | `multipart/form-data`; `clientFetch` must not set `Content-Type` |
| go live | `POST /api/v1/nurse_profiles/set_accepting_bookings` | the real switch behind `PublishGate`; reindexes in-transaction |
| bank list / add / primary | `GET nurse_bank_accounts/list`, `POST .../add`, `POST .../set_primary/{id}` | `add` is `sensitive` 20/min |
| role | `POST /api/v1/me/select_role` | `RoleNames.SelfAssignable = [customer, nurse]` (`RoleNames.cs:19`) |
Shapes live in [profiles.md](../integration/domains/profiles.md) and [nurse.md](../integration/domains/nurse.md).
`POST nurse_bank_accounts/verify_ownership/{id}` is wired server-side and implemented at
`nurse/apis/clientApi.ts:30` but **no hook or component ever calls it**.
## Rules that must hold
| Rule | Where enforced |
| --- | --- |
| **`is_verified` is never client-settable.** `UpsertNurseProfileCommand` has no such field; the handler's `Apply()` writes only bio/years/education×2/specializations. Only the verification finalize transaction flips it (INV-18) | `UpsertNurseProfileCommand.cs`, `.Handler.cs` |
| **`iban_hash` is UNIQUE platform-wide** — deterministic hash checked in the handler, `UNIQUE(iban_hash)` as the DB backstop | `AddNurseBankAccountCommand.Handler.cs:36-39` |
| **Write-then-masked IBAN.** Every read model returns `ibanMasked` (last-4); the full IBAN is never re-served | [nurse.md](../integration/domains/nurse.md) |
| **A verified *primary* IBAN with `matchedNationalId == true` is the first-payout gate** — not a search gate. No account ⇒ the nurse accrues a balance and is skipped with a recorded reason | [payouts.md](../integration/domains/payouts.md); `product/business/10` §(b) |
| **`is_searchable = is_verified AND is_accepting_bookings AND status != suspended AND variant.is_active`** (INV-17). Bio and avatar are **not** in it | `SearchIndexMaintainer.cs:248-249` |
| A profile row does **not** exist until the first `upsert`; `select_role` only grants the role | `SelectRoleCommand.Handler.cs` |
| Bank `add` requires a nurse profile to already exist ("Create your profile first.") — profile precedes bank | `AddNurseBankAccountCommand.Handler.cs:32-34` |
## How to test
Log in as **09120000001** (زهرا عزیزی, verified nurse) — see [testing-setup.md](testing-setup.md).
1. Open `/fa/nurse`. **Expect:** the «راه‌اندازی حساب» card. **Actual today:** it renders *incomplete* with
«احراز هویت و مدارک» un-passed, because the verification row reads a client mock (see gaps). It never
collapses to the green «فعال در جستجو» state for any account.
2. Open `/fa/nurse/profile`. **Expect:** bio «پرستار سالمند با هشت سال سابقه مراقبت در منزل.», years `8`,
level «کارشناسی», field «پرستاری». A warning card «پروفایل شما هنوز فعال نیست» is shown — also mock-driven,
and wrong for this account (`GET nurse_profiles/me``isVerified: true`).
3. Edit the bio and save. **Expect:** a success toast and the value persisted on reload. Verified live:
`POST nurse_profiles/upsert``200`.
4. Same call with `"isVerified": false, "isAcceptingBookings": false` injected into the body.
**Expect (verified live):** `200` with `isVerified: true`, `isAcceptingBookings: true` — the guard holds.
5. Open `/fa/nurse/bank`. **Expect:** one card, «تأییدشده», «بانک ملت», `••••9012`, primary.
6. Submit `IR820170000000123456789012` (nurse 1's own seeded IBAN) via «افزودن حساب دیگر».
**Expect (verified live):** `400` `{"Iban":["This IBAN is already registered."]}` and no new row. The UI
shows only the generic «ثبت این حساب ممکن نشد…» toast.
7. Open `/fa/nurse/profile/preview`. **Expect:** the C3 dossier rendered from own data. The avatar renders as
a fallback icon, not the photo — see the `file://` gap.
**Seeded-world limits.** Nothing exercises the *first-run* path: all three demo nurses already have a profile,
and nurses 12 already have a verified primary IBAN. To see the empty profile → 404 → blank form, the
"no nurse profile yet" bank rejection, or `select-role`, you need a **new** account, which the phone-OTP
login cannot mint for an unseeded phone. `09120000003` (unverified, `in_review`) is the closest stand-in for
a mid-onboarding nurse; its token in `tokens.env` returned `401` at this stamp, so its state was **not**
re-probed live.
## Known gaps
- **The activation checklist mixes real and mocked truth.** `useActivationChecklist.ts:43-47` folds four real
domains (`profiles`, `catalog`, `serviceAreas`, `nurse`) with one mocked one (`verification`,
`USE_VERIFICATION_MOCK = true` at `verification/constants.ts:9`). The «احراز هویت و مدارک» row is browser
state, not server state.
- **Every nurse looks unverified on every page load.** The verification mock's `steps` starts `[]`
(`verification/apis/mockApi.ts:40`) and `getStatus` returns that aggregate, so `isApproved()` is `false`
for the seeded, server-verified nurse 1 — and the module state resets on every reload/HMR. Consequences:
the checklist never reaches «فعال در جستجو»; `PublishGate` renders the *blocked* branch and hides the real
`set_accepting_bookings` CTA; `/fa/nurse/profile` shows a false «پروفایل شما هنوز فعال نیست» banner.
- **`isSearchVisible` is not the server's gate, despite the component saying it is.**
`useActivationChecklist.ts:35-41` claims the four rows "are exactly the server's `is_searchable` gate". They
are not: the `profile` row (`bio.trim() !== '' && avatarUrl != null`, `:31-33`) is client-invented and the
server never reads it, while `is_accepting_bookings` — which *is* in the gate — is excluded from `searchRows`
and tracked separately. A nurse can pass all four rows and still be invisible.
- **The avatar URL is unrenderable on the default seam.** `LocalDiskObjectStorage.GetUrl` returns
`new Uri(localPath).AbsoluteUri` (`LocalDiskObjectStorage.cs:51`), so `nurse_profiles/me` serves
`file:///C:/Users/.../avatars/nurse/1/….png` (verified live). A browser cannot load `file://` from an
`http://` page — the `<Avatar>` on `/fa/nurse/profile` and the preview both fall back to the placeholder
icon, while the checklist's `profile` row counts the non-null URL as passed.
- **`avatarUrl` on the upsert input is dead on the real path.** `UpsertNurseProfileInput.avatarUrl`
(`profiles/types.ts:40`) is populated by the form (`nurse/profile/page.tsx:154`) but
`profilesClientApi.upsertNurseProfile` (`profiles/apis/clientApi.ts:85-95`) never sends it. Harmless today
(the multipart route already persisted it) but it is a mock-era field the real client silently drops.
- **Duplicate IBAN is `400`, not `409`.** [nurse.md](../integration/domains/nurse.md) says "the second `add`
returns a `409`"; the handler returns `FailureResult``400` with a field error (verified live). The
integration doc is wrong.
- **The duplicate-IBAN message never reaches the nurse.** `nurse/bank/page.tsx:51` toasts the generic
«ثبت این حساب ممکن نشد. شبا را بررسی کرده و دوباره تلاش کنید.» and discards the server's
`{"Iban":["This IBAN is already registered."]}`, so a nurse re-entering their own IBAN is told to check it.
- **The ownership inquiry ignores the national id.** `MockBankAccountOwnershipVerifier` decides from the IBAN
alone (`:21-26`) and matches everything except `Seams:BankOwnership:MismatchIban`. A nurse who has not done
identity KYC has `users.national_id = NULL` (`NurseIdentityContext.cs:9-10`) and still gets
`matchedNationalId = true` — the payout gate opens on a claim nothing checked.
- **`verifyOwnership` is a dead seam op on the client.** Declared (`nurse/types.ts:41`), implemented
(`nurse/apis/clientApi.ts:30`), server route live — but no hook exists, so a `mismatch` account has no
re-inquiry affordance; the UI only offers "re-enter the IBAN".
- **`/fa/select-role` cannot be reached with any seeded account** — all 8 demo users already hold a role, so
the role picker is untestable end-to-end today.
- **The bank page polls forever on a stuck inquiry.** `useNurseBankAccounts` refetches every 2 s while any
account is `pending` (`nurse/constants.ts:14`) with no ceiling and no timeout copy.
+119
View File
@@ -0,0 +1,119 @@
# Flow — partner-center
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** partner-center owner / center admin («مدیر مرکز») · **Status:** mocked
**Client:** mock · **Server:** partial
**Business source:** [product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md)
**Integration:** [docs/integration/domains/partner-center.md](../integration/domains/partner-center.md)
## What it does
A licensed nursing center («مرکز») sponsors nurses onto Balinyaar and — when it is the **merchant of
record** — is the legal seller: it issues the customer invoice and receives settlement, with Balinyaar's cut
booked as a commission against it. The portal is the center owner's own read-only window: who they sponsor,
which bookings they legally cover, and what they are owed. It is a **separate authz scope from `/admin`**.
**The core finding:** there is **no partner-facing controller**. The server ships one admin controller
(`admin/partner-centers`), one center-scoped aggregate (`centers/{id}/dashboard`), and an internal MoR
resolver. The portal's five `/centers/me*` reads do not exist, so all six screens run on client fixtures.
## Screens
All six sit inside `PartnerLayout` (5-tab `MobileShell`, no bell). Every page's access gate is
`useMyPartnerCenter()` — which today resolves a **mock** center for any caller.
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/partner` | [`PartnerHomeScreen.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/partner/PartnerHomeScreen.tsx) — MoR chip, onboarding banner (`draft`/`pending`/`suspended`), license block (permit · technical-director licence · Enamad · legal type), masked settlement IBAN (MoR only, `:101-103`) |
| 2 | `/fa/partner/nurses` | Sponsored roster — name + verification `StatusChip`, unpaginated (bounded set) |
| 3 | `/fa/partner/bookings` | Read-only sponsored-bookings list; URL-synced status filter over 7 wire codes; translated labels only |
| 4 | `/fa/partner/bookings/[id]` | Scoped detail (REQ-064) — patient display name, date, `StatusTimeline`. **No clinical content, no address, no money** |
| 5 | `/fa/partner/settlement` | MoR drives the whole view: non-MoR → `settlement_not_mor` state; MoR → per-booking commission invoices (`PartnerSettlementRow`), masked IBAN, PDF link, client-side CSV of the loaded page |
| 6 | `/fa/partner/more` | Center identity + MoR chip, appearance/language, sign-out |
`layout.tsx:15` wraps the shell in a `RoleGuard` **with no `expected` role** — partner is deliberately not an
`AppRole`; the guard only hardens `/me` hydration.
## API
Chain traced: page → `useMyPartnerCenter` ([hooks/useMyPartnerCenter.ts:14](../../client/src/services/partnerCenter/hooks/useMyPartnerCenter.ts))
`partnerCenterApi` selector (`apis/index.ts`) → **`USE_PARTNER_MOCK = true`**
([constants.ts:10](../../client/src/services/partnerCenter/constants.ts)) → `mockApi.ts:276`
`centerById(MOCK_MY_CENTER_ID = 1)`. The real branch exists but points at routes that 404. Shapes live in
[partner-center.md](../integration/domains/partner-center.md) — not restated here.
| Call | Endpoint | Notes |
| --- | --- | --- |
| Portal: my center / nurses / bookings / booking detail / settlement | `GET /api/v1/centers/me{,/nurses,/bookings,/bookings/{id},/settlement}` | **Phantom — 5 routes.** `clientApi.ts:105-123`. Probed live: `GET /centers/me` as owner `09120000030`**404** (no such route in swagger; only `/centers/{id}/dashboard` exists) |
| The one real portal read | `GET /api/v1/centers/{id}/dashboard` | **Wired, and it works.** Probed as `09120000030`**200**: `«مرکز پرستاری آرامش»`, `isMerchantOfRecord true`, `settlementIbanMasked "••••7777"`, `sponsoredNurseCount 1`, `sponsoredBookingCount 2`, `invoiceCount 2`, one inline nurse (`nurseProfileId 2`). **No client code calls it** |
| Tenancy | same | Probed as customer `09120000010`**403 "This dashboard belongs to another center."** ([GetCenterDashboardQuery.Handler.cs:26-28](../../server/src/Core/Baya.Application/Features/PartnerCenters/Queries/GetCenterDashboard/GetCenterDashboardQuery.Handler.cs)) — keyed on `partner_centers.admin_user_id`, **not** a role |
| MoR resolver (HTTP) | `GET /api/v1/internal/bookings/{bookingId}/center` | Exists ([InternalCentersController.cs:23](../../server/src/API/Baya.Web.Api/Controllers/V1/InternalCentersController.cs)) but is `DynamicPermission`. Probed as `super_admin`**403 "Authorization Error"** — unreachable over HTTP in the demo |
| MoR resolver (in-process) | — | **This is the path that actually runs.** [`IssueInvoiceCommand.Handler.cs:54`](../../server/src/Core/Baya.Application/Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs) calls `ResolveCenterForBookingAsync`, then sets `IssuingEntityType` (`:63`) and `PartnerCenterId` (`:64`) |
| Admin CRUD / verify / sponsor / set-active | `GET·POST·PATCH /api/v1/admin/partner-centers…` | 7 wired routes, all `DynamicPermission` + sensitive → **403 for every seeded admin** (see [testing-setup.md](testing-setup.md)). Client half is behind the same mock flag |
| Admin roster read | `GET /api/v1/admin/partner-centers/{id}/nurses` | **Phantom** (`clientApi.ts:101`) — the server folds nurses into the dashboard aggregate instead |
**The resolver, traced:** `booking → NurseId → NurseProfile.PartnerCenterId → PartnerCenter.IsMerchantOfRecord`
([PartnerCenterRepository.cs:77-106](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Repositories/PartnerCenterRepository.cs)).
A non-MoR sponsor does **not** change the issuer — it falls through to `platform`.
**Proof it works on live data:** `GET /api/v1/invoices/6` (booking 6, nurse علی کریمی, the sponsored nurse) as
customer `09120000011``"issuingEntityType": "partner_center"`, gross `3,200,000`, commission `480,000`,
VAT `48,000`, total `528,000`. The same call for booking 1 (unsponsored nurse) → `"platform"`. The MoR chain
is real end-to-end **on the server**; only the portal that should surface it is fake.
## Rules that must hold
| Rule | Value / invariant | Source |
| --- | --- | --- |
| Merchant of record | `isMerchantOfRecord = true` → the **center** is the taxable seller and invoice issuer; `false` → sponsor only, platform issues | [business/13 §Decisions](../../product/business/13-tax-invoicing-and-legal.md) |
| Why the vehicle exists | Home nursing is a licensed activity (MoH پروانه تأسیس + مسئول فنی). Partnering with a licensed center is what makes online payment and BNPL legal pre-permit | [business/13](../../product/business/13-tax-invoicing-and-legal.md) |
| VAT | 10% on the **commission only**, additive (`480,000 × 0.10 = 48,000`, confirmed on invoice 6) — never on the nurse's fee | [business/13](../../product/business/13-tax-invoicing-and-legal.md) |
| Commission rate | Per-center override of the platform default; seeded center = `0.05`. Snapshotted at compute time | [integration/partner-center.md](../integration/domains/partner-center.md) |
| Settlement IBAN | Write-then-masked — the full value never comes back on any read (`Mask.IbanTail`, repository `:142`) | [integration/partner-center.md](../integration/domains/partner-center.md) |
| Technical director | `technicalDirectorNurseUserId` links to a real verified nurse, not free text — regulation requires a named مدیر فنی | [integration/partner-center.md](../integration/domains/partner-center.md) |
| `set-active` | Suspend/activate, **not** delete — sponsored nurses and past bookings stay resolvable | [integration/partner-center.md](../integration/domains/partner-center.md) |
| Portal scope | Center admin sees **no clinical content, no address, no customer money** — only patient display name, date, status | `partner/bookings/[id]/page.tsx` |
| `onboardingState` | Client-derived from `isActive` + `verifiedAt` (`deriveCenterState`) — the wire never carries the string | [integration/partner-center.md](../integration/domains/partner-center.md) |
## How to test
1. Log in as **09120000030** (بهنام رستگار, the partner-center owner) — see [testing-setup.md](testing-setup.md).
2. **Expect:** you land on the **customer** home. `/me` returns `roles: ["customer"]` only — there is no
partner signal (REQ-038), and `services/auth/routing.ts:60` returns `null` for `/partner`, so even
`?next=/fa/partner` is discarded. Nothing anywhere links to the portal.
3. Type `/fa/partner` in the address bar (locale prefix required).
4. **Expect (this is the tell):** the header reads **«مرکز پرستاری آسان‌گستر»**, permit `MOH-12345`, Enamad
`EN-999`, IBAN `••••0001` — the **mock fixture** (`mockApi.ts:46-59`), *not* the seeded
«مرکز پرستاری آرامش» / `MOH-C-1001` / `ENAMAD-DEMO-771` / `••••7777`. PASS for the flow = you see the mock
center. Seeing the seeded one would mean the flag flipped.
5. `/fa/partner/nurses` → three fixture nurses (زهرا موسوی، مریم رضایی، سارا کاظمی). The seeded reality is
**one** sponsored nurse, علی کریمی (09120000002).
6. `/fa/partner/bookings` → three rows with ids `5001·5002·5003`; open one. **Expect:** a detail screen that
resolves — those ids exist in no database. The seeded sponsored bookings are **6 and 5**, both `completed`.
7. `/fa/partner/settlement` → two commission invoices with a fake مودیان reference and a stub PDF. The real
center's invoices (`invoiceCount 2`) are unreachable from here.
8. **Server-side truth check** (no UI path exists): with the owner's token,
`GET /api/v1/centers/1/dashboard` → 200 with the seeded center. With a customer's token → 403.
9. **MoR check:** `GET /api/v1/invoices/6` as customer **09120000011**`issuingEntityType: "partner_center"`;
`GET /api/v1/invoices/1` as **09120000010**`"platform"`. This is the only place the MoR rule is
observable today.
*Seeded-world caveat:* the owner must be told center id `1` out of band — nothing in the API tells a caller
which center they administer. Any admin-side action (create / verify / suspend / sponsor) is **403** for both
demo admin accounts, so the center cannot be administered at all in this build.
## Known gaps
- `USE_PARTNER_MOCK = true` (`partnerCenter/constants.ts:10`) — all 6 portal screens render fixtures; the demo shows a center, nurses, bookings and invoices that exist in no database.
- 5 portal routes are phantom (`GET /centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`, `clientApi.ts:105-123`) — probed 404. REQ-032 / REQ-033 / REQ-064.
- `MOCK_MY_CENTER_ID = 1` (`constants.ts:13`, used at `mockApi.ts:276`) resolves "my center" for **any** signed-in caller — the portal has zero tenancy today, and `layout.tsx:15` passes no `expected` role.
- No partner signal in `/me` (REQ-038): `/fa/partner` is an orphan route reachable only by typing the URL, and `services/auth/routing.ts:60` makes it an invalid `?next=` target.
- Shape mismatch: the server serves **one aggregate** (`centers/{id}/dashboard`, nurses inline and capped by `DashboardNurseCap`); the portal needs `/me` + paginated splits. It cannot page an inline array.
- `GET /centers/{id}/dashboard` requires a center id the portal cannot discover, and **no client code calls it** — the one working portal endpoint is unused.
- The HTTP MoR resolver (`internal/bookings/{id}/center`) is `DynamicPermission` and 403s for both seeded admins — reachable only in-process from `IssueInvoice`.
- All 7 `admin/partner-centers` routes 403 for the seeded `super_admin`/`finance` accounts, and the admin console (`/fa/admin/partners`) is behind the same mock flag — there is **no** working path to create, verify, suspend or sponsor.
- A center owner cannot read the invoices it issues: `GetInvoiceQuery.Handler.cs:25-27` authorizes the `admin` role or the booking's customer only. The settlement view has no legal read even per booking.
- Two MoR resolvers can disagree: BNPL reads the `bnpl_merchant_of_record` config (`platform`) while invoicing uses `ResolveCenterForBookingAsync` — a MoR center's BNPL order and its invoice would name different sellers.
- Seeded invoices carry `moadianStatus: "pending"`, `moadianReferenceNumber: null`, `pdfUrl: null` — the settlement view's PDF/CSV columns have nothing real to render.
- Center self-onboarding is deferred (`product/business/01-actors-and-onboarding.md:24`); the write-then-masked IBAN flow has never been exercised on a real route.
+108
View File
@@ -0,0 +1,108 @@
# Flow — patient-care-records
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (owns the care plan) · nurse (appends visit notes) · admin (read-only) · **Status:** mocked
**Client:** mock · **Server:** real
**Business source:** none — **no `product/business/` area covers clinical records.** The only product source is
[product/data-model/10-reviews-and-records.md](../../product/data-model/10-reviews-and-records.md). This is the
atlas's largest product-doc hole.
**Integration:** [docs/integration/domains/patient-records.md](../integration/domains/patient-records.md)
## What it does
A family keeps a **care plan** for a patient — medications, daily routine, a task checklist — that outlives any
one booking. A nurse on a confirmed booking reads that plan, ticks the checklist during a visit, and **appends**
one visit note. Nobody edits or deletes a note; the next nurse reads the whole patient history for continuity.
> ⚠ **Two endpoints one character apart.** `care_record` (singular) is the **family-owned plan**
> (`GET`/`PUT`). `care_records` (plural) is the **append-only visit history** (`GET`/`POST`). Different
> resources, different owners, different write verbs. Misreading the `s` silently targets the wrong resource.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| Customer opens the care circle | `/fa/patients` | E1 list → row links to the record |
| **E2 care record** | `/fa/patients/[id]/record` | [`record/page.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/patients/%5Bid%5D/record/page.tsx) — 4 tabs داروها / روتین / سوابق / وظایف; per-item bottom-sheet editing; `canView === false` → non-leaking access-denied card **before** any clinical fetch |
| Nurse opens today's visit | `/fa/nurse/visits/[id]` | [`page.tsx:24`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/page.tsx) mounts the panel under the EVV surface |
| **E3 nurse visit note** | same route | [`NurseVisitNotesPanel.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/NurseVisitNotesPanel.tsx) — checklist + free-text composer + read-only history. Panel renders only when the booking is confirmed-or-beyond (`:36`); composer hides unless `canAppendNote` (`:72`) |
## API
All five ops go hook → [`services/patientRecords/apis/index.ts`](../../client/src/services/patientRecords/apis/index.ts)
**`mockApi`**, because `USE_PATIENT_RECORDS_MOCK = true`
([`constants.ts:13`](../../client/src/services/patientRecords/constants.ts)). The table is the **real half**
([`clientApi.ts`](../../client/src/services/patientRecords/apis/clientApi.ts)) that is compiled but not selected.
Shapes: [docs/integration/domains/patient-records.md](../integration/domains/patient-records.md).
| Call | Endpoint | Live probe (2026-08-02) |
| --- | --- | --- |
| `getRecordAccess` | `GET /patients/{id}/record_access` | **200** — owner `{canView:t, canEdit:t, canAppendNote:f}`; nurse 1 `{t, f, t}`; patient 9999 `{f,f,f, deniedReason:"not_found"}` |
| `getFamilyRecord` | `GET /patients/{id}/care_record` | **200** — but patient 1 returns `{medications:[],routine:[],tasks:[]}` (nothing seeded) |
| `updateFamilyRecord` | `PUT /patients/{id}/care_record` | not probed (write). Owner-only guard at [`UpsertCarePlanCommand.Handler.cs:24`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/UpsertCarePlan/UpsertCarePlanCommand.Handler.cs) |
| `getPatientHistory` | `GET /patients/{id}/care_records?page&pageSize` | **200** — 2 notes for patient 1, newest-first, Persian bodies decrypted |
| `createVisitNote` | `POST /patients/{id}/care_records` | not probed (write). Nurse + qualifying-booking guard at [`WritePatientCareRecordCommand.Handler.cs:39-43`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Commands/WritePatientCareRecord/WritePatientCareRecordCommand.Handler.cs) |
Server: one controller,
[`PatientCareRecordsController.cs`](../../server/src/API/Baya.Web.Api/Controllers/V1/PatientCareRecordsController.cs),
five actions, all `[Authorize]`, all five handlers present.
**REQ-027 is delivered.** Every client comment claiming "no wire endpoint exists"
([`clientApi.ts:61-62`, `:67`, `:71`, `:87`](../../client/src/services/patientRecords/apis/clientApi.ts)),
plus [`constants.ts:6-8`](../../client/src/services/patientRecords/constants.ts) and
[`types.ts:11-13`](../../client/src/services/patientRecords/types.ts), is **stale** — contradicted by a live 200.
## Rules that must hold
| Rule | Where enforced | Verified |
| --- | --- | --- |
| **Append-only.** No edit, no delete of a visit note — there is no such endpoint and none may be added | controller has only `POST` + `GET` on `care_records` | ✅ read |
| **Encrypted at rest.** Bodies leave the repo as ciphertext (`CareRecordCipherRow.BodyEncrypted`) and are decrypted only after the access check | [`GetPatientHistoryQuery.Handler.cs:56-65`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/Queries/GetPatientHistory/GetPatientHistoryQuery.Handler.cs) | ✅ read |
| **Patient-scoped, not booking-scoped** — a new nurse reads the whole history | `GetPatientHistoryAsync(patientId, …)` | ✅ live (2 notes span bookings 3 and 4) |
| **One access resolver** — edit = owning customer, append = nurse with a confirmed booking, view = either or admin | [`PatientAccess.cs:24-50`](../../server/src/Core/Baya.Application/Features/PatientCareRecords/PatientAccess.cs) | ✅ live |
| `record_access` **never 403s** — always 200 with non-leaking flags (deliberate INV-7 exception) | same | ✅ live (patient 9999 → 200) |
| **Tenancy mismatch is a 404, never a 403** ([server/CLAUDE.md hard rule 20](../../server/CLAUDE.md)) | — | ❌ **VIOLATED — see gaps** |
| Nurse view never wires plan editing | [`NurseVisitNotesPanel.tsx`](../../client/src/app/%5Blocale%5D/%28private-routes%29/nurse/visits/%5Bid%5D/NurseVisitNotesPanel.tsx) imports no `useUpdateCareRecord` | ✅ read |
| Client hard rule 19 — never leak clinical data; gate access **before** any clinical fetch | `useRecordAccess` gates `enabled:` on the other two queries (`:42-43`) | ✅ read |
Note counts are not load-bearing; there are no money or config numbers in this flow.
## How to test
1. Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md).
2. Go to `/fa/patients`, open a patient, tap پروندهٔ مراقبت (`/fa/patients/1/record`).
**Expect (today, mocked):** all four tabs populated — «متفورمین ۵۰۰», a routine list, a task list, and a
month-grouped سوابق timeline. **None of it is on the server**; it is
[`mockApi.ts:47`](../../client/src/services/patientRecords/apis/mockApi.ts) `defaultFamilyRecord`, and it
**resets on every page reload**.
3. Navigate to `/fa/patients/8888/record`.
**Expect:** the access-denied card. `8888` is `MOCK_FOREIGN_PATIENT_ID` (`constants.ts:36`) — a mock-only
sentinel with no real-path equivalent.
4. To see the **truth**, bypass the client:
`curl -s --noproxy '*' "http://localhost:5002/api/v1/patients/1/care_records?page=1&pageSize=10" -H "Authorization: Bearer $TOKEN"`.
**Expect:** `200`, `total: 2`, two Persian bodies by «زهرا عزیزی» — and `taskResults` entries with
**`label: null, done: false`** (a seed defect, see gaps).
`GET /patients/1/care_record``200` with **three empty arrays** — the seeded world has no care plan at all.
5. Log in as `09120000001` (زهرا عزیزی, nurse), open `/fa/nurse/visits/3`, scroll past EVV.
**Expect:** the checklist + composer + continuity history — again all mock. `GET /patients/1/record_access`
with the nurse token returns `canAppendNote: true`, so the real path would also permit the append.
**Seeded-world limits:** no care plan row exists for any patient, so the real path's medications/routine/tasks
tabs and the nurse's checklist would all render empty. Only patients 1 and (nurse 2 / customer 011's infant)
have visit notes. Creating a plan requires a real `PUT /patients/1/care_record`.
## Known gaps
- `USE_PATIENT_RECORDS_MOCK = true` (`patientRecords/constants.ts:13`) while **all five server endpoints are live** — the entire clinical surface both actors see is in-browser fiction that resets on reload.
- **Care-plan shape mismatch blocks the flip.** Server `MedicationDto(long Id, string Name, string? Dosage, string Frequency, string? TimingNote)` ([`CarePlanDtos.cs:11`](../../server/src/Core/Baya.Application/Models/Patients/CarePlanDtos.cs)) vs client `Medication { id: string; doseAmount; doseUnit; frequencyCode; frequencyText; timeOfDay: TimeOfDayCode[] }` ([`types.ts:56-66`](../../client/src/services/patientRecords/types.ts)). Only `name`/`timingNote` survive. The ui-phase-9 structured addendum was never delivered server-side.
- **H-17 ID-TYPE MISMATCH — CONFIRMED, not fixed.** Client `Medication.id` / `RoutineItem.id` / `CareTask.id` are `string` (`types.ts:57,70,78`, mock-seeded `'m1'`/`'r1'`/`'t1'`); the wire is `long Id`. `updateFamilyRecord` `PUT`s the record whole, ids included, so a flip sends `"m1"` where the server binds `long` — the write is unsafe, not just lossy.
- **`RoutineItem.timeOfDay` is an array client-side, a single `string?` server-side** (`RoutineItemDto`, `CarePlanDtos.cs:13`) — multi-slot routine items cannot round-trip.
- **`deniedReason` vocabulary disagrees.** Server emits `"not_authorized"` (`PatientAccess.cs:20`); the client union is `'no_access' | 'not_found'` (`types.ts:117`) and [the integration doc](../integration/domains/patient-records.md) documents `no_access`. A real denial would land on an unmapped code.
- **Structured `taskResults` are discarded in both directions on the real path.** The server accepts them on write (`WriteCareRecordBody.TaskResults`, controller `:63`) and returns them on read (`CareRecordDto.TaskResults`), but `clientApi.ts:37` hardcodes `taskResults: []` and `clientApi.ts:100-103` posts only `{bookingId, body}`, folding the checklist into free text via `composeVisitNoteBody`. The `:36`/`:45` comments saying the wire has no structured field are wrong.
- **Seeded visit notes return `label: null, done: false`.** `DemoLifecycleSeeder.Social.cs:323,330,337` writes camelCase JSON (`[{"label":…,"done":true}]`) while `GetPatientHistoryQuery.Handler.cs:78` deserializes `List<TaskResultDto>` with default (PascalCase) options. Live-confirmed: 3 result rows, all null/false. API-written notes round-trip fine; only the demo data is broken.
- **Tenancy leak: a foreign patient returns 403, not 404.** Live-confirmed — customer `09120000011` on `GET /patients/1/care_records` got `403 "You do not have clinical access to this patient's care records."`, confirming patient 1 exists. `GetPatientHistoryQuery.Handler.cs:56-58`, `GetCarePlanQuery.Handler.cs:23-24`, `UpsertCarePlanCommand.Handler.cs:24-25` all return `ForbiddenResult`. Violates server/CLAUDE.md hard rule 20 and INV-7.
- **No care plan is seeded for any patient** — `GET /patients/1/care_record` returns three empty arrays, so on the real path the nurse's task checklist would always be empty and the E2 plan tabs blank.
- **No `product/business/` file covers clinical records** — no owning business area for encryption, the append-only rule, or the clinical access gate. Everything above is derived from code, not from a product decision.
- **`MOCK_FOREIGN_PATIENT_ID = 8888`** (`constants.ts:36`) is the only way to demo access-denied; the real path has no such sentinel, so that screen state is untested against the server.
- Four stale doc-blocks assert "no wire endpoint exists" / "REQ-027 gap": `clientApi.ts:61-62`, `constants.ts:6-11`, `types.ts:11-16`, `apis/index.ts:7-8`. All four are wrong; the integration domain file is right. (`docs/status/mocks-registry.md`, which recorded the correct verdict, no longer exists — [docs/status/](../status/index.md) is now only an index.)
+141
View File
@@ -0,0 +1,141 @@
# Flow — public front door
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** guest (unauthenticated) · **Status:** partial — what is built works; tier (c) guest browse is not built
**Client:** real (rewrite, gating and `?next=` all verified against a production build) · **Server:** real
**Business source:** no dedicated business area — the depth decision lives in
[product/notes/open-questions.md § "Decided — public guest-browse depth"](../../product/notes/open-questions.md);
the nearest requirement is "a customer can register and browse with only a verified phone" in
[product/business/01-actors-and-onboarding.md](../../product/business/01-actors-and-onboarding.md)
**Integration:** [docs/integration/api-contract.md § The anonymous surface](../integration/api-contract.md) ·
[domains/search.md](../integration/domains/search.md)
## What it does
A visitor with no session lands on `/` and is meant to see a marketing landing — what Balinyaar is, the
service categories, how escrow works, why nurses are trusted — plus a nurse-recruitment CTA and the legal
pages. Everything else redirects to login, carrying the attempted URL so a deep link survives the round
trip. Guest **browse** (searching nurses, opening a public profile without logging in) was deliberately
**not** built: tier (c) is still deferred.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa` (guest) | Middleware **rewrites** — never redirects — to `/{locale}/welcome`, so the URL and SEO canonical stay `/` ([`client/middleware.ts:35-37`](../../client/middleware.ts)) |
| 1 | `/fa` (signed in) | Falls through to the customer Home; a **nurse/admin** hitting `/` is a `RoleGuard` mismatch and is bounced to their own app |
| 2 | `/fa/welcome` | `(public-routes)/welcome/WelcomeScreen.tsx` — RSC with **zero query hooks**; hero, 5 static category tiles (linking to login, not to search), 3-step "how it works" with the verbatim `EscrowNotice`, 4-row trust explainer, nurse CTA (`/login?role=nurse`), footer. A signed-in hit here 307s to `/` (`middleware.ts:40-42`) |
| 3 | `/fa/terms`, `/fa/privacy` | Static RSC legal copy, 8 / 7 sections, both fronted by a «پیش‌نویس» draft banner — **not legally reviewed**. Linked from the welcome footer and the login consent line |
| 4 | `/fa/login` | Phone-OTP entry — see the auth flow, not this file |
| 5 | any unmatched `/fa/*` | `[...rest]/page.tsx` calls `notFound()` → branded `[locale]/not-found.tsx` («صفحه پیدا نشد») |
| — | render throw | `[locale]/error.tsx` («مشکلی پیش آمد») inside the locale providers; `app/global-error.tsx` above it |
| — | `/robots.txt`, `/sitemap.xml` | `app/robots.ts` disallows 13 private roots per locale; `app/sitemap.ts` emits only `''`, `/login`, `/terms`, `/privacy` × 2 locales |
`PUBLIC_PATHS = ['/login','/terms','/privacy','/welcome']`
([`src/constants/routes.ts:210`](../../client/src/constants/routes.ts)), matched with `startsWith`.
**`ROUTES.HOME` ('/') must never be added** — `startsWith('/')` would un-gate every route. That trap is why
the guest root is an exact-match rewrite instead of a `PUBLIC_PATHS` entry.
## API
The front door itself calls **nothing** — welcome, terms and privacy are static Server Components. The 20
anonymous server operations exist for other flows (and for the unbuilt tier c). Shapes:
[api-contract.md](../integration/api-contract.md).
| Call | Endpoint | Notes |
| --- | --- | --- |
| OTP pair | `POST /auth/request_otp`, `POST /auth/verify_otp` | the only anonymous ops the front door actually reaches, via `/login` |
| Reference reads | `GET /catalog/categories`, `/catalog/option_groups`, `/geo/{provinces,cities,districts,tree}` | probed **without a token → 200**. The welcome grid does **not** use them (static i18n labels) |
| Public nurse reads | `GET /nurses/{id}/profile`, `/trust_badge`, `/reviews`, `/review_tags`, `/nurse_variants/get/{id}` | anonymous **today**, but no guest UI consumes them |
| Search | `GET /search/nurses` | anonymous; **requires** `cityId` and `serviceCategoryId` > 0 — bare call returns `400`, so there is no open browse |
| Ping | `GET /ping/get_status`, `/get_status_rate_limited` | anonymous liveness |
| Webhooks | `POST /webhooks/payments/{p}`, `/webhooks_bnpl/{p}`, `/webhooks/payouts/{p}` | provider callbacks, not a UI surface |
| Dev | `GET /dev/last_otp/{phone}` | Development-only, anonymous — see [testing-setup.md](testing-setup.md) |
## Rules that must hold
| Rule | Source |
| --- | --- |
| `/` **forks by auth with a rewrite, never a redirect** — the guest URL and canonical stay `/` | [`middleware.ts:31-37`](../../client/middleware.ts), welcome `generateMetadata` sets `alternates.canonical = '/{locale}'` |
| `'/'` is never in `PUBLIC_PATHS` (the `startsWith` trap) | [`routes.ts:205-210`](../../client/src/constants/routes.ts), client/CLAUDE.md hard rule 16 |
| The middleware auth check is **UX only**`isTokenAlive` cannot read a JWE claim, so any well-formed 5-part token counts as alive. Never a security boundary | client/CLAUDE.md hard rule 17 |
| `?next=` is validated same-origin **and** role-permitting on the way out (`resolvePostLoginDestination`); `/partner*` is never a valid target | [`services/auth/routing.ts`](../../client/src/services/auth/routing.ts) |
| The escrow line on the landing is **product-mandated verbatim fa copy**, never paraphrased | `EscrowNotice.tsx:8-9` |
| Guest browse depth = tiers (a)+(b) only; tier (c) is deferred pending REQ-066/067 + a privacy sign-off | [open-questions.md §3.1](../../product/notes/open-questions.md) |
| Welcome must never wait on an API call — RSC, zero query hooks in the whole tree | `WelcomeScreen.tsx:22-30` |
**Tier (c) is still deferred — confirmed.** REQ-066/067 remain open, but *narrower than filed*: both
endpoints are **already anonymous** on the server (probed below). What is missing is (a) the rate limit —
`SearchController`/`NursesController` carry no `[EnableRateLimiting]`, so guest traffic falls to the 100/min
global per-IP limiter — and (b) the privacy review of the profile payload. See
[domains/search.md:80-81](../integration/domains/search.md). No guest search/profile route exists in the
client.
## How to test
1. Log in as nobody — this flow is the logged-out surface. Boot per [testing-setup.md](testing-setup.md);
use a **private window** (or clear `access_token`/`refresh_token`).
2. **Prove the anonymous surface, no token.** All seven returned `200`:
```bash
for u in nurses/1/profile nurses/1/trust_badge "nurses/1/reviews?page=1&pageSize=2" \
catalog/categories geo/provinces ping/get_status nurses/3/profile; do
curl -s --noproxy '*' -o /dev/null -w "$u %{http_code}\n" "http://localhost:5002/api/v1/$u"; done
```
**Expect:** seven `200`s. `nurses/1/trust_badge``isVerified: true`, `credentialTypes:
["criminal_record","moh_competency_license"]`; `nurses/1/reviews` → `averageRating: 5.00,
publishedCount: 1`. A bare `GET /search/nurses` → **`400`** («City Id must be greater than 0») — that is
correct, not a defect.
3. Open `http://localhost:3000/fa/welcome`. **Expect:** the marketing landing — brand lockup + tagline,
«شروع کنید» CTA, 5 category tiles, «چطور کار می‌کند» with the escrow callout, the trust card, the nurse
CTA, and terms/privacy footer links. Tab title is plain «بالین‌یار» (welcome sets no `title`).
4. Open `/fa`. **Expect:** the *same* body as step 3 with the URL still `/fa` — the rewrite, not a redirect.
5. Open `/fa/terms` and `/fa/privacy`. **Expect:** 8 and 7 numbered sections, each under the blue
«این متن پیش‌نویس است…» draft banner.
6. Open `/fa/zzz-nope` **logged out**. **Expect:** `307 → /fa/login?next=%2Fzzz-nope` — the auth gate runs
before routing, so a guest never reaches the branded «صفحه پیدا نشد» card. Log in first to see it.
7. Open `/fa/bookings` logged out. **Expect:** `307 → /fa/login?next=%2Fbookings`.
8. `curl /robots.txt` and `/sitemap.xml`. **Expect:** `200`; robots disallows `/*/bookings`, `/*/nurse`,
`/*/admin`, … ; the sitemap lists exactly 4 URLs × 2 hreflang alternates.
> **Run these against a production build, not `next dev`.** `cd client && npm run build && PORT=3001 npm run start`.
> Under `next dev` bare `/` returns `404` (a Turbopack root-path quirk) and stale `.next/` prerenders can be
> served ahead of the middleware, which makes every gate above look broken when it is not.
**Verified 2026-08-02 against `npm run build` (exit 0) on `:3001`:** `/``307 → /fa` · guest `/fa``200`
and byte-identical to `/fa/welcome` bar the URL (213 894 vs 213 906 chars) · `/fa/{bookings,profile,wallet,
admin,nurse,select-role,zzz-nope}` → `307 → /fa/login?next=…` each · `/bookings` → `307 → /fa/bookings` ·
`/robots.txt` and `/sitemap.xml``200`.
## Known gaps
- **`next dev` cannot be used to check any of this — and it looks broken when it is not.** On the dev
server every gate above appeared to fail: `/fa` served the customer Home's metadata
(`<title>اپلیکیشن خانواده | بالین‌یار</title>`) instead of the welcome page, `/bookings` returned `200`
rather than `307`, `/fa/{profile,wallet,admin,nurse,select-role}` all returned `200`, and no
`Link: …hreflang` header was emitted even though `middleware.ts:62-65` always copies one on. Every
response carried `x-nextjs-cache: HIT` / `x-nextjs-prerender: 1` — stale prerenders being served ahead of
the middleware. A fresh `npm run build` + `npm run start` on `:3001` reproduced **none** of it; all gates
behave exactly as coded. The gap is in the dev-server experience, not the product: anyone auditing guest
routing against `next dev` will file bugs that do not exist.
- `GET /api/v1/nurses/{id}/profile` returns `avatarUrl` as a **`file:///C:/Users/<user>/AppData/Local/Temp/
balinyaar-object-storage/avatars/nurse/1/…png`** — a local filesystem path, to an **anonymous** caller.
A browser cannot load it, and it discloses the host path. Deployed it becomes `file:///app/data/…`,
equally unusable. Blocks REQ-067 and breaks any avatar on a public profile.
- `GET /api/v1/nurses/3/profile` returns `200` **anonymously for the unverified nurse** (مریم احمدی,
`in_review`, `isBookable: false`) — the persona that "must never appear in search". Search correctly
hides her; the by-id profile read does not. Enumerable by incrementing `nurseId`.
- No anonymous rate limit on the public reads: `SearchController` and `NursesController` carry no
`[EnableRateLimiting]`, so guest traffic falls to the shared 100/min global per-IP limiter (REQ-066).
- `/terms` and `/privacy` ship **placeholder legal copy** behind a draft banner — flagged for human/legal
review since ui-phase-3 and still unreviewed.
- Tier (c) unbuilt: there is no guest search-results screen, no public nurse profile route, and no
guest→login handoff at the «درخواست رزرو» tap. The welcome category tiles link to `/login`, not `/search`.
- `welcome/opengraph-image.tsx` is **Latin-only**`ImageResponse`'s fallback font has no Persian glyphs,
so the fa OG card cannot show the Persian tagline until a Mikhak font buffer is embedded.
- `/fa/welcome` has **no in-app link**`ROUTES.WELCOME` appears only in `middleware.ts`. It is reachable
only as the rewrite body of `/` or by typing the URL, so the rewrite failing hides the page entirely.
- `NEXT_PUBLIC_SITE_URL` is unset in `.env.development`, so dev `robots.txt`/`sitemap.xml`/OG tags fall back
to `http://localhost:3000`. Correct in `.env.production`; only a dev-preview caveat.
- `GET /api/v1/dev/last_otp/{phone}` is anonymous and Development-only, and the deployment runs as
Development — so it is live on `api.balinyaar.ir`, returning any registered phone's login code.
+119
View File
@@ -0,0 +1,119 @@
# Flow — reviews
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (author) · admin (moderator) · public/anonymous (reader) · **Status:** partial
**Client:** real · **Server:** real (moderation half unreachable — RBAC)
**Business source:** [product/business/11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md)
**Integration:** [docs/integration/domains/reviews.md](../integration/domains/reviews.md)
## What it does
After a visit is finished, the family rates the nurse 15, optionally writes a note and picks trait tags. The
review is **not public on submit** — it is pre-screened, parked in `pending_moderation`, and only an admin
decision makes it visible. The nurse's public star average is derived from published reviews only, and is
**recomputed from source** on every moderation transition so hiding a bad review actually lowers the average.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| 1 | `/fa/bookings` | Completed-and-unreviewed booking shows a compact star-strip CTA — [`BookingsScreen.tsx:16,233`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/BookingsScreen.tsx) gates it on `useReviewEligibility` |
| 2 | `/fa/bookings/[id]` | CTA «ثبت نظر» / passive «در حال بررسی» — [`[id]/page.tsx:52`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/%5Bid%5D/page.tsx), `useMyReviewForBooking` fires only when `completed`/`closed` |
| 3 | `/fa/bookings/[id]/review` | The form: `RatingInput` + body (2000 chars) + `ReviewTagSelector`; moderation notice shown **before** submit ([`review/page.tsx:169-174`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/bookings/%5Bid%5D/review/page.tsx)). Already-reviewed → a read-only state card, never a second form (`:93`) |
| 4 | `/fa/search/nurse/[nurseId]` | «نظرات» tab — aggregate + infinite published list ([`page.tsx:288`](../../client/src/app/%5Blocale%5D/%28private-routes%29/%28customer%29/search/nurse/%5BnurseId%5D/page.tsx)) |
| 5 | `/fa/admin/reviews` | Moderation queue + publish/hide/reject/unpublish dialog ([`admin/reviews/page.tsx:61-62`](../../client/src/app/%5Blocale%5D/%28private-routes%29/admin/reviews/page.tsx)) — **renders an error state today, see Known gaps** |
| — | *(none)* | **The nurse has no screen to read their own reviews.** No route consumes `useNurseReviews` outside step 4 |
## API
Client seam: [`services/reviews/apis/index.ts:11`](../../client/src/services/reviews/apis/index.ts) selects
`reviewsClientApi` because `USE_REVIEWS_MOCK = false`
([`constants.ts:14`](../../client/src/services/reviews/constants.ts)). Shapes:
[docs/integration/domains/reviews.md](../integration/domains/reviews.md).
| Call | Endpoint | Notes |
| --- | --- | --- |
| `getReviewEligibility` [`clientApi.ts:67`](../../client/src/services/reviews/apis/clientApi.ts) | `GET /bookings/{id}/review_eligibility` | `[Authorize]` [`BookingReviewsController.cs:33`](../../server/src/API/Baya.Web.Api/Controllers/V1/BookingReviewsController.cs) → [`GetReviewEligibilityQuery.Handler.cs:24-33`](../../server/src/Core/Baya.Application/Features/Reviews/Queries/GetReviewEligibility/GetReviewEligibilityQuery.Handler.cs). **Probed 200** |
| `getMyReviewForBooking` `clientApi.ts:78` | `GET /bookings/{id}/my_review` | `BookingReviewsController.cs:39`. **Probed 200** |
| `createReview` `clientApi.ts:91` | `POST /bookings/{id}/review` | `BookingReviewsController.cs:26` → [`SubmitReviewCommand.Handler.cs`](../../server/src/Core/Baya.Application/Features/Reviews/Commands/SubmitReview/SubmitReviewCommand.Handler.cs). Not probed (would mutate the demo world) |
| `getNurseReviews` `clientApi.ts:54` | `GET /nurses/{id}/reviews` | **anonymous** — [`NursesController.cs:21,38`](../../server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs). **Probed 200** |
| `listModerationQueue` `clientApi.ts:99` | `GET /admin/reviews/moderation_queue` | [`AdminReviewsController.cs:20`](../../server/src/API/Baya.Web.Api/Controllers/V1/AdminReviewsController.cs) `DynamicPermission`. **Probed HTTP 403** |
| `moderateReview` `clientApi.ts:113` | `PATCH /reviews/{id}/status` | [`ReviewsController.cs:32-33`](../../server/src/API/Baya.Web.Api/Controllers/V1/ReviewsController.cs) `DynamicPermission`. **Probed HTTP 403** (authz fires before the handler) |
| *(no client method)* | `GET /nurses/{id}/review_tags` | anonymous, `NursesController.cs:44`. **Probed 200** — returns the code/labelFa/labelEn/count/percentage rollup. **Unwired** |
| *(no client method)* | `POST /reviews/{id}/tags` | `ReviewsController.cs:27`, plain `[Authorize]`. Probed with a customer token: **404 "Review not found"** — authz passes, no UI calls it |
## Rules that must hold
| Rule | Value | Source of truth |
| --- | --- | --- |
| Rating range | `1``5` | DB CHECK `CK_Reviews_Rating` (**HARDCODED**) · [business/11](../../product/business/11-reviews-trust-and-safety.md) |
| One review per booking | `UNIQUE(booking_id)` on `reviews.Reviews` | `ReviewConfig.cs` (**HARDCODED DB**); handler pre-checks and returns `409` (`SubmitReviewCommand.Handler.cs:57`) |
| Reviewable only when finished | booking status `completed` or `closed` | `SubmitReviewCommand.Handler.cs:54`; eligibility is **server-decided**, the client never infers it |
| Low-rating alert threshold | `≤ 2``SupportAlertType.LowRating` | CONFIG key `min_rating_for_support_alert`; `SubmitReviewCommand.Handler.cs:112-119` |
| Not public on submit | clean text → `pending_moderation` (a human-review *flag*) unless `Seams:ReviewModeration:AutoApproveClean` | [`MockReviewModerationService.cs:28`](../../server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/MockReviewModerationService.cs); the key is **absent from `appsettings*.json`** so the default `false` holds |
| Only `published` is public and counted | hidden/rejected/pending excluded from both the list and the aggregate | `ReviewModerationStatus.cs:12-22`; **verified live** — nurse 2 holds a seeded hidden 1★ and returns `averageRating 0.00 / publishedCount 0` |
| **Aggregate recomputed from source, never incremented** | `COUNT`/`SUM(rating)` over the nurse's published reviews *excluding* the transitioning row, then the row's **new** status folded in; average rounded to 2 dp away-from-zero | [`RecomputeNurseRating.cs:33-43`](../../server/src/Core/Baya.Application/Features/Reviews/RecomputeNurseRating.cs) — **verified in the handler, no `+= 1` path exists.** Called by `ModerateReviewCommand.Handler.cs:45` on **every** transition and by an auto-published/auto-hidden submit (`SubmitReviewCommand.Handler.cs:101`) |
| Recompute is atomic with the transition | status change + aggregate + b7 search-index reindex staged on one unit of work, one `CommitAsync` | `ModerateReviewCommand.Handler.cs:42-47`; `RecomputeNurseRating.cs:46` |
| Public row carries no author | probed payload is `{id, rating, body, tagCodes, createdAt}`**no author field at all**, stronger than the masking REQ-026 describes | live probe of `GET /nurses/1/reviews` |
| Tag codes are codes | labels are i18n keys; never render a raw code | `review/page.tsx:123,202` fall back to the code only when the key is missing |
## How to test
1. Log in as **09120000010** (customer Mohammadi) — see [testing-setup.md](testing-setup.md).
2. Open `/fa/bookings`. **Expect:** bookings 1, 2, 3, 4, 7, 8, 10 listed; only **booking 8** shows the
star-strip «ثبت نظر» CTA (probed: `booking 8 → canReview: true`; bookings 3 and 4 →
`canReview: false, reason: "already_reviewed"`).
3. Open `/fa/bookings/4`. **Expect:** the passive CTA reads «مشاهدهٔ نظر شما», and `/fa/bookings/4/review`
renders the read-only card — 5 stars, the Persian body, chips «وقت‌شناس» + «حرفه‌ای», status chip
«منتشرشده» (`published`). No form.
4. Open `/fa/bookings/8/review`. **Expect:** the form, with the blue moderation notice above it. Submit 4
stars + any body. **Expect:** the page flips to the state card with status `pending_moderation`
(«در انتظار بررسی»). *This is the only genuinely writable review path left in the seeded world.*
5. Open `/fa/search/nurse/1` → «نظرات» tab. **Expect:** average **۵٫۰** and **۱** review — the single
published 5★. The review you just wrote in step 4 must **not** appear.
6. Anonymous check (no login): `curl -s --noproxy '*' http://localhost:5002/api/v1/nurses/2/reviews`.
**Expect:** `averageRating 0.00, publishedCount 0` — nurse 2 carries a seeded **hidden** 1★ and it is
correctly excluded from both the list and the aggregate. This is the from-source recompute proof.
7. Log in as **09120000020** (super_admin) and open `/fa/admin/reviews`. **Expect (today):** an error state,
not a queue — the endpoint returns **403**. See Known gaps.
**Seeded-world caveat:** three reviews exist — booking 4 `published` 5★ (nurse 1), booking 3
`pending_moderation` 4★ (nurse 1), one `hidden` 1★ (nurse 2)
([`DemoLifecycleSeeder.Social.cs:29-85`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleSeeder.Social.cs)).
Because moderation 403s, **no review submitted during testing can ever become public** — the publish half of
the loop is not demonstrable end to end.
## Known gaps
- `GET /admin/reviews/moderation_queue` returns **403** for the seeded `super_admin` (09120000020) and
`finance` (09120000021) — `DynamicPermission` grants on the literal role `admin`; no seeded account holds
it. `/fa/admin/reviews` is therefore a dead screen. (`AdminReviewsController.cs:20`)
- `PATCH /reviews/{reviewId}/status` returns **403** for the same reason (`ReviewsController.cs:33`, probed
against a non-existent id so authz is provably the blocker). **No review can leave `pending_moderation`**,
so the submit→publish loop is untestable and every new review is permanently invisible.
- With `AutoApproveClean` absent from config and moderation 403, `published` is only reachable via the
seeder. A demo customer's review never reaches the nurse's profile.
- `ReviewModerationStatus.Rejected` is unreachable end to end: the submit path maps a banned-word
`ModerationDecision.Reject` to **`Hidden`**, not `Rejected` (`SubmitReviewCommand.Handler.cs:77`), and the
only other producer is the 403'd admin PATCH. The client still maps a `rejected` chip
(`review/page.tsx:48`).
- `GET /nurses/{id}/review_tags` is **unwired** — the "% of reviewers said X" rollup is served by the API
(probed) but no client method exists in `clientApi.ts`; the profile builds chips from per-review `tagCodes`
instead.
- `POST /reviews/{reviewId}/tags` is **unwired** — no screen lets an author amend tags after submit.
- The client hardcodes the five tag codes in
[`services/reviews/types.ts:36`](../../client/src/services/reviews/types.ts) instead of reading the server's
`review_tags` master. A newly seeded tag is invisible to the UI, and an unseeded code makes submit fail with
`Unknown review tag(s)` (`SubmitReviewCommand.Handler.cs:67`).
- **The nurse cannot see their own reviews.** No nurse route consumes any reviews hook — a nurse learns of a
1★ only through the notification the moderation handler dispatches (`ModerateReviewCommand.Handler.cs:54`),
which itself cannot fire while moderation 403s.
- Eligibility distinguishes `not_found` from `not_owner` with a `200`
(`GetReviewEligibilityQuery.Handler.cs:24-27`), letting any authenticated caller probe whether an arbitrary
booking id exists. Contradicts the repo's 404-not-403 tenancy invariant (INV-7).
- The low-rating support alert is raised on submit (rating ≤ 2) but has **no reachable triage surface**
admin support-alert reads are behind the same 403.
- Stale doc comments assert the opposite of the shipped config: `constants.ts:5-12` says "**Mock is primary
this phase**" and `clientApi.ts:50` says "`USE_REVIEWS_MOCK = true`", while the flag is `false`.
`services/reviews/apis/mockApi.ts` (14.6 KB) is now dead behind that flag.
+127
View File
@@ -0,0 +1,127 @@
# Flow — search and discovery
> Last verified: 2026-08-02 against commit `c841bde`
**Actor(s):** customer (guest is aspirational — see gaps) · **Status:** partial
**Client:** partial · **Server:** real
**Business source:** [product/business/04-search-and-matching.md](../../product/business/04-search-and-matching.md)
**Integration:** [docs/integration/domains/search.md](../integration/domains/search.md)
## What it does
A family picks a care category, a city (district optional), an optional same-gender preference, a date
intent and a Toman price range, then browses rating-sorted nurses and opens one nurse's trust dossier
before requesting a booking. Only verified, currently-accepting nurses are ever shown. Discovery is
**filter-only** — there is no free-text search.
## Screens
| Step | Route | Component / notes |
| --- | --- | --- |
| C1 «یافتن پرستار» | `/fa/search` | [`SearchScreen.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/SearchScreen.tsx) + [`useSearchFilters.ts`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/useSearchFilters.ts). `CategoryTile` grid (catalog) · `CascadingRegionSelect` (district blank = whole city) · `GenderToggle allowAny` · `JalaliDateIntentPicker` · Toman price fields (400 ms debounce, `tomanToRial` at the field boundary) · `StickyActionBar` showing the **live result count**. |
| C2 «نتایج جستجو» | `/fa/search/results` | [`results/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/results/page.tsx). Filter set lives in the URL (deep-linkable cache key); tappable recap chips carry the *whole* query string back to C1. `NurseResultCard` grid, load-more grows `pageSize`, `keepPreviousData` so the list never flashes empty. |
| C3 trust dossier | `/fa/search/nurse/[nurseId]` | [`nurse/[nurseId]/page.tsx`](../../client/src/app/%5Blocale%5D/(private-routes)/(customer)/search/nurse/%5BnurseId%5D/page.tsx). Identity header · tappable `TrustBadge``VerificationPanel` dialog · attribute chips · tabs «خدمات» / «نظرات» (published reviews only) · sticky «درخواست رزرو» → `/fa/bookings/request`. |
`sort_static` renders «مرتب‌شده بر اساس امتیاز» as a caption, not a dropdown — rating is the only sort.
## API
| Call | Endpoint | Notes |
| --- | --- | --- |
| C1 live count + C2 list | `GET /api/v1/search/nurses` | anonymous · **snake_case params, `page`/`page_size`** |
| C3 profile | `GET /api/v1/nurses/{id}/profile` | anonymous · one request builds the whole screen |
| C3 reviews tab | `GET /api/v1/nurses/{id}/reviews` | anonymous — [reviews.md](../integration/domains/reviews.md) |
| C2/C3 trust badge | `GET /api/v1/nurses/{id}/trust_badge` | anonymous, **exists, and is not reached** — see gaps |
Shapes live in [search.md](../integration/domains/search.md); do not duplicate them here.
Chain traced: `useNurseSearch` ([`hooks/useNurseSearch.ts:17`](../../client/src/services/search/hooks/useNurseSearch.ts)) →
`searchApi` ([`apis/index.ts:10`](../../client/src/services/search/apis/index.ts), `USE_SEARCH_MOCK = false` at
[`constants.ts:9`](../../client/src/services/search/constants.ts)) →
[`clientApi.ts:73-114`](../../client/src/services/search/apis/clientApi.ts) →
[`SearchController.cs:26`](../../server/src/API/Baya.Web.Api/Controllers/V1/SearchController.cs) →
`SearchNursesQuery` → [`SqlNurseSearch.cs:20`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SqlNurseSearch.cs).
Every link exists. C3: `useNurseProfile``clientApi.ts:116` → [`NursesController.cs:34`](../../server/src/API/Baya.Web.Api/Controllers/V1/NursesController.cs) → `NurseProfileRepository.GetPublicProfileAsync:66`.
## Rules that must hold
| Rule | Where it is enforced |
| --- | --- |
| **`is_searchable` is one gate over four conditions** — verified **and** accepting bookings **and** not suspended **and** the variant is active. Deactivated rows are kept with `is_searchable = 0`, never deleted (INV-17). | [`SearchIndexMaintainer.cs:177,248`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Search/SearchIndexMaintainer.cs) · read gate at `SqlNurseSearch.cs:24` |
| **`districtId = NULL` means whole city, on both sides** (INV-3). A district search matches that district's rows **plus** the NULL rows; a city-only search matches everything in the city. | `SqlNurseSearch.cs:30-31`; supply side in [geography](../integration/domains/geography.md) |
| The index is maintained **inline in each source write's transaction**, not by a job — a publish is visible in search immediately. | `ISearchIndexMaintainer` |
| Price is IRR Rials, integer. Toman exists only in the C1 input fields. | `NurseSearchIndex.Price` (`long`); `useSearchFilters.ts:16-24` |
| `nurse_gender` accepts only `male`/`female`; **omit it for "any"** — there is no `any` value. | [`SearchNursesQuery.Validator.cs:15-18`](../../server/src/Core/Baya.Application/Features/Search/Queries/SearchNurses/SearchNursesQuery.Validator.cs) |
| Date is **intent only** at MVP — carried into the booking request, never used to filter results. | `SearchScreen.tsx:29-31` |
## How to test
Log in as `09120000010` (سارا محمدی, customer) — see [testing-setup.md](testing-setup.md). No token is
needed for the API calls themselves; the *screens* are behind the customer role guard.
1. Open `http://localhost:3000/fa/search`. Pick «مراقبت از سالمند» (category 1) and city تهران.
**Expect:** the sticky CTA reads «مشاهدهٔ ۹ پرستار».
2. Tap it. **Expect:** `/fa/search/results?...` with the header «۹ پرستار» — but only **one** distinct
nurse, زهرا عزیزی, repeated nine times (see the first gap). Her avatar is a broken image.
3. Tap the ✓ تاییدشده chip on any card. **Expect:** a panel listing «پروانه صلاحیت» *and* «نظام پرستاری».
That is the **mock** — the real badge for nurse 1 is `criminal_record` + `moh_competency_license`.
4. Open a card. **Expect:** C3 with 5 services, rating ۵٫۰, one published review, and no distance chip.
5. Verify the whole-city invariant without the browser:
```bash
curl -s --noproxy '*' "http://localhost:5002/api/v1/search/nurses?service_category_id=1&city_id=101&district_id=1006&page=1&page_size=50"
```
**Expect:** `total: 3` — nurse 1 names districts 1001/1003 only, so these are her three whole-city
(`districtId: null`) rows surfacing in a district she never enumerated. PASS is `districtId: null` on
every returned row.
6. Negative control — the unverified nurse must never appear:
`?service_category_id=3&city_id=101`**`total: 0`** (nurse 3, مریم احمدی, is the only category-3
supplier and is `in_review`).
The seeded world supports every step above; unlike the booking flows it is **not** damaged by the
2026-07-26 staleness, because the index is derived, not time-anchored.
## Known gaps
- **The result list is index rows, not nurses.** The index is one row per (variant × area), and neither
`SqlNurseSearch` nor the client de-duplicates. Live: `category=1&city=101``total: 9` for **one**
nurse (3 variants × 3 areas). The copy says «۹ پرستار» ("9 nurses", `messages/fa.json` `search.results_count`
/ `cta_view_results`), so C1's headline count and C2's header both lie to the customer.
- **Duplicate React keys in C2.** `results/page.tsx:151` keys on `` `${nurseId}-${variantId}` ``, which is
not unique once the same variant appears under several areas — the nine live rows collapse to three
distinct keys.
- **C2 and C3's trust dossier is mocked.** `TrustBadge`/`VerificationPanel` call `useNurseTrustBadge`
`verificationApi`, and `USE_VERIFICATION_MOCK = true` (`verification/constants.ts:9`). The mock returns
the same fabricated `['moh_competency_license','ino_membership']` for **every** nurse id. The real
anonymous `GET /nurses/{id}/trust_badge` works (probed: nurse 1 → `criminal_record`,
`moh_competency_license`, `approvedAt` 2026-07-26) and is simply not reached.
- **C3 asserts "verified" for an unverified nurse.** `nurse/[nurseId]/page.tsx:173` renders
`<TrustBadge state="verified">` unconditionally and never reads `profile.isVerified`. `GET /nurses/3/profile`
returns `200` anonymously with `isVerified: false`, full name, bio and price list — so a deep link to
`/fa/search/nurse/3` shows «✓ تاییدشده» for a nurse who is `in_review`. Related to the still-open REQ-067
privacy review; the badge half is a defect regardless.
- **`isVerified: true` is fabricated client-side** at `search/apis/clientApi.ts:101`. Justified by the index
invariant, but it is a trust signal the client asserts rather than one the server served.
- **Avatars are unusable URLs.** `LocalDiskObjectStorage` yields `file:///C:/Users/.../avatars/nurse/1/….png`
(probed on `/nurses/1/profile`), which no browser will load. Every seeded nurse photo renders as the
initials fallback in dev, and the same code path signs the C2 card image.
- **`distanceKm` is always `null`** — `SqlNurseSearch.cs:62-67` hardcodes it because the covering index
carries no coordinate. The distance chip on `NurseResultCard.tsx:46` can never render.
- **`topReviewTag` is never served** (REQ-040), so `clientApi.ts:111` always maps `null` and the card's tag
chip (`NurseResultCard.tsx:128`) is dead code today.
- **`nurseGender: 'female'` is hardcoded** on the C3 profile mapping (`clientApi.ts:154`) because
`NursePublicProfileDto` carries no gender (REQ-042). The page deliberately omits the chip, so the
fabricated value is unused — but it is a live lie in the typed model.
- **No free-text search** (REQ-041). A customer who knows a nurse's name cannot find her.
- **Guests cannot reach any of it.** All three routes sit in `(private-routes)/(customer)` behind
`RoleGuard expected="customer"`, and `PUBLIC_PATHS` does not include `/search`, so the middleware
redirects to `/fa/login`. The endpoints are anonymous; the screens are not (REQ-066).
- **`search/nurses` carries no `[EnableRateLimiting]`** — the one deliberately pre-auth read falls back to
the 100/min global per-IP limiter (REQ-066, narrower than filed).
- **Pagination footgun.** This is the only endpoint declaring `page_size` (snake). Binding is
case-insensitive but not separator-insensitive, so `pageSize=2` binds nothing and silently yields
`Pagination.DefaultPageSize` — which is **50**, not the 20 the client's `SEARCH_PAGE_SIZE` assumes.
Probed: `?…&pageSize=2``pageSize: 50`. `searchClientApi` sends `page_size` correctly; anything else
hitting this route will not.
- **`attributeChips` is empty for every seeded nurse** (probed on nurses 1 and 3), and
`latestReview.authorMasked` is `null`, which the client maps to `''` — the C3 snippet renders a bare
«` · <date>`».
+519
View File
@@ -0,0 +1,519 @@
# Testing setup — boot the stack, get a code, log in
Everything you need to run Balinyaar locally and walk any flow in [index.md](index.md). **Executed, not
transcribed:** every command, URL, status code and console line below was run against this repo on the date
in the stamp. Where a predecessor doc says something different, this file says so and says which is right.
> Last verified: 2026-08-02 against commit `c841bde`, on Windows 11 / .NET SDK 10.0.300-preview / Node 24.11.1.
**Predecessors, and their standing:** [RUNBOOK.md](../../archive/post-phase/refinement/RUNBOOK.md) and
[manual-testing-plan.md](../../archive/post-phase/manual-testing-plan.md) are **superseded by this file**. Both
contain instructions that no longer work — see [What the old docs get wrong](#what-the-old-docs-get-wrong).
---
## The five-minute path
Three terminals. The third one is not optional — see [Getting an OTP](#getting-an-otp).
```bash
# 1 — API. Plain HTTP on :5002. Needs the SMS provider overridden, or login 500s.
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# PowerShell: $env:Seams__Sms__Provider="mock"; dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# 2 — client
cd client && npm install && npm run dev # http://localhost:3000/fa
# 3 — read the OTP (the console does NOT print the code)
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
```
Then open **`http://localhost:3000/fa/login`**, enter `09120000010`, and paste the code from terminal 3.
No database setup step. The committed dev config already points at a seeded remote SQL Server.
---
## Prerequisites
| | Verified working | Notes |
| --- | --- | --- |
| .NET SDK | `10.0.300-preview.0.26177.108` | `NETSDK1057` (preview SDK) is an expected warning |
| Node | `24.11.1` / npm `11.6.2` | README says 18+; 24 is fine |
| SQL Server | none locally | the dev config uses a **remote** instance — nothing to install |
| Docker | **not required** | only for the optional local-DB path, which is *unverified* here |
`dotnet build Baya.sln` completes with **0 errors, 95 warnings** on a clean clone. The warnings are
pre-existing (`NU1903` vulnerability advisories on `Microsoft.OpenApi` / `SQLitePCLRaw`, `NU1510`,
`NETSDK1057`). They are not yours; don't "fix" them.
---
## Configuration — where it lives
**`dotnet user-secrets` is not used and is not read.** The `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj` in `5885280`. Any instruction to `dotnet user-secrets set …` is dead — the command
will error with "could not find UserSecretsId", and even a leftover `secrets.json` on your machine is inert.
| What | Where | Read when |
| --- | --- | --- |
| Server config + keys | [`server/src/API/Baya.Web.Api/appsettings.Development.json`](../../server/src/API/Baya.Web.Api/appsettings.Development.json) | process start |
| Server base file | `appsettings.json` — placeholders only, guarded by `StartupSecretsGuard` | process start |
| Client dev config | [`client/.env.development`](../../client/.env.development) | `next dev` |
| Client prod config | `client/.env.production` | **`next build`** — inlined into the bundle |
| Deployment overrides | [`docker-compose.yml`](../../docker-compose.yml) (`Seams__…` env vars) | container start |
Any server key can be overridden by an environment variable using `__` for `:`
`Seams__Sms__Provider`, `ConnectionStrings__SqlServer`. That is how the five-minute path avoids editing a
committed file. Full matrix: [docs/integration/config-matrix.md](../integration/config-matrix.md).
### The four crypto values, and why they are load-bearing
```jsonc
"IdentitySettings": { "SecretKey": …, "Encryptkey": … } // signs + encrypts the JWE access token
"Seams": { "FieldEncryption": { "Key": …, "HashKey": … } } // decrypts PII; derives users.PhoneHash
```
`Seams:FieldEncryption:Key` and `:HashKey` **must match whatever the target database was encrypted under.**
Every phone, address, IBAN and clinical note in `Baya` was written with the committed values. Boot against
that database with different ones and you get *silent* failure first — every phone lookup misses, so every
login says "no such account" — then `Padding is invalid and cannot be removed` on the first PII read. The
appsettings file carries a `"//"` comment saying exactly this. Do not rotate them.
`IdentitySettings` may be changed freely; it only invalidates tokens already issued.
> **The repo contains live credentials on purpose** — a pre-launch trade for a demo deployment. Rotating them
> is a "Going to Production" step in [DEPLOY.md](../../DEPLOY.md), not a local setup step.
---
## Which database
**The committed dev config points at a remote SQL Server: `87.107.152.16,1433``Baya` (+ `Baya_Logs`).**
It is the same instance the deployed demo uses, it is **already migrated and already seeded**, and it is
shared — your writes are visible to everyone else pointed at it.
Verified reachable (`Test-NetConnection … -Port 1433``TcpTestSucceeded: True`) and the API's
`sql-app` health check reports `Healthy`.
### The local alternative — **UNVERIFIED**
[`server/docker-compose.yml`](../../server/docker-compose.yml) provisions SQL Server 2022 on
`localhost:1433` with the dev-only SA password `Balinyaar_Dev1433`. Point the API at it with:
```bash
export ConnectionStrings__SqlServer="Server=localhost,1433;Database=Baya;User Id=sa;Password=Balinyaar_Dev1433;TrustServerCertificate=True;Encrypt=False;"
```
The API self-migrates and self-seeds on boot, so an empty instance is enough. **This path was not executed
for this stamp** — Docker is not installed on the verification machine. Treat the remote path as the tested
one and this as documented-but-unproven.
Two things that are true either way: the local DB starts empty, so you get a *freshly dated* demo world
(see [staleness](#the-seeded-world-and-how-stale-it-is)); and it is encrypted under whatever
`Seams:FieldEncryption` values you boot with, so it is not interchangeable with the remote one.
---
## Boot
```bash
cd server
Seams__Sms__Provider=mock dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
```
**`http://localhost:5002` — plain HTTP.** `launchSettings.json` binds no TLS. There is no
`https://localhost:5002`, and `dotnet dev-certs https --trust` has nothing to trust; skip it. Swagger is at
`http://localhost:5002/swagger`, the OpenAPI doc at `/swagger/v1/swagger.json` (178 paths).
A healthy boot logs, in order:
```
[INF] Demo world already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
[INF] Demo lifecycle already seeded — no-op. Search index re-derived: 3 nurses, 27 rows.
[INF] Recurring job scheduler starting with 7 job(s): booking_request_expiry, notification_retention,
verification_expiry_scan, no_show_sweep, weekly_payout_generation, moadian_reconciliation,
audit_log_retention
[INF] مودیان reconciliation scanned 8 invoice(s); 0 reached registered
```
Noise you can ignore: ~11 `WRN Entity 'X' has a global query filter …` lines, and two
`WRN HTTP/2 is not enabled for 127.0.0.1:5002` lines (expected — HTTP/2 needs TLS; HTTP/1.1 is used).
### Health
| Endpoint | Expected | Actual |
| --- | --- | --- |
| `GET /healthz/live` | `200 Healthy` | ✅ `200` |
| `GET /healthz/ready` | `200 Healthy` | ❌ **`503 Unhealthy` on Windows** |
`/healthz/ready` fails on the `object-storage` probe, not the database:
```
IOException: The process cannot access the file
'C:\Users\<you>\AppData\Local\Temp\balinyaar-object-storage\healthz\object-storage-probe'
because it is being used by another process.
at LocalDiskObjectStorage.DeleteAsync … at ObjectStorageWriteHealthCheck…
```
`sql-app` reports `Healthy` in the same response. It is a real code defect, not configuration:
`ObjectStorageWriteHealthCheck.cs:29` opens the probe blob with `await using var` and line 33 deletes it
**while the `FileStream` handle is still open**. POSIX `unlink` permits that, so the Linux container passes;
Windows `File.Delete` throws. **Do not read a red `/healthz/ready` as "the app is broken"** — check the
`entries` object. Phase 4 backlog material.
`Seams:ObjectStorage:RootPath` is `""` in dev, which resolves to `%TEMP%\balinyaar-object-storage`
(`LocalDiskObjectStorage.cs:18-20`). Deployed, `docker-compose.yml` sets `/app/data/object-storage`.
### Client
```bash
cd client && npm install && npm run dev
```
`✓ Ready in 6.8s` on `http://localhost:3000`. **Routes are locale-prefixed**: `/fa` and `/en` return `200`,
bare `/` returns **`404` under `next dev`**. That is a Turbopack dev-server quirk on the root path, not a
routing bug — **confirmed by building for production:**
```bash
cd client && npm run build && PORT=3001 npm run start
```
`next build` exits 0. Against `:3001`, `/``307 → /fa`; guest `/fa` returns `200` and renders the welcome
landing itself (byte-for-byte the same page as `/fa/welcome`, differing only in the URL — which is the
middleware **rewrite**, not a redirect); `/fa/search``307 → /fa/login?next=%2Fsearch`. **Never judge
root-path or guest routing from `next dev`.**
`client/.env.development` already carries `NEXT_PUBLIC_API_URL = http://localhost:5002`, correctly on HTTP.
**Do not copy `.env.sample`** — it still says `https://localhost:5002` and will break every call with an
opaque network error.
---
## Getting an OTP
This is where a fresh clone fails, so read the whole section.
### The problem
`appsettings.Development.json` ships with:
```jsonc
"Seams": { "Sms": { "Provider": "telegram", "Telegram": { "BaseUrl": "http://127.0.0.1:5010", … } } }
```
With nothing listening on `:5010`, `POST /api/v1/auth/request_otp` returns **`500 Server Error`**:
```
[WRN] Telegram OTP relay delivery failed for phone ending 0010 — http 502
[ERR] Telegram OTP relay delivery failed (http 502).
at TelegramSmsSender.PostAsync(…) TelegramSmsSender.cs:line 73
```
This is deliberate — the relay fails **loudly** rather than pretending an undelivered code was sent. In the
browser it surfaces as an error toast on the login screen and you never reach the code step.
The code is still generated and persisted before the send, so `dev/last_otp` works even while `request_otp`
500s. That is a usable-but-ugly fallback, not a fix.
### The fix — one of three
| Option | Command | Result |
| --- | --- | --- |
| **A (recommended)** | boot with `Seams__Sms__Provider=mock` | `request_otp``200`, no relay needed |
| B | set `"Provider": "mock"` in `appsettings.Development.json` | same, but shows up in `git status` |
| C | run the Telegram relay | code arrives in Telegram — see below |
Option A verified:
```json
POST /api/v1/auth/request_otp {"phone":"09120000002"} → HTTP 200
{"data":{"otpSent":true,"resendAvailableInSeconds":120,"codeLength":6,"expiresInSeconds":60},"isSuccess":true}
```
### Reading the code
**`GET /api/v1/dev/last_otp/{phone}` is the only way to read the code.** It is anonymous and
Development-only (`404` in any other environment).
```bash
curl http://localhost:5002/api/v1/dev/last_otp/09120000010
# {"data":{"phone":"09120000010","code":"724740"},"isSuccess":true,"statusCode":200,…}
```
The capture bridge is registered only for a capture-safe sender — `mock`, `telegram`, or unset
(`Program.cs:94-100`). Select the real `kavenegar` gateway and the endpoint stops returning codes, by design.
> **The server console does NOT print the OTP.** With `Provider=mock` the line is
> `[INF] MOCK SMS — OTP issued to phone ending in 0002` — the phone suffix only, **no code**. RUNBOOK.md and
> manual-testing-plan.md both promise `MOCK SMS — OTP code 123456 for phone ending in 0001`. That string does
> not exist in the codebase. Verified: zero 6-digit sequences appear anywhere in a full boot+login log.
### Option C — the Telegram relay
```bash
cd telegram-otp-bot && npm start # zero dependencies, no npm install
```
Two things must line up, and on the verification machine **neither did**:
1. `telegram-otp-bot/.env` `API_KEY` **must equal** `Seams:Sms:Telegram:ApiKey` in
`appsettings.Development.json`. On the verification machine it did not: the local `.env` still holds
`ab8984974bc1fe5ce514d0fd74f71c8738b3aed92a7e4d86` — byte-for-byte the key published in `.env.example`.
`TelegramSmsSender.cs:35` hardcodes that exact string as a rejected placeholder, so the relay would
reject the API's calls even with everything running. Copy the appsettings value into `.env`.
2. `api.telegram.org` is filtered in Iran, so `TELEGRAM_PROXY_URL` must point at a working proxy.
It **broadcasts** every code to every configured chat id, so it is a shared inbox for a trusted group, not
an SMS gateway.
### Limits that will bite you
| Limit | Value | Source |
| --- | --- | --- |
| OTP length | 6 digits | `IdentityDefaults.OtpCodeLength` |
| OTP validity | **60 s** | `IdentityDefaults.OtpExpirySeconds` |
| Resend window | **120 s** per phone | `platform_configs.auth_otp_resend_seconds` |
| Wrong attempts | **5**, then `code: "otp_locked"` | `Lockout.MaxFailedAccessAttempts` |
| **Endpoint rate limit** | **5 requests / 60 s per IP** | `otp` policy, `RateLimitingServiceExtension.cs` |
**The `otp` rate-limit policy covers `request_otp` *and* `verify_otp`.** Five combined calls inside one
minute and both endpoints return `429``verify_otp` with an **empty body**, which looks like a crash. A
scripted login is 2 calls, so **you get two logins per minute, total.** See
[Scripting logins](#scripting-logins).
---
## Demo accounts
Read out of [`DemoWorldDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs)
and [`DemoLifecycleDefinitions.cs`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoLifecycleDefinitions.cs)
— the seeders are the authority, not any doc — and each one confirmed by a live `GET /api/v1/me`.
| Phone | `/me` roles | Who | Demonstrates |
| --- | --- | --- | --- |
| `09120000001` | `nurse` | زهرا عزیزی (f) | **verified** · 3 priced variants · whole-city Tehran + districts 1, 3 · 2 credentials · bank account |
| `09120000002` | `nurse` | علی کریمی (m) | **verified** · 2 variants · districts 3, 6, 12 · sponsored by the partner center |
| `09120000003` | `nurse` | مریم احمدی (f) | **unverified** (`status: in_review`, `isBookable: false`, blocking `moh_competency_license` + `criminal_record`) — must never appear in search |
| `09120000010` | `customer` | سارا محمدی (f) | 2 patients · 1 Tehran address · owns 6 of the 8 seeded bookings |
| `09120000011` | `customer` | رضا حسینی (m) | 1 infant patient · 1 address · owns the BNPL + payout-eligible bookings |
| `09120000020` | `super_admin` | نگار مدیری (f) | the full backoffice — **but see the RBAC gap below** |
| `09120000021` | `finance` | کامران مالی (m) | scoped backoffice; the client's `useAdminCapabilities()` shows only money consoles |
| `09120000030` | `customer` | بهنام رستگار (m) | owns مرکز پرستاری آرامش (merchant-of-record). **No partner role**`/me` returns `["customer"]`; navigate to `/fa/partner` manually |
Admin sub-roles are **server-granted**`POST /me/select_role` only accepts `customer` and `nurse`
(`RoleNames.SelfAssignable`).
### ⚠ The seeded admins cannot reach any admin endpoint
**Every `[Authorize(DynamicPermission)]` route returns `403` for `09120000020` and `09120000021`.** Verified
against all 16 admin GET operations in the live swagger — `platform_config`, `audit`, `holidays`,
`admin_verifications`, `admin_payouts`, `admin_refunds`, `admin_evv`, `admin_bnpl`, `admin/tickets`,
`admin/reviews`, `admin_cancellation_policies`, `admin/partner-centers`. All `403`. The same token gets
`200` from `/me`, and nurse/customer endpoints work normally, so this is not a token problem.
Root cause, in `DynamicPermissionService.CanAccess`:
```csharp
if (user.IsInRole("admin")) return true;
var key = $"{area}:{controller}:";
return user.FindAll(ConstantPolicies.DynamicPermission).Any(c => c.Value.Equals(key, …));
```
It grants on the **literal** role `"admin"` or on a per-controller `DynamicPermission` claim. The demo
admins hold `super_admin` / `finance` (`RoleNames.cs:13-17`), and refinement-phase-5 stopped auto-seeding the
`admin`/`qw123321` account — so **no seeded account satisfies either branch.**
Consequence for testing: **the entire admin backoffice is untestable end-to-end on the real path.** The
client hides this because `USE_ADMIN_MOCK = true`, so the console renders fully on in-browser fake data.
See [admin-backoffice.md](admin-backoffice.md). Workaround: add both `"Seed": { "AdminUsername",
"AdminPassword" }` keys to `appsettings.Development.json` before boot to mint a literal-`admin` account, and
call the API directly (the web login is phone-OTP only).
---
## The seeded world, and how stale it is
`DemoWorldSeeder` builds the personas; `DemoLifecycleSeeder` layers 8 bookings plus the money, reviews,
tickets, notifications and records behind them. Both are **idempotent and Development-only**, and log
`already seeded — no-op` on every subsequent boot.
Live counts on the shared remote DB at this stamp:
| Thing | Count | Read with |
| --- | --- | --- |
| Bookings | 8 (6 for `…010`, 2 for `…011`) | `GET /bookings/list?role=customer` |
| Booking requests | 15 (11 nurse 1, 4 nurse 2) | `GET /booking_requests/list` |
| Search rows | 27 across 3 nurses; `category=1&city=101`**9**, `category=3`**0** | `GET /search/nurses` (anonymous) |
| Patients / addresses | 2 / 1 for `…010` | `GET /patients/list`, `GET /customer_addresses/list` |
| Notifications | 8, 6 unread | `GET /notifications/get_notifications` |
| Tickets | 9 | `GET /tickets` |
| Reviews | nurse 1 → `averageRating: 5`, `publishedCount: 1` | `GET /nurses/1/reviews` (anonymous) |
| Refunds | booking 7 → `succeeded`, `psp_card`, `2000000` | `GET /refunds/by_booking/7` |
| Payouts | nurse 1: paid `3187500`, clawback outstanding `212500`; nurse 2: eligible `2720000` | `GET /nurse_payouts/earnings_balance` |
| Care records | 2 for patient 1 | `GET /patients/1/care_records` |
### ⚠ It has aged out — and this is not cosmetic
`DemoLifecycleDefinitions` expresses every timestamp as an **offset from seed time**, and the seeder anchors
to the *first* run's epoch, never to wall-clock now. The seeder's own comment is explicit:
> *"A wipe + reseed is what moves the demo world forward in time."*
**This world was seeded on 2026-07-26. It is 7 days old.** What that has already broken:
| Scenario as designed | State today | Effect |
| --- | --- | --- |
| B1 `upcoming` — scheduled +3 d | scheduled 2026-07-29, **in the past** | nothing is actually "upcoming" |
| B3 `completed_in_window` — dispute window open | window closed 2026-07-28 | the dispute/review-moderation path can't be walked |
| B2 `in_progress` — session 3 checked in *today* | scheduled 2026-07-26 | mid-engagement is 7 days stale |
| One `pending` request awaiting the nurse | `expired_no_response` (the `booking_request_expiry` job ran) | — |
| Two `accepted` requests awaiting payment | `payment_deadline_expired` | — |
**There is no longer a single `pending` or `accepted` booking request in the world.** Confirmed across both
nurses: statuses are only `converted`, `rejected_by_nurse`, `cancelled_by_customer`, `expired_no_response`,
`payment_deadline_expired`.
So **[booking-request](booking-request.md) and [checkout-and-payment](checkout-and-payment.md) have nothing
seeded to act on.** Create a fresh request yourself (customer → search → C4/C5) — that path works and is the
intended way to exercise both — or reseed.
---
## The scheduler is running while you test
`RecurringJobSchedulerHostedService` starts with the API and gives each of the 7 jobs its own loop. **Every
job fires once immediately at boot**, then on its own cadence, re-reading its interval from
`platform_configs` each tick. It does **not** run under the `Testing` environment.
Three of them will change state under you:
| Job | Cadence | What you will notice |
| --- | --- | --- |
| `booking_request_expiry` | **every 60 s** (hardcoded) | A request you leave un-actioned flips to `expired_no_response`, then `payment_deadline_expired`. **This is what aged the seeded world out**, and it will do the same to yours — accept and pay promptly |
| `no_show_sweep` | hourly | A session whose start time passed with no EVV check-in gets flagged missed |
| `weekly_payout_generation` | at boot, then 7 d | Creates a **draft** payout batch you did not ask for. Generation only — it never moves money; `process` stays an explicit admin action |
The other four (`notification_retention`, `verification_expiry_scan`, `moadian_reconciliation`,
`audit_log_retention`) are no-ops on a freshly seeded database. `moadian_reconciliation` logs
`scanned 8 invoice(s); 0 reached registered` because `IMoadianClient` is on its mock.
---
## Reset
There is **no in-app reseed**. Both seeders guard on natural keys (a persona's phone, a ticket's reference
code, the partner permit number, a request's customer/nurse/variant/date tuple), so re-running never
duplicates and never refreshes. Moving the world forward in time means dropping the data first.
| Target | Procedure | Verified |
| --- | --- | --- |
| Local Docker DB | `cd server && docker compose down -v && docker compose up -d`, then `dotnet run` | ✗ no Docker here |
| Remote shared DB | `DROP DATABASE Baya` (and `Baya_Logs`), then boot — `MigrateAsync` + both seeders run | ✗ **not attempted** |
**Do not drop the shared remote database casually.** It backs the `balinyaar.ir` demo deployment and is used
by other people. If you need a clean, freshly-dated world, use a local instance.
Schema-only, without booting the app:
```bash
cd server
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence \
--startup-project src/API/Baya.Web.Api
```
Reference and demo seeds still run on the next app boot.
---
## Scripting logins
Two API calls per login, both on the `otp` policy, 5 per 60 s per IP. **Space logins ≥ 40 s apart** or you
will 429. This script minted tokens for all 8 demo accounts:
```bash
login() {
P="$1"
curl -s -X POST http://localhost:5002/api/v1/auth/request_otp \
-H "Content-Type: application/json" -d "{\"phone\":\"$P\"}" > /dev/null
C=$(curl -s "http://localhost:5002/api/v1/dev/last_otp/$P" \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.code")
curl -s -X POST http://localhost:5002/api/v1/auth/verify_otp \
-H "Content-Type: application/json" \
-d "{\"phone\":\"$P\",\"code\":\"$C\",\"deviceInfo\":\"cli\"}" \
| node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).data.accessToken"
}
for P in 09120000001 09120000002 09120000003 09120000010 \
09120000011 09120000020 09120000021 09120000030; do
echo "T_$P=$(login $P)"; sleep 40
done
```
Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.
Then `curl -H "Authorization: Bearer $T_09120000010" http://localhost:5002/api/v1/me`.
Four things to get right:
- **The field is `phone`, not `phoneNumber`.** `phoneNumber` returns `400` with
`"The Phone field is required."`
- **`Authorization: Bearer <token>`, never a cookie.** The client stores the JWE in a cookie it reads
itself and sends as a header; the server's CORS policy does not allow credentials.
- Access tokens last **60 minutes** (`IdentitySettings.ExpirationMinutes`).
- **One live token per account. Logging in again kills the previous one.** Verified: after re-running the
script, the earlier tokens for the re-minted phones all returned `401` while untouched accounts kept
working. `AppUserManagerImplementation.VerifyUserCode` calls `UpdateSecurityStampAsync` on every
successful verification, and the bearer handler's `OnTokenValidated` runs
`ValidateSecurityStampAsync` — so a new login invalidates every token previously issued to that user.
**Two people cannot share a demo account**, and a second browser profile will silently log the first out.
---
## What the old docs get wrong
Each row was checked against running code. **This file is right; the predecessor is stale.**
| # | Claim | Where | Reality |
| --- | --- | --- | --- |
| 1 | Set crypto keys with `dotnet user-secrets` | manual-testing-plan | Removed in `5885280`; the store is not read. Edit `appsettings.Development.json` or use `__` env vars |
| 2 | API on `https://localhost:5002` | RUNBOOK ×6, manual-testing-plan | **`http://`**. No TLS binding exists |
| 3 | Run `dotnet dev-certs https --trust` | RUNBOOK setup step 1 | Nothing to trust. Skip |
| 4 | `client/.env.development` has `https://localhost:5002` | RUNBOOK | The file says `http://`. But **`.env.sample` still says `https://`** — a live trap |
| 5 | `Seams:Sms:Provider` "committed default is `mock`" | RUNBOOK, Telegram step 3 | It is **`telegram`**, so `request_otp` **500s** on a fresh clone |
| 6 | Console prints `MOCK SMS — OTP code 123456 …` | RUNBOOK, manual-testing-plan | It prints `MOCK SMS — OTP issued to phone ending in 0002`**no code**. Use `dev/last_otp` |
| 7 | "Only `auth` is real; 21 of 22 domains are mocked" | RUNBOOK "Good to know" | Stale by refinement-phase-4. **15 of 22 are real**, 7 mocked — see [index.md](index.md) |
| 8 | `docker compose down -v` resets the world | RUNBOOK | Only for the local-DB path. The committed config uses a **remote** DB where it does nothing |
| 9 | Demo world shows "upcoming" / open dispute windows | manual-testing-plan | Seeded 2026-07-26 and **aged out**; no `pending`/`accepted` requests remain |
| 10 | `09120000020` gives "full backoffice" | RUNBOOK | `403` on **every** admin endpoint — see [the RBAC gap](#-the-seeded-admins-cannot-reach-any-admin-endpoint) |
Contradictions C-1, C-3, C-4 and C-5 from
[clarify-chain/open-contradictions.md](../../archive/clarify-chain/open-contradictions.md) are settled by rows 14 and 8.
---
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `request_otp``500`, log shows `Telegram OTP relay delivery failed (http 502)` | `Provider=telegram`, relay not running | Boot with `Seams__Sms__Provider=mock` |
| `request_otp``429`; `verify_otp``429` **with an empty body** | `otp` policy, 5/60 s per IP | Wait 60 s. Space scripted logins ≥ 40 s |
| `400` `"The Phone field is required."` | Sent `phoneNumber` | The field is `phone` |
| `403` on every `/api/v1/admin*` route | `DynamicPermission` doesn't recognise `super_admin` | No clean workaround — see the RBAC gap |
| `/healthz/ready``503` | object-storage probe file lock (Windows) | Cosmetic. Check `entries.sql-app` instead |
| `dev/last_otp``404 "No OTP has been issued for this phone yet."` | No `request_otp` for that phone yet, or not Development | Call `request_otp` first (a `500` from it still stores the code) |
| Login "succeeds" but the account is unknown / `Padding is invalid` | `Seams:FieldEncryption` doesn't match the DB | Restore the committed values |
| `Refusing to start: required secret configuration is missing…` | `ConnectionStrings` blank or still `SET_VIA_USER_SECRETS_OR_ENV` | Use `appsettings.Development.json` or `ConnectionStrings__SqlServer` |
| Every `curl` returns `502` | A machine-wide `HTTP_PROXY`/`HTTPS_PROXY` intercepting localhost | `curl --noproxy '*'`, or clear `NO_PROXY` |
| Client `404` on `http://localhost:3000/` | Locale prefixes; `next dev` root-path quirk | Use `/fa`. Verify root behaviour with a prod build |
| Browser: blocked by CORS | Origin not in `Cors:AllowedOrigins` | `http://localhost:3000` is listed by default |
| `dotnet user-secrets`: "could not find UserSecretsId" | Expected | Edit appsettings instead |
| A `500` returns a raw stack trace instead of the `ApiResult` envelope | `ExceptionHandler` returns `false` in Development on purpose | Expected locally. Don't document the error shape from a dev response |
| A booking request flipped status while you were reading it | `booking_request_expiry` runs **every 60 s** | Expected. Act on requests promptly |
| A token that worked a minute ago now `401`s | Someone (or another tab) logged into the same demo account — the security stamp rotated | Use one account per tester, or re-login |
---
## Where to go next
[index.md](index.md) — the flow atlas: what is built, what is mocked, and how to walk each journey.
+284
View File
@@ -0,0 +1,284 @@
# The API contract
Everything that holds for **every** Balinyaar endpoint. The per-domain files in
[`domains/`](domains/index.md) assume all of this and never restate it.
> Last verified: 2026-07-30 against commit `d3ec723`. Wire facts were derived mechanically from
> [`openapi/swagger.v1.json`](openapi/swagger.v1.json) (2026-07-29) and read out of the code named
> beside each claim. **When the JSON and this file disagree, the JSON wins and this file is wrong.**
---
## Base, versioning, routing
| | |
| --- | --- |
| Client base URL | `NEXT_PUBLIC_API_URL` → [`client/src/config.ts`](../../client/src/config.ts) `API_URL` (required; boot fails without it) |
| Local | `http://localhost:5002`**plain HTTP**, per `launchSettings.json`. There is no local TLS and no certificate to trust |
| Deployed | `https://api.balinyaar.ir` → Caddy → `balinyaar-api:8080` |
| Route template | `api/v{version:apiVersion}/[controller]/[action]`, `DefaultApiVersion = 1.0` |
| Segment casing | snake_case, via `SnakeCaseParameterTransformer` (`RouteTokenTransformerConvention`) |
| Swagger UI | `/swagger` · ReDoc `/api-docs/{documentName}` · documents `v1` and `v1.1` |
Route strings are **never hardcoded** server-side — the `[controller]`/`[action]` tokens also derive the
dynamic-permission key, so renaming a handler renames its URL *and* its permission
([server/CLAUDE.md](../../server/CLAUDE.md) hard rule 4).
`v1.1` is registered (`AddSwagger("v1","v1.1")`) but **empty**: all 55 controllers are `[ApiVersion("1")]`
and `ApiVersionDocumentProcessor` drops every path whose URL lacks the document's version segment.
## The envelope
`Baya.Application/Models/ApiResult/ApiResult.cs`, applied by `ApiResultFilterAttribute` +
`base.OperationResult(result)`.
| Field | Type | Notes |
| --- | --- | --- |
| `isSuccess` | boolean | |
| `statusCode` | **integer** | `ApiResultStatusCode`: `200 400 401 403 404 406 409 422 424 500`. Not a string |
| `message` | string \| null | User-safe. Defaults to the status's display name (`"Success"`, `"Bad Request Error"`, …) |
| `requestId` | string \| null | `Activity.Current.TraceId` as hex — the W3C trace id, empty string if no activity |
| `code` | string \| null | Optional stable machine-readable error code. **Omitted from the wire when null** |
| `data` | `T` \| null | The payload. Present only on `ApiResult<T>` |
Failure responses use the same shape with `data` null (or the validation dictionary, below).
**Client-side drift:** `ApiEnvelope<T>` in
[`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts) declares `isSuccess`, `statusCode`,
`message`, `requestId`, `data` — but **not `code`**, even though `clientFetch` reads `body.code` at
runtime and threads it into `ApiError`. Harmless today, incomplete as a type.
## Casing
**JSON bodies are camelCase. URL segments are snake_case.** REQ-001 settled this; verified here
mechanically — across all 339 component schemas there are **427 distinct property names, 0 containing an
underscore and 0 in PascalCase**.
Query parameters are the exception with no single rule: most are camelCase, and `GET /search/nurses`
takes snake_case (`service_category_id`, `city_id`, `district_id`, `nurse_gender`, `min_price`,
`max_price`, `page_size`) — the only endpoint that does. See [domains/search.md](domains/search.md).
## Status codes
| Code | Meaning | Server source |
| --- | --- | --- |
| `200` | Success, payload in `data` | |
| `400` | Validation / business-rule failure | `data` is `{ "<field>": ["<message>", …] }` (`ApiResultOfDictionaryOfStringAndListOfString`), from `ModelStateValidationAttribute` + FluentValidation |
| `401` | Unauthenticated — missing, expired or unreadable token | |
| `403` | Authenticated but lacks the policy/permission | |
| `404` | Not found — **and a tenancy mismatch.** Deliberate: a 403 would confirm the row exists ([server/CLAUDE.md](../../server/CLAUDE.md) rule 20) | |
| `406` | Not acceptable | |
| `409` | Conflict — forward-only state-machine violation, duplicate, or converged idempotent replay | `OperationResult.ConflictResult` |
| `422` | Entity process error | |
| `424` | Failed dependency (an external rail refused) | |
| `429` | Rate limited | The rate limiter, below |
| `5xx` | Unexpected. Generic message; detail only in logs — **except** in Development, where the developer exception page returns a stack trace | `ExceptionHandler` |
Handlers **never throw for an expected failure** — they return `OperationResult.FailureResult` /
`NotFoundResult` / `ConflictResult`, which the filter maps to the codes above.
### What the client does with each
[`client/src/lib/api/client.ts`](../../client/src/lib/api/client.ts):
| | Behaviour |
| --- | --- |
| `401` | One silent refresh + retry (single-flight). On failure: clear both auth cookies, toast "session expired", `window.location.replace('/{locale}/login')`, **return `undefined` rather than throw** |
| `403` | Toast + throw `ApiError(403, message, code)` |
| `5xx` | Toast + throw `ApiError` |
| other `4xx` | **Throw without toasting** — the calling hook owns the user-facing message |
| network failure | Toast + `throw new ApiError(0, 'Network error')` |
| `204` | Returns `undefined` |
`serverFetch` ([`server.ts`](../../client/src/lib/api/server.ts)) never toasts and never redirects — every
non-OK response throws `ApiError` and the caller decides between `notFound()`, `redirect()` and an error
boundary.
## Auth
### Transport is a header, storage is a cookie
The JWE access token lives in a **client-readable cookie** (`access_token`) that the client reads itself
and re-sends as `Authorization: Bearer <token>`. **No cookie crosses the wire as an auth credential.**
Consequences:
- The server's CORS policy sets **no** `AllowCredentials()` — see [config-matrix.md](config-matrix.md#cors).
- Neither fetch layer sets `credentials: 'include'`.
- A cross-site cookie policy (`SameSite`) is irrelevant to API auth; the cookies are same-origin storage.
| Cookie | Options | Written by |
| --- | --- | --- |
| `access_token` | `path=/`, `maxAge=900` (15 min), `sameSite=lax`, `secure` | `persistAuthTokens` |
| `refresh_token` | `path=/`, `maxAge=604800` (7 days), `sameSite=lax`, `secure` | `persistAuthTokens` |
> **Asymmetry worth knowing:** the cookie's `maxAge` is 15 minutes but `IdentitySettings:ExpirationMinutes`
> is **60**. The token stays valid for an hour; the client simply stops having it after 15 minutes, so the
> next call goes out unauthenticated, gets a 401, and refreshes. It works, but the refresh cadence is set
> by the cookie, not the token.
### The token is opaque
It is a **JWE** — signed *and* AES-128-encrypted. The client cannot read a claim out of it and must not
try. Identity, roles and profile-completeness come from `GET /api/v1/me`; a user holding more than one
role commits to one with `POST /api/v1/me/select_role`. See [domains/auth.md](domains/auth.md) and
[docs/rules/client/auth.md](../rules/client/auth.md).
### Refresh, rotation, reuse detection
`POST /api/v1/auth/refresh` with `{ refreshToken }` returns a **new pair**; the old refresh token is
retired. Presenting a retired token is treated as theft and kills the session. The client coalesces
concurrent 401s into **one** refresh via a module-level in-flight promise
([`refresh.ts`](../../client/src/lib/api/refresh.ts)) and retries the original request exactly once.
`/auth/refresh`, `/auth/request_otp` and `/auth/verify_otp` are excluded from the retry — a 401 there is
terminal.
### Authorization model
Three levels, declared per controller:
| Attribute | Used by | Meaning |
| --- | --- | --- |
| *(none)* | 7 controllers | Anonymous — there is **no** `FallbackPolicy`, so an omitted attribute *is* the decision |
| `[Authorize]` | user-facing controllers | Any authenticated caller; tenancy is then resolved from `ICurrentUser` |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | every `Admin*` controller + `Holidays`, `PlatformConfig`, `SupportAlerts`, `Audit`, `InternalCenters` | Dynamic permission keyed off the controller/action route |
### The anonymous surface
20 of 186 operations declare no security. This is the complete list:
```
POST /api/v1/auth/request_otp POST /api/v1/auth/verify_otp
GET /api/v1/catalog/categories GET /api/v1/catalog/option_groups
GET /api/v1/geo/provinces GET /api/v1/geo/cities
GET /api/v1/geo/districts GET /api/v1/geo/tree
GET /api/v1/nurses/{nurseId}/profile GET /api/v1/nurses/{nurseId}/trust_badge
GET /api/v1/nurses/{id}/reviews GET /api/v1/nurses/{id}/review_tags
GET /api/v1/nurse_variants/get/{id} GET /api/v1/search/nurses
GET /api/v1/ping/get_status GET /api/v1/ping/get_status_rate_limited
POST /api/v1/webhooks/payments/{provider} POST /api/v1/webhooks_bnpl/{provider}
POST /api/v1/webhooks/payouts/{provider} GET /api/v1/dev/last_otp/{phone}
```
Two things follow that the REQ ledger has not caught up with:
- **REQ-066/067** ask for anonymous nurse search + profile reads for guest browse and are filed *open*.
Those endpoints are **already anonymous**. What is genuinely missing is the rate limit the REQ asks for
(`SearchController` and `NursesController` carry no `[EnableRateLimiting]`, so they fall to the 100/min
per-IP global limiter) and the privacy review of the profile payload.
- `GET /api/v1/dev/last_otp/{phone}` returns any registered phone's login code. It is Development-only
code, and the deployment runs as Development — **so it is live on `api.balinyaar.ir`**. Recorded in
[DEPLOY.md](../../DEPLOY.md) as the deployment's largest exposure.
## Localisation
The client sends `Accept-Language` (`fa` default) on **every** call, taken from the URL's locale segment.
`serverFetch` reads the locale from its own `x-app-locale` request header (`HEADER_NAMES.LOCALE`, set by
the Next.js middleware) and forwards it as `Accept-Language`. Reference data carrying `nameFa`/`nameEn`
returns both and the client picks.
## Pagination
Every unbounded list is paginated. Payload: `{ items, total, page, pageSize }``total`, `page` and
`pageSize` are `integer/int32`, `items` is nullable.
29 operations take paging params, in three declared spellings:
| Declared | Count | Endpoints |
| --- | --- | --- |
| `Page`, `PageSize` | 25 | the default — every `*/list`, `admin_*` worklist, `tickets`, `notifications`, … |
| `page`, `pageSize` | 3 | `admin_payouts/batches/{id}` · `nurses/{id}/reviews` · `patients/{id}/care_records` |
| `page`, **`page_size`** | 1 | `search/nurses` |
The first two are interchangeable — ASP.NET model binding is case-insensitive, which is what REQ-010
recorded. **`page_size` is not**: it is a different name, and sending `pageSize` to `search/nurses` binds
nothing and silently yields the default page size. The client's search client already sends `page_size`
correctly.
## Idempotency
`Idempotency-Key`, a request header. **Read on exactly two endpoints**, both via
`Request.Headers["Idempotency-Key"].FirstOrDefault()`:
| Endpoint | Controller | Semantics |
| --- | --- | --- |
| `POST /api/v1/bookings/{bookingRequestId}/payments` | `PaymentsController` | One key per payment **attempt**, reused across retries of that attempt. A new attempt takes a new key. `409` means "already in progress / already captured" — a benign convergence, not an error |
| `POST /api/v1/checkout_bnpl/initiate` | `CheckoutBnplController` | Same, per BNPL attempt |
Because it is read from the header rather than bound as a parameter, **it appears nowhere in swagger.**
It *is* in the CORS allow-list, so the pre-flight passes.
Money-path writes are idempotent by construction regardless of the header: the webhook event is upserted
first and a duplicate no-ops, `bookings.booking_request_id` is `UNIQUE` so a replayed conversion cannot
create a second booking, and a unique-violation on confirm is treated as idempotent success. **The DB
constraint is the backstop, not the handler's `if`.**
Webhooks do **not** use the header — they dedupe on the provider's `external_event_id`.
## Money on the wire
| Direction | Type | Count | Rule |
| --- | --- | --- | --- |
| Outbound — DTOs, results | **digit string** (`"23300000"`) | 68 / 68 | Parse with the `@/utils` BigInt helpers. **Never `Number()`** |
| Inbound — `*Command` / `*Request` bodies | **`integer/int64`** | 3 / 3 | `UpsertCancellationPolicyCommand.feeAmountIrr`, `CreateRefundCommand.platformFeeRefundedIrr`, `CreateRefundCommand.nursePayoutRefundedIrr` |
Invariants the client must not recompute: `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`;
VAT applies to Balinyaar's commission only, never the nurse payout; a rate change is never retroactive
(the rate is snapshotted onto the row at compute time). Toman↔IRR conversion happens only inside a
provider adapter. Payout dates are resolved server-side against the holiday calendar — the client never
computes one.
## Dates, ids, PII
- Timestamps are **UTC ISO-8601** strings. Shamsi rendering is a client concern.
- `dayOfWeek` for availability uses the **Shamsi week** (0 = Saturday … 6 = Friday), not ISO Monday-start.
- Entity ids are integers. Human-facing references (`referenceCode` on tickets, `invoiceNumber`) are
opaque strings.
- Encrypted-at-rest fields (phone, national id, IBAN, addresses, clinical notes) are returned only to
authorised callers and often masked. Each domain file states masked vs. full.
- **Two-stage clinical disclosure:** a booking *request* exposes only unencrypted `customerNotes` and a
city/district-coarse address; encrypted care instructions become readable only after confirmation, only
to the assigned nurse and admin, and are never projected into a list.
- `gender` is load-bearing — it drives same-gender caregiver matching. Never defaulted, never dropped.
## Enums
**Swagger carries no string enums.** Of 339 schemas exactly one has an `enum`, and it is the integer
`ApiResultStatusCode`. Every status/type/code field serialises as a bare `string`, so the JSON cannot
validate a vocabulary and **the domain files here are the only written record.**
Each vocabulary in [`domains/`](domains/index.md) was cross-checked against the server's `Baya.Domain`
code sets and the client's string-literal unions. Both sides agree on all 18 shared vocabularies at this
stamp, with one exception ([domains/tickets.md](domains/tickets.md): the client's `TicketAuthorRole`
carries a `system` member the server's `TicketCodes` does not define).
## Rate limits
`RateLimitingServiceExtension`. Over-limit → **429**. Partitioned on the client IP as resolved by the
forwarded-headers middleware, so behind Caddy each real client gets its own bucket.
| Policy | Limit | Applied to |
| --- | --- | --- |
| *(global, implicit)* | 100 / min per IP | every endpoint that opts into nothing else |
| `otp` | 5 / min | `auth/request_otp`, `auth/verify_otp` |
| `auth` | 10 / min | `auth/refresh` |
| `sensitive` | 20 / min | every `Admin*` controller, `checkout_bnpl`, `bookings` money actions, `booking_sessions` check-out, `nurse_bank_accounts` writes, `payments` |
| `webhook` | 120 / min | the three webhook controllers, partitioned **per provider × IP** so one PSP's burst cannot starve another |
| `global` (named) | 5 / 10 s | `ping/get_status_rate_limited` only — a demonstration endpoint |
`app.UseCors()` runs **after** `UseRouting()` and **before** the rate limiter and authentication, so a
pre-flight `OPTIONS` is answered rather than rejected as 429 or 401.
## Platform endpoints (outside every domain)
Not part of any `services/` domain, and intentionally so:
| Endpoint | Purpose |
| --- | --- |
| `GET /api/v1/ping/get_status` | Liveness smoke test (anonymous) |
| `GET /api/v1/ping/get_status_rate_limited` | Demonstrates a 429 (anonymous, 5/10 s) |
| `GET /healthz/live` | Process only — never touches a dependency |
| `GET /healthz/ready` | + app DB, log DB (deployed only), object-storage write probe |
| `GET /HealthCheck` | The aggregate, kept for existing probes |
| `GET /metrics` | Prometheus scrape (OpenTelemetry is the only metrics source) |
The health and metrics endpoints are **not** under `/api/v1` and carry no envelope.
+262
View File
@@ -0,0 +1,262 @@
# Config matrix
Every configuration key on both sides of the seam, plus docker and the OTP relay, with where it is set and
who reads it.
> Last verified: 2026-08-02 against commit `51e86a1`. Built by **mechanically enumerating** every leaf key
> in both `appsettings*.json`, every `environment:` entry in `docker-compose.yml`, every assignment in the
> three `client/.env*` files and `telegram-otp-bot/.env.example`, and every `process.env.*` read under
> `client/src/` — then diffing the sets. The gaps that diff found are in
> [§ What the diff found](#what-the-diff-found).
---
## Configuration lives in files. `dotnet user-secrets` is not used.
The `<UserSecretsId>` was **removed** from `Baya.Web.Api.csproj`, so that store is **not even read**. A
stale `secrets.json` on a developer machine is inert and can be deleted. Any instruction anywhere in this
repo to set a Balinyaar value with `dotnet user-secrets` is stale — including the placeholder string
`SET_VIA_USER_SECRETS_OR_ENV`, whose *name* is a historical artifact (see
[§ The placeholder's name](#the-placeholders-name)).
**Every value is a file in git**, which means **the repository contains live credentials** — a deliberate
pre-launch trade for a demo deployment. Before onboarding real users they must be rotated and the secret
half moved out of git: [DEPLOY.md § Going to Production](../../DEPLOY.md) and, when Phase 5 writes it,
`docs/roadmap/pre-launch.md`.
> ⚠️ **`Seams:FieldEncryption:Key` and `:HashKey` are load-bearing and must never change.** Every encrypted
> column in the database — phones, addresses, IBANs, clinical notes — was written with those exact values,
> and `users.PhoneHash`, which **every login** looks up, is derived from `HashKey`. Rotating either makes
> the existing data unreadable and locks every account out. The JWE keys
> (`IdentitySettings:SecretKey`/`Encryptkey`) are safe to rotate — that only signs everyone out.
### There is no `appsettings.Production.json`
Only `appsettings.json` (placeholders) and `appsettings.Development.json` (real values) exist. The
deployment runs `ASPNETCORE_ENVIRONMENT=Development`, so **`appsettings.Development.json` *is* the
production config.** Creating an `appsettings.Production.json` today would change nothing until the
environment name changes too.
---
## Server — `appsettings.json` / `appsettings.Development.json`
`appsettings.json` holds a rejected placeholder for every secret; `appsettings.Development.json` holds the
real value. Environment-variable overrides use the double-underscore form
(`Seams__Sms__Telegram__BaseUrl`).
| Key | Set in | Read by | Required | Default / committed value |
| --- | --- | --- | --- | --- |
| `ConnectionStrings:SqlServer` | both | `AddPersistenceServices`, `StartupSecretsGuard`, readiness probe | **yes, always** | placeholder / `87.107.152.16,1433;Database=Baya` |
| `ConnectionStrings:logDb` | both | Serilog sink, `StartupSecretsGuard`, readiness probe (**deployed only**) | **yes, always** | placeholder / `…;Database=Baya_Logs` |
| `IdentitySettings:SecretKey` | both | JWE signing | yes **when deployed** | placeholder / `dev-only-…-not-for-production` |
| `IdentitySettings:Encryptkey` | both | JWE AES-128 encryption | yes **when deployed** | placeholder / `dev-only-16bytes` |
| `IdentitySettings:Issuer` | both | token validation | — | `Balinyaar` |
| `IdentitySettings:Audience` | both | token validation | — | `BalinyaarClient` |
| `IdentitySettings:NotBeforeMinutes` | both | token validation | — | `0` |
| `IdentitySettings:ExpirationMinutes` | both | access-token lifetime | — | `60`**but the client's cookie expires at 15 min**, see [api-contract.md](api-contract.md#auth) |
| **`Seams:FieldEncryption:Key`** | both | `IFieldEncryptor` (process-wide singleton) | yes **when deployed** | placeholder / `local-dev-field-encryption-key-not-for-production` · **IMMUTABLE** |
| **`Seams:FieldEncryption:HashKey`** | both | deterministic lookup hashes incl. `users.PhoneHash` | yes **when deployed** | placeholder / `local-dev-field-hash-key-not-for-production` · **IMMUTABLE** |
| `Seams:ObjectStorage:RootPath` | both + compose | local-disk blob root | when provider = `local` | `""` / compose sets `/app/data/object-storage` |
| `Seams:Sms:Provider` | Dev only | SMS seam selector **and** the OTP-capture gate in `Program.cs` | — | `telegram` (default in code: `mock`) |
| `Seams:Sms:Telegram:BaseUrl` | Dev + **compose** | `TelegramSmsSender` | when provider = `telegram` | `http://127.0.0.1:5010` / compose: `http://balinyaar-otp-relay:5010` |
| `Seams:Sms:Telegram:ApiKey` | Dev only | relay `X-Api-Key`**must equal the relay's `API_KEY`** | when provider = `telegram` | `6a8dfaee…` (rotated away from the published example) |
| `Seams:Sms:Telegram:TimeoutSeconds` | Dev only | HTTP timeout | — | `10` |
| `Seams:Geocoding:ReturnNullCoordinates` | both | mock geocoder | — | `false` |
| `Seams:Geocoding:LowConfidenceMarker` | both | mock geocoder | — | `NO_GEO` |
| `Seams:Geocoding:ResolvedConfidence` | both | mock geocoder | — | `0.9` |
| `Cors:AllowedOrigins` | both | `AddCorsPolicies` | — | `[]` → falls back to `http://localhost:3000` / the three real origins |
| `ForwardedHeaders:KnownProxies` | both | `AddForwardedHeadersConfiguration` | — | `[]` |
| `ForwardedHeaders:KnownNetworks` | both | ditto — **required for the rate limiter to see the real client IP behind Caddy** | deployed | `[]` / the three docker bridge ranges |
| `AllowedHosts` | both | host filtering | — | `*` |
| `Kestrel:EndpointDefaults:Protocols` | both | Kestrel — `Http1AndHttp2` is what lets gRPC share the port | — | `Http1AndHttp2` |
### Keys the code reads that no file sets
Every one has a working default, so nothing is broken — but none is discoverable from the config files.
| Key | Read by | Behaviour when unset | Notes |
| --- | --- | --- | --- |
| `OpenTelemetry:Otlp:Endpoint` | `SetupOpenTelemetry` | **OTLP export is not wired at all.** Prometheus `/metrics` still works | Opt-in by design, so no exporter spams an absent collector |
| `Search:Backend` | `AddPersistenceServices` | `SqlNurseSearch` | Any value other than `sql`/empty **throws at startup** — Elasticsearch is deferred and fails loudly |
| `Seed:AdminUsername` / `:AdminPassword` / `:AdminEmail` | `SeedDataBase` | no break-glass admin is seeded | The hardcoded `admin`/`qw123321` was removed in refinement-phase-5 |
| `Seams:<rail>:Provider` (×11) | `AddCrossCuttingSeams` | **`mock`** | The seam selectors, below |
### The seam selectors
`SeamOptions` binds the whole `Seams` section. **Every rail defaults to its mock**, so an unconfigured
environment behaves exactly as before and a **partial rollout is the normal case** — real SMS and a real
geocoder while payments stay mocked is three config keys.
| Selector | `mock` (default) → | Real values |
| --- | --- | --- |
| `Seams:Sms:Provider` | log the OTP | `kavenegar` `smsir` `ghasedak` · `telegram` *(Development relay, not a gateway)* |
| `Seams:ObjectStorage:Provider` | `local` disk | `s3` (MinIO / ArvanCloud, path-style) |
| `Seams:Geocoding:Provider` | deterministic point | `neshan` |
| `Seams:Shahkar:Provider` | designated test values | `finnotech` |
| `Seams:IdentityKyc:Provider` | designated test values | `finnotech` |
| `Seams:BankOwnership:Provider` | designated test values | `finnotech` |
| `Seams:Payments:Provider` | deterministic capture | `zarinpal` `sadad` `vandar` `jibit` |
| `Seams:Bnpl:Provider` | one mock provider | `real``IBnplProviderResolver` per `provider_code` |
| `Seams:BankTransfer:Provider` | settles every payout | `jibit` `vandar` `sadad` |
| `Seams:Moadian:Provider` | stays `pending` | `moadian` |
| `Seams:Currency` *(no selector)* | `TomanToIrrMultiplier = 10` | a redenomination is a config change |
`Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` are shared by the three trust rails — they authenticate
against one tenant, so the connection facts live once.
Each mock also carries **test knobs** whose only purpose is to make a failure path reachable:
`Shahkar:SharedSimPhone` `09120000000` · `Shahkar:MismatchNationalId` `1111111111` ·
`IdentityKyc:FailNationalId` `0000000000` · `BankOwnership:MismatchIban` `IR0000…0000` ·
`Bnpl:NotEligibleMobile` `09120000099` · `BankTransfer:FailIban` · `BankTransfer:ForceFailure` ·
`PaymentCapture:ForceFailure` · `Moadian:ForceRegistered` · `ReviewModeration:AutoApproveClean` ·
`LicenseVerification:AutoApprove` · `Payments:InvalidSignatureMarker` `INVALID_SIGNATURE`.
---
## Client — `client/.env.*`
**Every `NEXT_PUBLIC_*` value is inlined into the browser bundle at build time.** It is public by
definition, and changing one requires **rebuilding the image**, not restarting the container. This is why
`docker-compose.yml` deliberately sets no `environment:` for the `web` service — anything there would be
silently ignored.
| Key | `.env.development` | `.env.production` | Read by | Required |
| --- | --- | --- | --- | --- |
| `NEXT_PUBLIC_API_URL` | `http://localhost:5002` | `https://api.balinyaar.ir` | `config.ts``API_URL` | **yes**`envRequired`, boot fails without it |
| `NEXT_PUBLIC_ENV` | `development` | `production` | `getCurrentEnvironment()``IS_PRODUCTION` | — |
| `NEXT_PUBLIC_DEBUG` | `true` | `false` | `IS_DEBUG``true` **prints the resolved config, including the API URL, to the browser console** | — |
| `NEXT_PUBLIC_PUBLIC_URL` | `http://localhost:3000` | `https://balinyaar.ir` | `PUBLIC_URL` (optional) | — |
| `NEXT_PUBLIC_SITE_URL` | *(unset)* | `https://balinyaar.ir` | `SITE_URL`**metadata only** (OG tags, `metadataBase`, `robots.ts`, `sitemap.ts`), never API calls | — · falls back to `http://localhost:3000` |
| `NEXT_PUBLIC_NESHAN_KEY` | *(unset)* | *(commented out)* | `NESHAN_WEB_KEY` — the Neshan **web** key | — · unset ⇒ `AddressMapPicker` uses its bounded-canvas grid, so dev/CI/jsdom work without it |
| `NEXT_PUBLIC_EVV_MOCK_GPS` | **not in any file** | **not in any file** | `bookings/constants.ts` | — · `in_range` when the bookings mock is on, else `off`. Values: `off` `in_range` `out_of_range` `denied` |
| `NEXT_PUBLIC_VERSION` | **not in any file** | **not in any file** | `getCurrentVersion()`, after `npm_package_version` | — · falls back to `'unknown'` |
> **Two Neshan keys exist and they are different products.** `NEXT_PUBLIC_NESHAN_KEY` is a client-embeddable
> **web** key; `Seams:Geocoding:ApiKey` is the **server** geocoding key. Never share one value between them.
`client/.env.sample` is the copy-me template for a fresh clone. It is **not** loaded by Next.js.
---
## Docker — `docker-compose.yml`
Three containers, **no published ports** — everything is reached through the existing Caddy on the external
`caddy_net`. Full graph in [topology.md](topology.md).
| Service | Variable | Value | Why it is here and not in a file |
| --- | --- | --- | --- |
| `api` | `ASPNETCORE_ENVIRONMENT` | `Development` | **Deliberate**, so the demo + lifecycle seeders populate the shared DB. Consequences in [DEPLOY.md](../../DEPLOY.md) |
| `api` | `Seams__Sms__Telegram__BaseUrl` | `http://balinyaar-otp-relay:5010` | Container DNS instead of loopback |
| `api` | `Seams__ObjectStorage__RootPath` | `/app/data/object-storage` | Must land on the named volume |
| `web` | *(none)* | — | Every `NEXT_PUBLIC_*` is baked at build time; a variable here would be ignored |
| `otp-relay` | `TELEGRAM_BOT_TOKEN` | `8968527151:AAF…` | Live credential in git |
| `otp-relay` | `TELEGRAM_CHAT_IDS` | `1277103616,110209855` | **Every id receives every login code, for every phone number** |
| `otp-relay` | `API_KEY` | `6a8dfaee…` | **Must equal `Seams:Sms:Telegram:ApiKey`** |
| `otp-relay` | `TELEGRAM_PROXY_URL` | `http://hysteria-client:8081` | `api.telegram.org` is filtered in Iran; a wrong value fails at boot, not per-OTP |
| `otp-relay` | `REDACT_CODE_IN_LOGS` | `"true"` | Keeps codes out of `docker logs` so a host-log reader cannot harvest them |
Volumes: `api-object-storage` (uploaded verification documents — **losing it breaks the admin queue**) and
`api-logs` (Serilog file sink).
## Caddy — `deploy/Caddyfile`
Not loaded by anything in this repo; it is a copy of the block `DEPLOY.md` tells you to paste into the
Caddy container that owns `caddy_net`.
| Hostname | Upstream | Notes |
| --- | --- | --- |
| `balinyaar.ir`, `www.balinyaar.ir` | `balinyaar-web:3000` | |
| `api.balinyaar.ir` | `balinyaar-api:8080` | **8080 in-container, not 5002** — 5002 is the local `launchSettings.json` port |
Caddy is the only TLS terminator and renews both certificates itself. It sets `X-Forwarded-For` and
`X-Forwarded-Proto` by default, which is why no header directives are needed — but the API must trust the
hop via `ForwardedHeaders:KnownNetworks`, or the rate limiter partitions every request onto Caddy's IP.
## CORS
`CorsServiceExtension`, policy `BalinyaarWebClient`.
| | |
| --- | --- |
| Origins | `Cors:AllowedOrigins`; falls back to `http://localhost:3000` when unset or empty |
| Headers | `Authorization` · `Content-Type` · `Accept-Language` · `Idempotency-Key` — explicit, **not** `AllowAnyHeader`, so the surface is auditable |
| Methods | any |
| **Credentials** | **not allowed.** The client authenticates with a bearer header, not a cookie, so `AllowCredentials()` is unnecessary |
| Pipeline position | after `UseRouting()`, **before** the rate limiter and authentication, so a pre-flight `OPTIONS` is answered rather than rejected as 429/401 |
Adding a browser origin means editing `Cors:AllowedOrigins` **and** rebuilding the client if its
`NEXT_PUBLIC_API_URL` changes.
## Telegram OTP relay — `telegram-otp-bot`
A standalone zero-dependency Node service. **It is not an SMS gateway**: there is no per-user routing — it
*broadcasts* every code to a fixed list of chat ids. Workable for a trusted demo group, disqualifying the
moment anyone outside it can request a code. Switching `Seams:Sms:Provider` to `kavenegar` at that point
changes nothing else.
| Variable | Required | Default | Notes |
| --- | --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | **yes** | — | From @BotFather |
| `TELEGRAM_CHAT_IDS` | **yes** | — | Comma-separated. **Each recipient must have messaged the bot first** — Telegram forbids a bot opening a conversation |
| `API_KEY` | **yes** | — | Min 16 chars; the process refuses to start without it. Must equal `Seams:Sms:Telegram:ApiKey`. **The value in `.env.example` is published, and `TelegramSmsSender` deliberately refuses to authenticate with it** |
| `PORT` | — | `5010` | |
| `HOST` | — | `127.0.0.1` | |
| `REDACT_CODE_IN_LOGS` | — | `false` | `true` in the deployment |
| `TELEGRAM_PROXY_URL` | — | unset ⇒ direct | HTTP CONNECT or SOCKS5. `HTTPS_PROXY`/`ALL_PROXY` are honoured as a fallback |
`telegram-otp-bot/.env` is for **local `npm start` only** — nothing in it is read inside the container.
## The database is not containerised
It is a remote SQL Server at `87.107.152.16:1433`, already provisioned and already seeded. Nothing in
`docker-compose.yml` creates it; the API only needs network reach. Two databases: `Baya` (app) and
`Baya_Logs` (Serilog sink).
## Health, metrics, and the secrets guard
| Endpoint | Checks |
| --- | --- |
| `/healthz/live` | process only — deliberately dependency-free, so a dependency outage never restarts a healthy instance |
| `/healthz/ready` | app DB · log DB (**deployed only** — its connection string is a placeholder in Development) · an object-storage **write** round-trip |
| `/HealthCheck` | the aggregate, retained for existing probes |
| `/metrics` | Prometheus scrape. OpenTelemetry is the only metrics source; the duplicate prometheus-net stack was removed |
`StartupSecretsGuard` runs before any service reads configuration and **refuses to boot** on a missing or
placeholder value. It requires both connection strings in every environment, and the four crypto keys only
when **not** Development. It is skipped entirely in the `Testing` environment. Placeholder markers:
`SET_VIA_USER_SECRETS_OR_ENV`, `not-for-production`, `change-me`,
`ShouldBe-LongerThan-16Char-SecretKey`, `16CharEncryptKey`.
---
## What the diff found
Enumerating both sets and subtracting them surfaced five things. None is a broken deployment; all five are
places where the config is not discoverable from the config files.
1. **`NEXT_PUBLIC_EVV_MOCK_GPS` and `NEXT_PUBLIC_VERSION` are read by client code and declared in no `.env`
file.** Both have working defaults. Adding them commented-out to `.env.sample` would make them findable.
2. **`OpenTelemetry:Otlp:Endpoint`, `Search:Backend` and `Seed:Admin*` are read by server code and set
nowhere.** All three are intentionally opt-in, but a reader of `appsettings.json` cannot learn they
exist. `Search:Backend` is the sharpest: a wrong value **throws at startup**.
3. **`client/.env.sample` still says `NEXT_PUBLIC_API_URL = https://localhost:5002`** — the `https` half of
contradiction **C-3**, in the one file a fresh clone is meant to copy. `.env.development` has the
correct `http://`.
4. **`Seams:Sms:Telegram:ApiKey` and the relay's `API_KEY` are the same secret in two files** with no
mechanism keeping them equal. They currently match. A mismatch fails every OTP send at runtime, not at
boot.
5. **There is no `appsettings.Production.json`,** and `DEPLOY.md` step 2 of "Going to Production" is
therefore a *create*, not an *edit*.
### The placeholder's name
`SET_VIA_USER_SECRETS_OR_ENV` names a store that no longer exists (contradiction **C-2**). The *behaviour*
is correct — it is a sentinel that `StartupSecretsGuard` rejects — but the name instructs a reader to use a
removed mechanism.
It was **not renamed in this phase**, because the string is load-bearing in several live files:
`appsettings.json` (×6), `StartupSecretsGuard.cs`, `Baya.Test.Api/StartupSecretsGuardTests.cs` (×2), and
`docs/rules/server/structure.md`. Renaming it is a server-code + test change requiring `dotnet build` and
`dotnet test` to prove the gate still fires — out of scope for a documentation phase. **This section is the
authoritative statement of the mechanism**; the rename is filed for Phase 4 with that worklist.
@@ -0,0 +1,45 @@
# addresses — customer addresses
> Client seam `client/src/services/addresses/` · `USE_ADDRESSES_MOCK = false` (**real**) · 5 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The customer's saved addresses. One address is primary; the rest are ordered by recency. The address a
booking uses is **snapshotted** onto the booking, so editing an address later never rewrites history.
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/customer_addresses/list` | `[Authorize]` | wired · paginated (`Page`/`PageSize`) |
| POST | `/api/v1/customer_addresses/create` | `[Authorize]` | wired |
| POST | `/api/v1/customer_addresses/update/{id}` | `[Authorize]` | wired |
| POST | `/api/v1/customer_addresses/set_primary/{id}` | `[Authorize]` | wired |
| DELETE | `/api/v1/customer_addresses/delete/{id}` | `[Authorize]` | wired · **soft delete** |
No phantoms. The domain maps 1:1.
## Shape rules the JSON does not express
- **`provinceId` is on `CustomerAddressDto`** (REQ-009, delivered). The client needs it to preselect the
province in the city cascade without a reverse lookup.
- **The client-picked map pin is accepted on create and update** (REQ-008, delivered) — the server does
**not** re-geocode over a pin the user placed. When no pin is given, `IGeocoder` resolves one.
- **`latitude`/`longitude` are nullable.** Null means the geocoder could not resolve the address and the
user placed no pin; the UI shows "saved without a map pin" rather than an error. `IGeocoder`'s mock
forces this path for any address whose text contains `NO_GEO`
(`Seams:Geocoding:LowConfidenceMarker`).
- **The full address line is encrypted at rest** and returned decrypted only to its owner. A *booking
request* sees a city/district-coarse mask instead — see [booking-requests.md](booking-requests.md).
- **`set_primary` touches two rows** (demote the old, promote the new) in one transaction. The client
invalidates the whole list key rather than patching one item.
- Delete is a soft delete behind the entity's global query filter; a booking that snapshotted the address
is unaffected.
## Enums
None of its own. `provinceId` / `cityId` / `districtId` are geography ids — see
[geography.md](geography.md), where **`districtId = null` means whole-city**.
## Open REQs
None. REQ-008 and REQ-009 were both delivered in refinement-phase-3 and are folded into the rules above.
+99
View File
@@ -0,0 +1,99 @@
# admin — platform config, holidays, audit, support alerts
> Client seam `client/src/services/admin/` · `USE_ADMIN_MOCK = true` (**mock is primary**) · 14 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The ops console's cross-cutting reads and writes. Domain-specific admin surfaces live with their domain —
verification admin in [verification.md](verification.md), refunds in [refunds.md](refunds.md), payouts in
[payouts.md](payouts.md), catalog in [catalog.md](catalog.md), geo in [geography.md](geography.md),
partner centers in [partner-center.md](partner-center.md), the ticket queue in [tickets.md](tickets.md).
Every endpoint here is `[Authorize(ConstantPolicies.DynamicPermission)]` + `sensitive` rate limit
(20/min), **except** the three `platform_config`/`holidays`/`audit`/`support_alerts` controllers, which
carry the dynamic-permission policy without the sensitive limit — they fall to the 100/min global limiter.
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/platform_config/get_platform_configs` | wired · paginated |
| POST | `/api/v1/platform_config/update_platform_config` | wired |
| GET | `/api/v1/platform_config/get_config_change_history` | wired · paginated |
| GET | `/api/v1/holidays/get_holidays` | wired · paginated |
| POST | `/api/v1/holidays/upsert_holiday` | wired |
| POST | `/api/v1/holidays/delete_holiday` | **unwired** — no real client caller; the console offers no delete |
| GET | `/api/v1/audit/get_audit_trail` | wired · paginated |
| GET | `/api/v1/support_alerts/get_support_alerts` | wired · paginated |
| POST | `/api/v1/support_alerts/assign_support_alert` | wired |
| POST | `/api/v1/support_alerts/resolve_support_alert` | wired |
| GET | `/api/v1/admin_cancellation_policies/list` | **unwired** — the tier table is read through [refunds.md](refunds.md)'s policy preview instead |
| POST | `/api/v1/admin_cancellation_policies/upsert` | **unwired** — no console screen edits tiers |
| POST | `/api/v1/admin_search/rebuild_index` | **unwired** — an ops one-shot, no UI |
| POST | `/api/v1/admin_booking_requests/expire` | **unwired** — an ops one-shot; the scheduler does this unattended |
### Phantom — 5
The client's real `clientApi.ts` calls five routes the server does not expose. Both groups are
**deliberately written real-shaped** so flipping the seam is one line once they ship.
| Client call | REQ | Note |
| --- | --- | --- |
| `GET /api/v1/admin_roles/list_roles` | REQ-031 | **Deferred** in refinement-phase-3. The admin sub-role vocabulary and phone-OTP admins are seeded, not managed |
| `POST /api/v1/admin_roles/grant_role` | REQ-031 | |
| `POST /api/v1/admin_roles/revoke_role` | REQ-031 | |
| `GET /api/v1/admin_users/search` | **REQ-061 — never filed** | Backs `UserPicker`/`NursePicker` |
| `POST /api/v1/admin_users/lookup` | **REQ-061 — never filed** | Batch id→label resolve for `AuditLogRow` |
> **REQ-061 does not exist in the ledger.** `ui-phase-11-report.md` records "REQ-061…064 appended", but
> only 062/063/064 were. Ten live client files cite REQ-061 for the admin user directory. Phase 4 must
> file it rather than assume it is tracked.
## Two live drifts
**1. The client sends `page_size`; these endpoints declare `PageSize`.** `admin/apis/clientApi.ts`'s
`pageQuery()` builds `page` + `page_size` "per b1 api-conventions". Model binding is case-*insensitive*,
not separator-insensitive, so `page_size` does **not** bind to `PageSize` — every admin list would
silently fall back to the server's default page size. Invisible today because the mock is primary; it
becomes a real defect the moment `USE_ADMIN_MOCK` flips. See
[../api-contract.md](../api-contract.md#pagination).
**2. `updatedAt`/`updatedBy` and the audit filters *are* on the wire.** REQ-029 (config audit fields) and
REQ-030 (`actorId`/`action`/`from`/`to` filters on `audit/get_audit_trail`) were both **delivered** in
refinement-phase-3. `admin/constants.ts` still gives them as reasons the mock is primary. The only
remaining reason is REQ-031 + REQ-061.
## Shape rules the JSON does not express
- **Platform config is rows read at compute time**, never hardcoded, and **a rate change is never
retroactive** — the effective rate is snapshotted onto the row when the amount is computed.
- `platform_fee_rate` and `vat_rate` are **rates in `[0, 1)`** — the console validates the closed-open
interval before writing (`RATE_CONFIG_KEYS` in `admin/constants.ts`). The canonical values are
`0.15` fee / `0.10` VAT (refinement-phase-3).
- The audit trail's `changedFieldsJson` is a **string containing JSON**, not an object:
`{"Field": {"old": …, "new": …}}`. The client parses it defensively and yields `null` on malformed input.
- `POST update_platform_config` and the holiday/alert writes go through self-committing facades that call
`SaveChanges` on the shared scoped context — they run **after** the handler's own `CommitAsync`.
- Holidays drive **payout date shifting**: the server resolves a bank-closure-safe payout date from this
calendar and the client never computes one.
## Enums
| Vocabulary | Values |
| --- | --- |
| `ConfigDataType` | `string` `int` `decimal` `bool` `json` |
| `AuditAction` | `created` `updated` `deleted` |
| `HolidayType` | `official` `religious` `national` |
| `SupportAlertType` | `low_rating` `evv_no_show` `evv_location_mismatch` `verification_expired` `shared_sim` `payment_anomaly` `fraud_signal` `nurse_clawback` `emergency` |
| `SupportAlertSeverity` | `low` `medium` `high` |
| `SupportAlertStatus` | `open` `assigned` `resolved` |
| `AdminRole` *(phantom surface)* | `super_admin` `admin` `support` `finance` `moderation` |
| `DirectoryUserRole` *(phantom surface)* | `customer` `nurse` `admin` `partner` |
The last two describe the REQ-031/REQ-061 shapes and are **not on the wire**.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-031 | deferred | No RBAC console. Admin roles are seeded |
| **REQ-061** | **never filed** | No admin user directory. `AuditLogRow` shows `#id` instead of a name |
+79
View File
@@ -0,0 +1,79 @@
# auth — phone OTP, sessions, `/me`, role selection
> Client seam `client/src/services/auth/` · `USE_AUTH_MOCK = false` (**real**) · 7 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The only way into the platform. Phone + OTP, no passwords. The transport rules — bearer header, cookie
storage, silent refresh, rotation, reuse detection — are in
[../api-contract.md](../api-contract.md#auth); this file is the endpoints and the payload semantics.
## Endpoints
| Method | Path | Auth | Rate limit | Verdict |
| --- | --- | --- | --- | --- |
| POST | `/api/v1/auth/request_otp` | **anonymous** | `otp` 5/min | wired |
| POST | `/api/v1/auth/verify_otp` | **anonymous** | `otp` 5/min | wired |
| POST | `/api/v1/auth/refresh` | **anonymous** | `auth` 10/min | wired · called by both `services/auth` **and** the fetch layer |
| POST | `/api/v1/auth/logout` | `[Authorize]` | — | wired |
| GET | `/api/v1/me` | `[Authorize]` | — | wired |
| POST | `/api/v1/me/select_role` | `[Authorize]` | — | wired |
| GET | `/api/v1/dev/last_otp/{phone}` | **anonymous** | — | **Development only** — see below |
No phantoms.
`AUTH_API_BASE` is `/api/v1`; the routes above are exactly what the client sends.
## `dev/last_otp` — live on the deployment
`DevController` is registered unconditionally, and the OTP-capture bridge behind it is wired only when
`IsDevelopment()` **and** the SMS provider is capture-safe (`mock` or `telegram`). The
`balinyaar.ir` deployment runs `ASPNETCORE_ENVIRONMENT=Development` with `Seams:Sms:Provider = telegram`,
so **both conditions hold and the endpoint is reachable on `api.balinyaar.ir`.** Anyone who knows a
registered phone number can read its login code. Recorded in [DEPLOY.md](../../../DEPLOY.md) as the
deployment's largest exposure; the fix is the environment switch, not a code change.
Selecting a real gateway (`kavenegar`, …) disables the bridge — the OTP must never be logged or captured
once real SMS ships.
## Shape rules the JSON does not express
- **`RequestOtpResult` carries the code length and expiry** (REQ-002, delivered) so the client sizes the
input and runs the countdown from server truth rather than a hardcoded constant.
- **`verify_otp` failures carry a machine-readable `code`** on the envelope (REQ-003, delivered) —
e.g. `otp_locked` — so the client branches on the state instead of matching a message string. This is
the `code` field described in [../api-contract.md](../api-contract.md#the-envelope).
- **`refresh` returns a new pair and retires the old refresh token.** Presenting a retired token is
treated as theft: the session is killed, not merely refused. A 401 from `/auth/refresh` is therefore
terminal and the client must not retry it.
- **`/me` is the only source of identity.** The JWE is opaque; the client reads role, gender and
profile-completeness from `/me`, never from a decoded claim.
- **Multi-role users**: `/me` reports the roles the caller holds. `POST /me/select_role` commits to one.
REQ-004 was **resolved as a client concern** — the client owns the disambiguation and the "resolved vs.
pending" role hydration; no backend change was needed. See
[docs/rules/client/auth.md](../../rules/client/auth.md).
- **`logout` returns an empty envelope** — no `data`. The client awaits the revocation and must not
`unwrap()` it.
- **Phone numbers are encrypted at rest.** Login looks the user up by a deterministic HMAC hash
(`users.PhoneHash`, derived from `Seams:FieldEncryption:HashKey`), never by comparing the encrypted
column. This is why that key is immutable — see [../config-matrix.md](../config-matrix.md).
## Enums
| Vocabulary | Values |
| --- | --- |
| `PublicRole` | `customer` `nurse` |
| `AdminRole` | `admin` `support` `finance` `moderation` `super_admin` |
| `Gender` | `male` `female`**load-bearing**, drives same-gender caregiver matching |
| `NurseVerificationStatus` *(as surfaced on `/me`)* | `not_started` `in_progress` `pending_review` `verified` `rejected` |
> The `/me` verification summary uses a **different vocabulary** from the verification domain's own
> aggregate status (`not_started` `pending` `in_review` `approved` `rejected` `suspended`). They are two
> read models over the same source of truth, not a drift — but do not treat the strings as
> interchangeable. See [verification.md](verification.md).
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-038 | open | `/me` carries no signal that the caller administers a partner center, so partner auto-routing cannot be driven off it. See [partner-center.md](partner-center.md) |
| REQ-039 | open | The OTP SMS template is not WebOTP-conformant, so the browser's one-tap autofill never fires. The client's WebOTP hook ships anyway and degrades silently |
+92
View File
@@ -0,0 +1,92 @@
# bnpl — provider-financed installments
> Client seam `client/src/services/bnpl/` · `USE_BNPL_MOCK = true` (**mock is primary**) · 9 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The second checkout rail. **Balinyaar does not finance anything** — a provider (SnappPay, Digipay, …) pays
the platform net of its commission and carries the customer's installments itself. Card checkout is
[payment.md](payment.md).
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| POST | `/api/v1/checkout_bnpl/eligibility` | `[Authorize]` · `sensitive` | wired |
| POST | `/api/v1/checkout_bnpl/initiate` | `[Authorize]` · `sensitive` | wired · **`Idempotency-Key`** |
| GET | `/api/v1/checkout_bnpl/{id}` | `[Authorize]` · `sensitive` | wired |
| GET | `/api/v1/checkout_bnpl/by_request/{bookingRequestId}` | `[Authorize]` · `sensitive` | wired |
| POST | `/api/v1/webhooks_bnpl/{provider}` | **anonymous** · `webhook` 120/min | server-only — the provider calls it |
| GET | `/api/v1/admin_bnpl/{id}` | admin · `sensitive` | **unwired** — no console screen |
| POST | `/api/v1/admin_bnpl/{id}/verify` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/admin_bnpl/{id}/settle` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/admin_bnpl/{id}/revert` | admin · `sensitive` | **unwired** — the reversal is driven from [refunds.md](refunds.md) instead |
### Phantom — 3
All three are REQ-022's deferred half. Written real-shaped so the swap is one line.
| Client call | REQ | Note |
| --- | --- | --- |
| `GET /api/v1/checkout_bnpl/options/{bookingRequestId}` | REQ-022 | The D1/D2 provider + plan list, per-plan monthly / down-payment / total |
| `GET /api/v1/checkout_bnpl/schedule/{id}` | REQ-022 | The D4 repayment schedule |
| `GET /api/v1/checkout_bnpl/wallet_installments` | REQ-024 | The D5 wallet installment list |
REQ-022 was **partially** delivered: `balinyaar` was added to the `provider_code` enum; `options` and
`schedule` were deferred. `BnplEligibilityDto` carries a single `planSummary` + `installmentCount`, not a
list of plans — which is exactly why the client needs `options`.
## The money shape
Two facts that make BNPL different from card, and both are easy to get wrong:
1. **The card payment is recorded net of the provider's fee.** The provider deducts its commission before
remitting, so the platform receives `orderAmount bnplCommission`. `settledAmountIrr` and
`bnplCommissionIrr` are both on `BnplOrderStatusDto`, and the handler reads the **actual deducted
amount from the settlement response** — never a rate from config. `Seams:Bnpl:CommissionRate` tunes the
*mock* only.
2. **Settlement is not necessarily instant.** `settledAt` is nullable, modelling the deferred / T+13 /
weekly reality. A null `settledAt` on a `settled` order is normal, not an inconsistency.
`BnplStatus` is **forward-only**. A reversal is `reverted`, with `revertTransactionId`,
`revertedAmountIrr`, `revertedAt` and — when the provider returns it — `providerCommissionReversedAmount`,
which the reconciliation needs and which most providers do not send. See [refunds.md](refunds.md) for the
`bnpl_revert` refund channel.
## Shape rules the JSON does not express
- **Eligibility accepts the credit-check inputs** `{ nationalId, mobile, consent }` (REQ-023, delivered).
Consent is **required** when the KYC inquiry runs — it is a legal precondition, not a checkbox.
- **`eligibilityStatus` distinguishes three outcomes**, and the third is not a failure:
`not_eligible` (provider declined) vs `ceiling_exceeded` (order above `creditCeilingIrr` — offer card
instead) vs `eligible`. The UI must fall back to card, not show an error, on either negative.
- **`bookingId` is on the settled order** (REQ-024, confirmed) so the wallet can link an installment plan
to its booking.
- **D5 installment status is provider-reported, not ledger-derived.** The platform does not track the
customer's repayment; whatever the provider says is the truth. Never compute an installment state from
Balinyaar's own ledger.
- **`currency` is on the wire and matters.** `Seams:Bnpl:WireCurrency` is `IRR` by default; SnappPay and
Digipay speak Rial. Conversion happens **only** inside the adapter via `ICurrencyNormalizer`.
- Provider credentials proper live in the encrypted `payment_gateways.config_json`; only non-secret
connection facts (base URL, sandbox flag, merchant handle) come from `Seams:Bnpl:Providers`.
- `Seams:Bnpl:NotEligibleMobile` (`09120000099`) is the designated test mobile that returns
`not_eligible`, so the fall-back-to-card path is testable.
## Enums
| Vocabulary | Values |
| --- | --- |
| `BnplStatus` | `eligible` `token_issued` `verified` `settled` `reverted` `cancelled` `failed` |
| `BnplEligibilityStatus` | `eligible` `not_eligible` `ceiling_exceeded` |
| `ProviderCode` | `snapppay` `digipay` `tara` `torobpay` `balinyaar` |
| `BnplInstallmentStatus` *(D5, provider-reported)* | `paid` `due_soon` `upcoming` `overdue` |
| `BnplHandoffOutcome` *(client, from the return URL)* | `success` `failure` |
The first three are verified identical to `Entities/Bnpl/BnplStatus.cs`,
`BnplEligibilityStatus.cs` and `BnplProviderCodes.cs`. Note `snapppay` has **three** `p`s.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-022 | partially delivered | `options` and `schedule` deferred → 3 phantom routes. D1/D2/D4 are mock-only |
| REQ-024 | partially delivered | `bookingId` confirmed present; the wallet installment list is deferred |
@@ -0,0 +1,104 @@
# booking-requests — the money-free pre-payment request
> Client seam `client/src/services/bookingRequests/` · `USE_BOOKING_REQUESTS_MOCK = false` (**real**) · 7 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Stage one of the booking flow: the customer asks, the nurse answers, and **no money exists yet**. A
`booking_requests` row becomes a `bookings` row only on payment capture — see
[bookings.md](bookings.md) and [payment.md](payment.md).
> `Features/Booking` (singular, this domain) and `Features/Bookings` (plural, the post-payment engine) are
> **different server areas, not a rename.**
## Endpoints
| Method | Path | Caller | Verdict |
| --- | --- | --- | --- |
| POST | `/api/v1/booking_requests/create` | customer | wired |
| GET | `/api/v1/booking_requests/list` | both | wired · paginated · role-scoped |
| GET | `/api/v1/booking_requests/get/{id}` | both | wired |
| POST | `/api/v1/booking_requests/accept/{id}` | nurse | wired |
| POST | `/api/v1/booking_requests/reject/{id}` | nurse | wired |
| POST | `/api/v1/booking_requests/cancel/{id}` | customer | wired |
| GET | `/api/v1/booking_requests/checkout_summary/{id}` | customer | wired — but read by the **[payment](payment.md)** domain, not this one |
All `[Authorize]`. No phantoms.
`checkout_summary` is the C6 money read (gross / commission / VAT breakdown, REQ-016 delivered). It lives
on this controller because the request is what gets paid for, and is documented in
[payment.md](payment.md) where it is consumed.
## The lifecycle
```
pending_nurse_response ──accept──▸ accepted_awaiting_payment ──capture──▸ converted
│ │
├──reject──▸ rejected_by_nurse └──window lapses──▸ payment_deadline_expired
├──deadline──▸ expired_no_response
└──customer──▸ cancelled_by_customer
```
**Forward-only.** A backward or sideways transition is a clean `409`, never a 500. Two deadlines are
server-owned and config-driven (`booking_request_response_deadline_minutes`,
`payment_window_minutes` in [admin.md](admin.md)):
- the nurse's response window → `expired_no_response`
- the customer's payment window after acceptance → `payment_deadline_expired`
`POST /api/v1/admin_booking_requests/expire` is the ops one-shot for both; the in-process scheduler runs
it unattended. See [admin.md](admin.md).
## Shape rules the JSON does not express
- **Stage-one disclosure is deliberately partial.** Before payment the nurse sees only unencrypted
`customerNotes` and a **city/district-coarse masked address**. Encrypted care instructions and the full
address are unreadable until the booking is confirmed. This is a hard server rule, not a UI choice.
- **The countdown is server-frozen.** `CountdownTimer` renders a deadline the server sent; the client
never computes an expiry from a local clock.
- **`variantPrice` is on `BookingRequestDto`** (REQ-013, delivered) so the customer sees the price they are
committing to without a second variant fetch. It is a **digit string**.
- **The inbox list item carries `variantLabel` + `patientAge`** (REQ-014, delivered).
### The two list shapes, exactly
Confirmed field-by-field against the live swagger, because three in-repo comments disagree about this:
| | `BookingRequestListItemDto` (list) | `BookingRequestDto` (detail) |
| --- | --- | --- |
| `variantLabel` | **yes** | yes |
| `patientAge` | **yes** | — (`patientName` instead) |
| `variantPrice` · `variantPriceUnit` | **no** | yes |
| address / notes | `customerNotes` only | full masked address block |
| `nurseRejectionReason` | — | yes, **free text** |
Two consequences:
- **`client/src/services/bookingRequests/types.ts` is behind the wire.** It marks `variantLabel` as
"client-augmented … `undefined` on the real path", but the server serves it. Widening the client type is
safe and would let the real inbox card render its decision-first headline today.
- **REQ-050 is partly stale.** It states the list DTO carries neither field, "confirmed against
`services/bookingRequests/types.ts`" — i.e. confirmed against the client type, not the wire. What the
wire genuinely lacks is `variantPrice`/`variantPriceUnit` on the *list* row and the `status=answered`
group filter.
- **`requiredCaregiverGender` is never defaulted or dropped.** `any` is an explicit choice, distinct from
absent.
- The address the request references is snapshotted at create time; later edits to the saved address do
not rewrite it.
## Enums
| Vocabulary | Values |
| --- | --- |
| `BookingRequestStatus` | `pending_nurse_response` `accepted_awaiting_payment` `converted` `rejected_by_nurse` `expired_no_response` `payment_deadline_expired` `cancelled_by_customer` |
| `RequiredCaregiverGender` | `male` `female` `any` |
| `RequestRole` *(client-side list filter)* | `customer` `nurse` |
Verified identical to `Baya.Domain/Entities/Booking/BookingRequestStatus.cs`. REQ-015 confirmed these
serialise as the exact snake_case codes.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-044 | open | No structured `nurseRejectionReasonCode` — the wire carries free-text `nurseRejectionReason`. The client runs a keyword heuristic over it, documented in-code as a known approximation |
| REQ-050 | open, **narrower than filed** | The list row already has `variantLabel`; it lacks `variantPrice`/`variantPriceUnit`. The `status=answered` group filter is genuinely absent, so the «پاسخ‌داده» tab fires three page-1 queries and concatenates — an unpaged workaround a nurse with many answered requests will hit |
@@ -0,0 +1,97 @@
# bookings — the post-payment engine, sessions and EVV
> Client seam `client/src/services/bookings/` · `USE_BOOKINGS_MOCK = false` (**real**) · 16 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Stage two: a paid booking, its per-visit sessions, and electronic visit verification. A `bookings` row is
created **only on payment capture**, from an accepted [booking request](booking-requests.md).
## Endpoints
| Method | Path | Caller | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/bookings/list` | both | wired · paginated · `role=customer\|nurse\|all` |
| GET | `/api/v1/bookings/get/{id}` | both | wired |
| GET | `/api/v1/bookings/care_instructions/{id}` | nurse, admin | wired · **stage-2 disclosure gate** |
| POST | `/api/v1/bookings/submit_care_instructions/{id}` | customer | **unwired** — the client has no care-instructions form |
| POST | `/api/v1/bookings/convert` | — | **unwired** — Development-only capture simulator; the PSP webhook supersedes it |
| POST | `/api/v1/bookings/transition/{id}` | admin | **unwired** — a raw state-machine escape hatch, no UI |
| POST | `/api/v1/bookings/cancel/{id}` | — | **unwired, superseded** by `{id}/cancel` |
| POST | `/api/v1/bookings/{id}/cancel` | customer | wired — by **[refunds](refunds.md)** (cancel *and* refund, REQ-019) |
| GET | `/api/v1/bookings/{id}/cancellation_policy` | customer | wired — by **[refunds](refunds.md)** (REQ-020) |
| GET | `/api/v1/booking_sessions/today` | nurse | wired · paginated |
| GET | `/api/v1/booking_sessions/evv/{id}` | nurse | wired |
| POST | `/api/v1/booking_sessions/check_in/{id}` | nurse | wired |
| POST | `/api/v1/booking_sessions/check_out/{id}` | nurse | wired · `sensitive` 20/min — **this is what releases the payout clock** |
| POST | `/api/v1/booking_sessions/cancel/{id}` | nurse | **unwired** — no per-session cancel in the UI |
| GET | `/api/v1/admin_evv/list` | admin | **unwired** — no console screen; the mock demonstrates it |
| POST | `/api/v1/admin_evv/detect_no_shows` | admin | **unwired** — an ops one-shot; the scheduler runs it |
All `[Authorize]`; the two `admin_evv` routes are `DynamicPermission` + `sensitive`. No phantoms.
> **Two cancel routes exist on the same controller.** `POST bookings/cancel/{id}` (action-style, b9) and
> `POST bookings/{id}/cancel` (REST-style, b11 cancel-and-refund). Only the second is wired. They are
> not aliases — the second also drives the refund. Treat the first as legacy.
## The lifecycle
```
pending_payment ──capture──▸ confirmed ──first check-in──▸ in_progress
│ │
│ all sessions out
▼ ▼
cancelled ◂──cancel── completed ──dispute window──▸ closed
└──dispute──▸ disputed
```
Forward-only, through the transition table. `status` has a private setter; only cohesive domain methods
mutate it and the handler pre-checks, returning a clean `409`.
Sessions run their own machine: `scheduled → in_progress → completed`, or `missed` / `cancelled`.
## Shape rules the JSON does not express
- **Two-stage clinical disclosure is enforced server-side.** `care_instructions/{id}` decrypts and returns
the care plan **only** post-confirmation and **only** to the assigned nurse or an admin. It is never
projected into a list and never logged. The client's UI gate mirrors this; it does not create it.
- **EVV is advisory, and `checkInAddressMatch` is a tri-state**: `true` (inside tolerance), `false`
(outside), `null` (**no reading** — permission denied or unavailable). Null is not a failure. A
mismatch does not block check-in; it raises a support alert
(`evv_location_mismatch`, see [admin.md](admin.md)). Tolerance is
`evv_location_tolerance_meters` config. REQ-015 confirmed the tri-state.
- **`BookingDetailDto.variantSnapshotJson` and `addressSnapshotJson` are strings containing JSON**, not
typed objects — the point of a snapshot is that later edits to the variant or address cannot rewrite
history. The client parses defensively across multiple key spellings (REQ-045 open).
- **The three-amount split is guaranteed by a DB CHECK**:
`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. `platformFeeRate` is **snapshotted onto
the row** at compute time, so a later rate change is not retroactive. Never recompute any of these.
- **`payoutEligibleAt` per session** is the payout clock, started by check-out and resolved against the
holiday calendar server-side. See [payouts.md](payouts.md).
- **`disputeWindowEndsAt`** gates `completed → closed`; `dispute_window_hours` is config.
- `BookingListItemDto` is deliberately thin: `id, status, counterpartyName, scheduledDate, sessionCount,
amountIrr, disputeWindowEndsAt, createdAt`. **No `patientId`** — which is what blocks REQ-057's care
teaser.
- `NEXT_PUBLIC_EVV_MOCK_GPS` overrides the GPS reading for local testing (`off` = real capture). See
[../config-matrix.md](../config-matrix.md).
## Enums
| Vocabulary | Values |
| --- | --- |
| `BookingStatus` | `pending_payment` `confirmed` `in_progress` `completed` `disputed` `closed` `cancelled` |
| `BookingSessionStatus` | `scheduled` `in_progress` `completed` `missed` `cancelled` |
| `VisitVerificationStatus` (`evvStatus`) | `pending` `checked_in` `completed` |
| `BookingListRole` *(query param)* | `customer` `nurse` `all` |
| `EvvGpsMode` *(client test knob)* | `off` `in_range` `out_of_range` `denied` |
Verified identical to `Baya.Domain/Entities/Booking/*.cs`.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-045 | open | `variantSnapshot`/`addressSnapshot` are untyped JSON strings. The client keeps a defensive multi-key parse; no user-facing defect |
| REQ-051 | open | The nurse view of a confirmed+ booking still masks the address. The nurse sees a quiet fallback note, not a crash |
| REQ-052 | open | The today feed carries no service label — it renders patient name + visit index only |
| REQ-054 | deferred | No web push for new requests; a 15 s poll remains the only signal |
| REQ-057 | open | `BookingListItemDto` has no `patientId` and the patient read has no `lastVisitAt`, so the card renders no care teaser. See [patients.md](patients.md) |
@@ -0,0 +1,63 @@
# catalog — service categories, option groups, nurse pricing variants
> Client seam `client/src/services/catalog/` · `USE_CATALOG_MOCK = false` (**real**) · 14 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
An EAV catalogue: admin defines categories and their option groups; each nurse composes **variants**
a category + a chosen set of option values + a price. A variant is what a customer actually books.
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/catalog/categories` | **anonymous** | wired · paginated |
| GET | `/api/v1/catalog/option_groups` | **anonymous** | wired |
| GET | `/api/v1/nurse_variants/list` | `[Authorize]` | wired · paginated |
| GET | `/api/v1/nurse_variants/get/{id}` | **anonymous** | wired |
| POST | `/api/v1/nurse_variants/create` | `[Authorize]` | wired |
| POST | `/api/v1/nurse_variants/update/{id}` | `[Authorize]` | wired |
| POST | `/api/v1/nurse_variants/set_active/{id}` | `[Authorize]` | wired |
| POST | `/api/v1/admin_catalog/create_category` | admin | **unwired** — no console screen |
| POST | `/api/v1/admin_catalog/update_category/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/set_category_active/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/create_option_group` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/update_option_group/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/create_option_value` | admin | **unwired** |
| POST | `/api/v1/admin_catalog/update_option_value/{id}` | admin | **unwired** |
No phantoms. The seven `admin_catalog` routes are `DynamicPermission` + `sensitive`; the catalogue is
seeded and managed out of band today, so the console has no editor. That is a UI gap, not a contract gap.
> Mutations are **action-style, not REST**: `POST admin_catalog/create_category`, never
> `POST admin/catalog/categories`. The old contract doc calls this out explicitly and it still holds.
## Shape rules the JSON does not express
- **`GET nurse_variants/get/{id}` is anonymous** while `list` requires auth. That asymmetry is deliberate —
a public nurse profile links to a specific variant.
- **A duplicate variant is rejected by `option_set_hash`.** The server hashes the chosen option-value set
per (nurse, category) and enforces uniqueness, so a nurse cannot list the same configuration twice at two
prices. The client surfaces the resulting `409`, it does not pre-check.
- **The variant snapshot is serialised at booking time** (`IVariantSnapshotSerializer`) onto the booking
row — see [bookings.md](bookings.md). Editing or deactivating a variant never changes a past booking.
- **`set_active` is the only way to retire a variant.** There is no delete; a variant referenced by
bookings must remain resolvable.
- **Prices are IRR digit strings** outbound. The nurse enters Toman in the UI and the client converts at
the input boundary — the wire is always IRR.
- Reference names come as **both** `nameFa` and `nameEn`; the client picks by locale. `OptionGroupDto`
carries its `values` inline, so the variant builder needs one round trip, not one per group.
- `isRequired` + `sortOrder` on an option group drive the builder's validation and layout — the client
does not hardcode either.
## Enums
| Vocabulary | Values |
| --- | --- |
| `PriceUnit` | `per_hour` `per_session` `per_half_day` `per_day` `per_24h` |
`PriceUnit` is a label vocabulary, never a multiplier — the client must not derive a total from it. Display
strings are i18n keys, never the code.
## Open REQs
None. The variant builder (b7) and the Home category grid (A5) both read the contract as served.
@@ -0,0 +1,64 @@
# geography — provinces, cities, districts
> Client seam `client/src/services/geography/` · `USE_GEOGRAPHY_MOCK = false` (**real**) · 13 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The reference hierarchy every address, service area and search filter is keyed on. Also the home of the
single most load-bearing null in the schema.
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/geo/provinces` | **anonymous** | wired |
| GET | `/api/v1/geo/cities` | **anonymous** | wired |
| GET | `/api/v1/geo/districts` | **anonymous** | wired |
| GET | `/api/v1/geo/tree` | **anonymous** | **unwired** — the client fetches the three levels separately and caches each |
| POST | `/api/v1/admin_geo/create_province` | admin | **unwired** — no console screen |
| POST | `/api/v1/admin_geo/update_province/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/set_province_active/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/create_city` | admin | **unwired** |
| POST | `/api/v1/admin_geo/update_city/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/set_city_active/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/create_district` | admin | **unwired** |
| POST | `/api/v1/admin_geo/update_district/{id}` | admin | **unwired** |
| POST | `/api/v1/admin_geo/set_district_active/{id}` | admin | **unwired** |
No phantoms. The nine `admin_geo` routes are `DynamicPermission` + `sensitive`; geography is seeded, so
there is no console editor. Mutations are **action-style** (`admin_geo/create_city`, never
`admin_geo/cities`).
## `districtId = null` means whole-city
This is the one rule to get right, and it reads in **both** directions:
- **On a nurse service area** (see [service-areas.md](service-areas.md)), `districtId = null` means the
nurse covers the **entire city**, not "no district".
- **On a search query**, a customer in district *D* must match both a nurse whose area names *D* and a
nurse whose area is whole-city. See [search.md](search.md).
- **On an address** it is genuinely optional metadata — a missing district does not widen anything.
Never coerce the null to 0 or to a sentinel id, and never write a query that drops whole-city rows.
## Shape rules the JSON does not express
- Every level returns **both** `nameFa` and `nameEn`; the client picks by locale.
- `isActive` filters the pickers. An inactive city must stay **resolvable** — an existing address or
booking references it — so it is filtered from selection, never deleted.
- **Two Neshan keys exist and they are different products.** The server's geocoder key is
`Seams:Geocoding:ApiKey` (server-side address→point); the client's map key is
`NEXT_PUBLIC_NESHAN_KEY` (a *web* key for the embeddable map/search). Never share one value between
them. With the client key unset, `AddressMapPicker` falls back to a bounded-canvas grid, which is why
dev, CI and jsdom all work without it. See [../config-matrix.md](../config-matrix.md).
- Geocoding is a seam: `Seams:Geocoding:Provider` = `mock` (default) or `neshan`. The mock resolves a
deterministic point near the city centroid; an address containing `NO_GEO` resolves to null coordinates
so the "saved without a map pin" state is testable per-request.
## Enums
None. All three levels are integer ids with localised names.
## Open REQs
None. REQ-008 (accept the client-picked pin) and REQ-009 (`provinceId` on the address DTO) were delivered
and are documented in [addresses.md](addresses.md).
+121
View File
@@ -0,0 +1,121 @@
# Domain contracts
One file per client `services/` domain — **22 files, 22 domains, one-to-one.** Each names every server
endpoint that belongs to it, verdicted against the live swagger and the real client code.
> Last verified: 2026-07-30 against commit `d3ec723` and
> [`../openapi/swagger.v1.json`](../openapi/swagger.v1.json) (2026-07-29).
Read [`../api-contract.md`](../api-contract.md) first — the envelope, casing, pagination, errors, auth,
idempotency and money rules hold everywhere and are not restated per domain.
---
## How to read a domain file
Every endpoint carries one verdict:
| Verdict | Means |
| --- | --- |
| **wired** | In swagger **and** called by the domain's real `apis/clientApi.ts` |
| **unwired** | In swagger, no real client caller. Server-only, admin-only, or superseded — the reason is given |
| **phantom** | The client calls it; **the server has no such route.** It 404s. Always carries its REQ |
`phantom` rows are the frontend's proposed routes, filed as REQs and mocked behind the domain seam
meanwhile. They are not bugs in the client — they are the contract's open edge — but on a domain whose
mock is **off** they are live 404s, and that is called out where it happens.
## The census
186 operations, each in exactly one file below. 184 belong to a domain; 2 (`ping`) are platform
endpoints and live in [`../api-contract.md`](../api-contract.md#platform-endpoints-outside-every-domain).
| Domain file | `client/src/services/` | Seam | Server ops | Phantom |
| --- | --- | --- | --- | --- |
| [addresses.md](addresses.md) | `addresses` | real | 5 | — |
| [admin.md](admin.md) | `admin` | **mock** | 14 | 5 |
| [auth.md](auth.md) | `auth` | real | 7 | — |
| [bnpl.md](bnpl.md) | `bnpl` | **mock** | 9 | 3 |
| [booking-requests.md](booking-requests.md) | `bookingRequests` | real | 7 | — |
| [bookings.md](bookings.md) | `bookings` | real | 16 | — |
| [catalog.md](catalog.md) | `catalog` | real | 14 | — |
| [geography.md](geography.md) | `geography` | real | 13 | — |
| [notifications.md](notifications.md) | `notifications` | real | 4 | — |
| [nurse.md](nurse.md) | `nurse` | real | 4 | — |
| [partner-center.md](partner-center.md) | `partnerCenter` | **mock** | 9 | 6 |
| [patient-records.md](patient-records.md) | `patientRecords` | **mock** | 5 | — |
| [patients.md](patients.md) | `patients` | real | 5 | — |
| [payment.md](payment.md) | `payment` | real | 3 | 1 |
| [payouts.md](payouts.md) | `payouts` | **mock** | 13 | 1 |
| [profiles.md](profiles.md) | `profiles` | real | 7 | — |
| [refunds.md](refunds.md) | `refunds` | **mock** | 8 | 4 |
| [reviews.md](reviews.md) | `reviews` | real | 8 | — |
| [search.md](search.md) | `search` | real | 2 | — |
| [service-areas.md](service-areas.md) | `serviceAreas` | real | 3 | — |
| [tickets.md](tickets.md) | `tickets` | real | 11 | 1 |
| [verification.md](verification.md) | `verification` | **mock** | 17 | 3 |
| | | | **184** | **24** |
"Seam" is the domain's `constants.ts` flag (`USE_<DOMAIN>_MOCK`): **15 real, 7 mock.** A mocked domain
still has a complete real client — flipping the flag is one line in `apis/index.ts`.
**Two phantoms sit on a domain whose mock is off**, so they are reachable and they 404:
`GET /api/v1/bookings/payment_history` ([payment.md](payment.md), REQ-047) and
`POST /api/v1/tickets/{id}/assign` ([tickets.md](tickets.md), REQ-063). Both are guarded in the client —
the first renders an empty state, the second is behind a default-off capability flag.
## Route-shape exceptions
The routing convention is snake_case segments generated from `[controller]`/`[action]` tokens. **Four
controllers hardcode a route string instead**, and three of those introduce hyphens:
| Route | Controller | Note |
| --- | --- | --- |
| `api/v1/admin/partner-centers` | `AdminPartnerCentersController` | hyphens **and** a nested `admin/` segment; children add `/set-active`, `/sponsor-nurse` |
| `api/v1/admin/tickets` | `AdminTicketsController` | nested `admin/` segment |
| `api/v1/admin/reviews/moderation_queue` | `AdminReviewsController` | nested `admin/`, then snake_case |
| `api/v1/internal/bookings/{bookingId}/center` | `InternalCentersController` | an `internal/` namespace |
Every other admin controller uses a flat `admin_*` prefix (`admin_geo`, `admin_catalog`, `admin_refunds`,
…). The split is historical, not meaningful. Since the route also derives the dynamic-permission key,
normalising it is a breaking change to permissions as well as URLs — it is recorded here, not fixed.
## Enum vocabularies
Swagger declares **no** string enums (see
[`../api-contract.md`](../api-contract.md#enums)), so each domain file carries its own vocabulary. Every
one was cross-checked against the server's `Baya.Domain` code set **and** the client's string-literal
union. All match except one, noted in [tickets.md](tickets.md).
| Vocabulary | Domain file | Server source |
| --- | --- | --- |
| `BookingRequestStatus` · `RequiredCaregiverGender` | [booking-requests.md](booking-requests.md) | `Entities/Booking/BookingRequestStatus.cs` |
| `BookingStatus` · `BookingSessionStatus` · `VisitVerificationStatus` | [bookings.md](bookings.md) | `Entities/Booking/*.cs` |
| `PriceUnit` | [catalog.md](catalog.md) | catalog config rows |
| `BnplStatus` · `BnplEligibilityStatus` · `ProviderCode` | [bnpl.md](bnpl.md) | `Entities/Bnpl/*.cs` |
| `PaymentTransactionStatus` · `MoadianStatus` | [payment.md](payment.md) | `Entities/Payments/`, `Entities/Invoices/` |
| `RefundStatus` · `RefundChannel` · `ClawbackStatus` · cancellation codes | [refunds.md](refunds.md) | `Entities/Refunds/*.cs` |
| `PayoutStatus` · `PayoutBatchStatus` · `EarningsState` | [payouts.md](payouts.md) | `Entities/Payouts/*.cs` |
| `VerificationStatus` · `VerificationStepStatus` · `StepTypeCode` | [verification.md](verification.md) | `Entities/Verification/*.cs` |
| `ModerationStatus` · `ModerationAction` | [reviews.md](reviews.md) | `Entities/Reviews/ReviewModerationStatus.cs` |
| `TicketStatus` · `TicketCategory` · `TicketAuthorRole` | [tickets.md](tickets.md) | `Entities/Messaging/TicketCodes.cs` |
| `CenterOnboardingState` | [partner-center.md](partner-center.md) | `Entities/PartnerCenters/` |
| `BankAccountStatus` | [nurse.md](nurse.md) | bank-account entity |
| roles · `Gender` | [auth.md](auth.md) | identity seed |
| config/audit/holiday/alert codes | [admin.md](admin.md) | `Entities/Configuration/`, `Audit/`, `Holidays/`, `SupportAlerts/` |
## What replaced what
These files supersede [`archive/build-chain/contracts/domains/`](../../../archive/build-chain/contracts/domains/) — 17 hand-written files
frozen 2026-07-13, plus the two `conventions/` files. Route-level content there held up well: an audit of
every route those files name found **zero** that the live swagger lacks. What did not hold up:
| Was | Now |
| --- | --- |
| `conventions/api-conventions.md`: body casing is "typically `snake_case` … derive from swagger" | **camelCase**, proven mechanically. [`../api-contract.md`](../api-contract.md#casing) |
| `conventions/api-conventions.md`: server default `https://localhost:5002` | `http://localhost:5002` — plain HTTP (contradiction **C-3**) |
| The envelope has 5 fields | It has **6**`code` was added for machine-readable errors (REQ-003) |
| Enum vocabularies spread across 17 files and the REQ ledger | One vocabulary block per domain file, cross-checked both ways |
| `messaging.md` (851 B, headerless) silently amending `messaging-notifications-admin.md` | Merged: [tickets.md](tickets.md) + [notifications.md](notifications.md) + [admin.md](admin.md) (contradiction **C-8**) |
| 17 files whose names matched *backend phases* | 22 files whose names match the **client's domains**, which is how the seam is actually consumed |
| The REQ ledger as the change log you had to read to know the current shape | Each domain file states the current shape and lists only its **open** REQs |
@@ -0,0 +1,48 @@
# notifications — the in-app feed and unread badge
> Client seam `client/src/services/notifications/` · `USE_NOTIFICATIONS_MOCK = false` (**real**) · 4 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Server-raised, user-scoped notifications. In-app only — there is no push channel and no email channel.
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/notifications/get_notifications` | wired · paginated |
| GET | `/api/v1/notifications/get_unread_count` | wired — polled for the bell badge |
| POST | `/api/v1/notifications/mark_notification_read` | wired |
| POST | `/api/v1/notifications/mark_all_read` | wired |
All `[Authorize]`. No phantoms. The domain maps 1:1.
## Shape rules the JSON does not express
- **`dataJson` is a string containing JSON**, not an object. It is the deep-link payload: the client parses
it (`parse.ts`) and resolves a route from it (`deepLink.ts`), both with unit tests, and **falls back to
an inert notification rather than throwing** on anything unrecognised. A notification whose payload
cannot be parsed still renders — it just is not tappable.
- **Unread count is a separate read, not derived from the list.** The badge must be correct without
fetching a page, so `get_unread_count` is its own cheap query.
- **`mark_all_read` is a bulk write**; the client invalidates both the list and the count keys, and does not
patch items locally.
- The feed is **day-grouped in the UI** with Shamsi headers — a client-side transform over UTC
`createdAt`. The server sends no grouping.
- Notifications are raised through a self-committing facade (`DispatchAsync`) that runs **after** the
originating handler's `CommitAsync` — so a notification never exists for a transaction that rolled back.
## Enums
`type` is a **bare string** on the wire and the vocabulary is open-ended by design — new server-side
notification types must not break an older client. The client models the *payload* as a discriminated
union (`NotificationData`, keyed on a `kind` inside `dataJson`) and treats an unknown `type` as
non-actionable rather than an error.
Consequence for the server: **adding a notification type is safe; changing an existing type's `dataJson`
shape is not.** The deep-link parser keys off the payload, not the type string.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-054 | deferred, non-blocking | No web push for new booking requests. The nurse dashboard's 15 s poll remains the only signal. Nothing was built for it |
+53
View File
@@ -0,0 +1,53 @@
# nurse — nurse bank accounts
> Client seam `client/src/services/nurse/` · `USE_NURSE_BANK_MOCK = false` (**real**) · 4 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The nurse's payout destination. Narrow domain, high stakes: a payout cannot be paid to an unverified IBAN.
> The domain is named `nurse`, not `nurse-bank-accounts`, because that is the client folder name. The
> nurse's *profile* lives in [profiles.md](profiles.md); coverage in
> [service-areas.md](service-areas.md); verification in [verification.md](verification.md).
## Endpoints
| Method | Path | Rate limit | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/nurse_bank_accounts/list` | — | wired |
| POST | `/api/v1/nurse_bank_accounts/add` | `sensitive` 20/min | wired |
| POST | `/api/v1/nurse_bank_accounts/set_primary/{id}` | — | wired |
| POST | `/api/v1/nurse_bank_accounts/verify_ownership/{id}` | `sensitive` 20/min | wired |
All `[Authorize]`. No phantoms. The domain maps 1:1.
## Shape rules the JSON does not express
- **`iban_hash` is UNIQUE across the platform.** The same IBAN cannot be registered by two nurses; the
second `add` returns a `409`. The uniqueness is enforced on a deterministic hash, not the encrypted
column.
- **The IBAN is encrypted at rest and returned masked**`maskedIban` on every read model, including the
admin-side `PayoutDto` and the nurse's own `NursePayoutHistoryDto`. **The full IBAN is never returned
after the write that created it.** Write-then-masked is the pattern.
- **`verify_ownership` is استعلام شبا** — a Shahkar-class inquiry that confirms the account holder's
national id matches the nurse's. It is a seam: `Seams:BankOwnership:Provider` = `mock` (default) or
`finnotech`. The mock returns a match for every IBAN **except** `Seams:BankOwnership:MismatchIban`
(`IR000000000000000000000000`), which exists so the payout-gating path is testable.
- **Ownership verification gates payouts, and the gate lives in the payout engine, not here.**
`EligibleNurseEarningsDto.hasVerifiedPrimaryIban` is the flag the admin console reads before generating
a batch — see [payouts.md](payouts.md). A nurse with earnings and no verified primary IBAN accrues a
balance and is simply not paid.
- Client-side IBAN handling (`iban.ts`) does checksum validation and formatting only. It is a UX
affordance; the server re-validates.
## Enums
| Vocabulary | Values |
| --- | --- |
| `BankAccountStatus` | `pending` `verified` `mismatch` |
`mismatch` is a terminal, actionable state — the holder's national id did not match — and is distinct from
`pending`, which only means the inquiry has not run.
## Open REQs
None.
@@ -0,0 +1,99 @@
# partner-center — nursing companies
> Client seam `client/src/services/partnerCenter/` · `USE_PARTNER_MOCK = true` (**mock is primary**) · 9 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Nursing companies («مراکز») that sponsor nurses onto the platform. The domain with the **widest gap between
what the client wants and what the server serves** — 6 of its client calls are phantom.
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/admin/partner-centers` | wired · paginated |
| POST | `/api/v1/admin/partner-centers` | wired |
| GET | `/api/v1/admin/partner-centers/{id}` | wired |
| PATCH | `/api/v1/admin/partner-centers/{id}` | wired |
| POST | `/api/v1/admin/partner-centers/{id}/set-active` | wired — REQ-032's delivered half |
| POST | `/api/v1/admin/partner-centers/{id}/sponsor-nurse` | wired |
| POST | `/api/v1/admin/partner-centers/{id}/verify` | wired |
| GET | `/api/v1/centers/{id}/dashboard` | **unwired** — the client wants `centers/me/*` splits instead |
| GET | `/api/v1/internal/bookings/{bookingId}/center` | **unwired** — the MoR resolver, server-internal |
The seven `admin/partner-centers` routes are `DynamicPermission` + `sensitive`; `centers` is `[Authorize]`;
`internal/bookings` is `DynamicPermission`.
This domain owns **three of the four route-shape exceptions** in the API: hyphens (`partner-centers`,
`set-active`, `sponsor-nurse`), a nested `admin/` segment, and an `internal/` namespace. It also has one of
only two `PATCH` verbs. See [index.md](index.md#route-shape-exceptions).
### Phantom — 6
| Client call | REQ | Note |
| --- | --- | --- |
| `GET /api/v1/centers/me` | REQ-032 | The portal's own-center read |
| `GET /api/v1/centers/me/nurses` | REQ-032 | Split read — the server serves one aggregate instead |
| `GET /api/v1/centers/me/bookings` | REQ-032 | Split read |
| `GET /api/v1/centers/me/bookings/{id}` | REQ-064 | Partner-scoped booking detail |
| `GET /api/v1/centers/me/settlement` | REQ-033 | Per-booking commission invoices |
| `GET /api/v1/admin/partner-centers/{id}/nurses` | REQ-032 | The admin-side sponsored-nurse list |
**The shape mismatch is the point.** The server serves **one aggregate**, `GET centers/{id}/dashboard`
`CenterDashboardDto` with `sponsoredNurses` inline. The portal wants **`/me` plus paginated splits** — it
cannot page an inline array, and it does not know its own center id without REQ-038. Until REQ-032 lands,
the portal is mock-only.
## Merchant of record
The one business rule that changes where money goes:
- **`isMerchantOfRecord = true`** → the *center* is the seller. It invoices the customer, holds the
commercial relationship, and Balinyaar's cut is a commission **against the center**.
- **`isMerchantOfRecord = false`** → the nurse is the seller and the center is a sponsor only.
`GET internal/bookings/{bookingId}/center` is the **MoR resolver** the invoice pipeline calls to decide
which entity issues the invoice — which is why `InvoiceDto.issuingEntityType` exists. See
[payment.md](payment.md). `commissionRate` on the center is a **per-center override** of the platform
default, snapshotted at compute time like every other rate.
## Shape rules the JSON does not express
- **The settlement IBAN is write-then-masked.** `settlementIbanMasked` is the only form on every read model;
the full value never comes back after the write that set it. Same pattern as
[nurse.md](nurse.md).
- **`verify` is a licence check behind a seam.** `ILicenseVerificationService` checks the eNamad code and the
MoH establishment permit; by default it returns `NeedsManualReview`, so `verify` records a **human admin
decision**. `Seams:LicenseVerification:AutoApprove` makes the mock return `Valid` to test the
auto-approve path. A real eNamad/MoH registry adapter ignores the knob.
- **`technicalDirectorNurseUserId` links to a real verified nurse**, not a free-text name — Iranian
regulation requires a named technical director («مدیر فنی») with a valid licence.
- **`set-active` is suspend/activate, not delete.** A suspended center's sponsored nurses and past bookings
stay resolvable.
- `sponsoredNurseCount` is denormalised onto both the list item and the detail so the queue needs no
per-row count.
- `legalEntityType` and `mohEstablishmentPermitNo` are the regulatory identity; `enamadCode` is the
e-commerce trust seal. All three are distinct and none substitutes for another.
## Enums
| Vocabulary | Values |
| --- | --- |
| `CenterOnboardingState` | `draft` `pending_verification` `verified` `suspended` |
| `MoadianStatus` *(on the center's invoices)* | `pending` `submitted` `registered` `failed` |
`MoadianStatus` is shared with [payment.md](payment.md) — it is the سامانه مودیان submission state, and it
is the same vocabulary on both sides.
> `CenterOnboardingState` is the client's model of the center's position in onboarding. On the wire the
> server carries the **facts** it is derived from — `isActive` and `verifiedAt` on both
> `PartnerCenterListItemDto` and `PartnerCenterDetailDto` — not the state string itself. Derive, do not
> expect a field.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-032 | partially delivered | `set-active` landed. The `/me` split reads and the IBAN write-then-masked flow are deferred → 5 phantom routes. **The main reason this seam is mocked** |
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the per-booking commission invoice list is deferred → 1 phantom |
| REQ-064 | open | No partner-scoped booking detail → 1 phantom |
| REQ-038 | open | `/me` carries no signal that the caller administers a center, so the portal cannot auto-route or discover its own center id. See [auth.md](auth.md) |
@@ -0,0 +1,83 @@
# patient-records — the care plan and visit records
> Client seam `client/src/services/patientRecords/` · `USE_PATIENT_RECORDS_MOCK = true` (**mock is primary**) · 5 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The clinical content: a family-owned **care plan** (medications, routine, tasks) and the **append-only**
visit records nurses write against it. The patient rows themselves are [patients.md](patients.md).
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/patients/{patientId}/care_record` | wired — the care plan |
| PUT | `/api/v1/patients/{patientId}/care_record` | wired — **the only `PUT` in the API** |
| GET | `/api/v1/patients/{patientId}/care_records` | wired · paginated (**`page`/`pageSize`**) — visit history |
| POST | `/api/v1/patients/{patientId}/care_records` | wired — a nurse writes one visit record |
| GET | `/api/v1/patients/{patientId}/record_access` | wired — **ask before you read** |
All `[Authorize]`. **No phantoms — every route the client calls exists.** The seam is mocked for UI
completeness, not because the contract is missing.
> Singular vs. plural is load-bearing here: `care_record` (no `s`) is the **plan**; `care_records` is the
> **visit history**. They are different resources on adjacent paths.
## Two different ownership models on one path
| | `care_record` (plan) | `care_records` (visits) |
| --- | --- | --- |
| Owner | the **family** (the customer) | the **nurse** who performed the visit |
| Write | `PUT` — upsert, replaces | `POST`**append only** |
| Edit after the fact | yes, it is a living plan | **no** |
| Delete | no | **no** |
**A nurse can never edit or delete a visit record.** It is a clinical record: append-only is the whole
point, and there is no endpoint that would allow otherwise. Do not add one.
## `record_access` — check before you read
`GET record_access` answers "may this caller read this patient's records, and if not, why". The client
calls it **first** and renders the denial state rather than firing a read and interpreting an error.
Two reasons come back, and they are deliberately hard to tell apart from outside:
| `RecordAccessDeniedReason` | Means |
| --- | --- |
| `no_access` | The patient exists; you are not authorised |
| `not_found` | No such patient — **or** a tenancy mismatch |
That second row is the platform's tenancy rule: a row you do not own is a **404, never a 403**, because a
403 confirms it exists. See [../api-contract.md](../api-contract.md#status-codes).
## Shape rules the JSON does not express
- **Every record body is encrypted at rest** and decrypted only for an authorised caller. Care content is
**never** projected into a list, never logged, and never included in a search index.
- **Nurse read access is scoped by an active booking**, not by having ever cared for the patient. The
two-stage clinical disclosure rule applies: full care content is readable only post-confirmation, only by
the assigned nurse and admin. See [bookings.md](bookings.md).
- **`CarePlanDto` is `{ patientId, medications, routine, tasks }`** — three structured lists, not free text,
so the client can render a schedule and a checklist rather than a blob.
- **`CareRecordDto.taskResults` is structured** (REQ-027, delivered): each visit reports per-task outcomes
against the plan's tasks, which is what lets the family see whether the routine was actually followed.
- **`nurseName` is on the visit record** so the history is attributable without a per-row lookup.
- Dose units, frequency presets and times-of-day are **codes**; the UI labels are i18n keys. Never render
the code, and never parse a frequency into a schedule client-side.
## Enums
| Vocabulary | Values |
| --- | --- |
| `DoseUnit` | `tablet` `capsule` `drop` `cc` `unit` |
| `FrequencyPreset` | `once_daily` `twice_daily` `three_times_daily` `every_8_hours` `as_needed` |
| `TimeOfDayCode` | `morning` `noon` `evening` `night` |
| `RecordAccessDeniedReason` | `no_access` `not_found` |
| `CareRecordTab` *(client UI only)* | `medications` `routine` `history` `tasks` |
`as_needed` (PRN) has **no** time-of-day and must not be rendered on a schedule grid.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-027 | delivered | The family-owned care record (medications/routine/tasks), `record_access`, and structured task results are all served |
@@ -0,0 +1,52 @@
# patients — the care circle
> Client seam `client/src/services/patients/` · `USE_PATIENTS_MOCK = false` (**real**) · 5 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The people a customer books care *for* — «حلقهٔ مراقبت» in the UI. A patient is owned by the customer who
created them, never by a nurse. Clinical content about a patient lives in
[patient-records.md](patient-records.md).
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/patients/list` | wired · paginated (`Page`/`PageSize`) |
| GET | `/api/v1/patients/get/{id}` | wired |
| POST | `/api/v1/patients/create` | wired |
| POST | `/api/v1/patients/update/{id}` | wired |
| POST | `/api/v1/patients/archive/{id}` | wired — **archive, not delete** |
All `[Authorize]`. No phantoms. The domain maps 1:1.
## Shape rules the JSON does not express
- **`relation` and `conditions` are on `PatientDto`** (REQ-005, delivered). `relation` is the family
relationship shown on the record sheet; `conditions` is the coarse condition list used for triage.
- **`gender` is load-bearing.** It drives same-gender caregiver matching, which is a near-hard requirement
in this market. Never defaulted, never dropped, never inferred from a name.
- **`initialMedicalNotes` is encrypted at rest** and readable only by the owning customer (and, post-
confirmation, the assigned nurse via the booking's care-instructions read — see
[bookings.md](bookings.md)). It is not the care record.
- **Archive, never delete.** A patient referenced by a booking must stay resolvable; `isActive = false`
removes them from pickers. There is no delete endpoint and there should not be.
- **`displayName` is server-composed** from first/last name. The client renders `displayName` and uses the
parts only in the edit form — so a naming-convention change is a server change, not a client one.
- `birthDate` is a date; the client derives the age band (`age.ts`) for display. The **server** stamps
`patientAge` on the nurse-facing booking-request list row (see
[booking-requests.md](booking-requests.md)) — the nurse never receives a birth date.
## Enums
| Vocabulary | Values |
| --- | --- |
| `Gender` | `male` `female` |
`bloodType` is a free string, not an enum.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-057 | open | `PatientDto` has no `lastVisitAt` (or `visitCount`), and `BookingListItemDto` has no `patientId`, so the booking card can render no care teaser. Either field unblocks it |
| REQ-058 | deferred, non-blocking | No patient photo upload. `PatientDto` has no `avatarUrl` and no UI reads one — `InitialsAvatar` ships either way |
@@ -0,0 +1,95 @@
# payment — card checkout, the PSP webhook, invoices
> Client seam `client/src/services/payment/` · `USE_PAYMENT_MOCK = false` (**real**) · 3 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The card money path. Three endpoints on the wire; the domain reads three more that belong to neighbours.
Installment checkout is [bnpl.md](bnpl.md); reversals are [refunds.md](refunds.md).
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| POST | `/api/v1/bookings/{bookingRequestId}/payments` | `[Authorize]` · `sensitive` 20/min | wired · **`Idempotency-Key`** |
| GET | `/api/v1/invoices/{bookingId}` | `[Authorize]` | wired |
| POST | `/api/v1/webhooks/payments/{provider}` | **anonymous** · `webhook` 120/min | server-only — the PSP calls it |
Also read by this domain, documented with their owners:
`GET booking_requests/checkout_summary/{id}` and `GET booking_requests/get/{id}`
([booking-requests.md](booking-requests.md)).
### Phantom — 1
| Client call | REQ | Live? |
| --- | --- | --- |
| `GET /api/v1/bookings/payment_history` | REQ-047 | **Yes — this domain's mock is off.** The wallet «پرداخت‌ها» tab calls it, gets a 404, and renders its empty state. Guarded, but a real 404 on every visit |
## The flow, and why it is shaped this way
```
accepted request ──initiate──▸ PSP hosted page ──customer pays──▸ PSP webhook
(money-free) (redirectUrl) │
server re-verifies, then creates + confirms the booking
```
Four rules that follow, and they are the whole design:
1. **Payment is initiated against the accepted *request*, not a booking.** The `bookings` row does not
exist yet. `POST bookings/{bookingRequestId}/payments` takes a **request** id despite the `bookings/`
prefix — the route is misleading and the parameter name is the truth.
2. **There is no client verify endpoint.** The server re-verifies with the acquirer *inside* the webhook
handler. A client-reported "success" is never trusted.
3. **The client learns the outcome by polling.** `getPaymentOutcome` maps the request status
(`converted` → succeeded) with backoff. A first-class transaction-status read is REQ-017's remaining
half.
4. **One `Idempotency-Key` per attempt**, reused across retries of that attempt; a new attempt takes a new
key. A `409` on initiate means "already in progress / already captured" — a benign convergence, and the
client must not surface it as an error.
**Webhook idempotency does not use the header.** The handler upserts the provider event first, keyed on
`external_event_id`, and no-ops on a duplicate; `bookings.booking_request_id` is `UNIQUE` so a replay
cannot create a second booking; a unique-violation on confirm is treated as idempotent success. The DB
constraint is the backstop, not the handler's `if`.
`POST bookings/convert` ([bookings.md](bookings.md)) is the **Development-only** capture simulator that
stands in for the webhook locally. It is fail-closed outside Development/Testing.
## Shape rules the JSON does not express
- **`CheckoutSummaryDto` serves the money breakdown so the client never derives it** (REQ-016, delivered):
`serviceCostIrr`, `commissionIrr`, `vatIrr`, `vatRate`, `totalIrr` **and** the three-amount split
`grossPriceIrr` / `balinyaarCommissionIrr` / `nursePayoutAmount`. All digit strings.
- **VAT is on Balinyaar's commission only** — the platform's taxable supply — never on the nurse payout.
- **`InitiatePaymentResult` is `{ transactionId, redirectUrl, gatewayReferenceCode }`.** The client hands
off to `redirectUrl` and keeps `transactionId` to poll.
- **`InvoiceDto` carries `totalIrr`** (REQ-033, partial) = platform commission + BNPL commission + VAT, and
`moadianStatus`/`moadianReferenceNumber` for the سامانه مودیان e-invoicing submission.
`issuingEntityType` distinguishes a platform-issued invoice from a partner-center one — see
[partner-center.md](partner-center.md).
- **The acquirer is a seam.** `Seams:Payments:Provider` = `mock` (default) / `zarinpal` / `sadad` /
`vandar` / `jibit`; `IPaymentProvider`, `ISettlementSplitProvider` and `IWebhookVerifier` swap together.
Webhook signature secrets are per-provider (`Seams:Payments:WebhookSigningSecrets`), read from the
`X-Signature` header by default. A provider with no signature falls back to the mandatory server-side
re-verify.
## Enums
| Vocabulary | Values |
| --- | --- |
| `PaymentTransactionStatus` | `pending` `succeeded` `failed` |
| `MoadianStatus` | `pending` `submitted` `registered` `failed` |
| `GatewayReturnOutcome` *(client-side, from the return URL)* | `success` `failure` |
Verified against `Entities/Payments/PaymentTransactionStatus.cs` and `Entities/Invoices/MoadianStatus.cs`.
`GatewayReturnOutcome` is a client reading of the acquirer's redirect and is **advisory only** — the
authoritative outcome is the polled request status.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-017 | delivered (partial in practice) | `bookingId` is on the converted request; the first-class transaction-status read is not, so the client polls the request status instead |
| REQ-046 | open | No nurse identity on the checkout summary and no client-readable payment reference. The real receipt hides the identity avatar/badge and the tracking line |
| REQ-047 | open | No customer payment-transactions list. **Live 404** on `bookings/payment_history`; the wallet tab renders empty |
| REQ-049 | open | `InvoiceDto` has no payment method, transaction reference, or seller fiscal identity. The invoice renders the money breakdown and مودیان status unconditionally |
+102
View File
@@ -0,0 +1,102 @@
# payouts — weekly nurse settlement
> Client seam `client/src/services/payouts/` · `USE_PAYOUTS_MOCK = true` (**mock is primary**) · 13 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Money going out, weekly, in batches. The nurse's view is read-only; the admin's view is the one place in
the platform where an irreversible transfer is triggered by hand.
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/nurse_payouts/earnings` | `[Authorize]` | wired · paginated — per-booking earnings |
| GET | `/api/v1/nurse_payouts/earnings_balance` | `[Authorize]` | wired — the four-bucket balance |
| GET | `/api/v1/nurse_payouts/history` | `[Authorize]` | wired · paginated |
| GET | `/api/v1/nurse_payouts/{id}` | `[Authorize]` | wired — payout detail |
| GET | `/api/v1/nurses/{nurseId}/payable_balance` | `[Authorize]` | **unwired** — the nurse reads `earnings_balance` instead |
| GET | `/api/v1/admin_payouts/eligible` | admin · `sensitive` | wired · paginated |
| GET | `/api/v1/admin_payouts/batches` | admin · `sensitive` | wired · paginated |
| POST | `/api/v1/admin_payouts/batches` | admin · `sensitive` | **unwired** — generation is automatic (below) |
| GET | `/api/v1/admin_payouts/batches/{id}` | admin · `sensitive` | wired · paginated (**`page`/`pageSize`**) |
| POST | `/api/v1/admin_payouts/batches/{id}/process` | admin · `sensitive` | **unwired** — the irreversible step; no console button yet |
| POST | `/api/v1/admin_payouts/{payoutId}/retry` | admin · `sensitive` | wired |
| POST | `/api/v1/admin_payouts/{payoutId}/mark_failed` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/webhooks/payouts/{provider}` | **anonymous** · `webhook` | server-only — the transferor's reconciliation callback |
This domain absorbed the **entire** wire-level drift between the 2026-07-13 contract freeze and the
2026-07-29 snapshot — one added endpoint and one changed schema, both here:
- **Added (C-6):** `POST /api/v1/webhooks/payouts/{provider}` — the transferor's reconciliation callback.
- **Changed (C-7):** `GeneratePayoutBatchCommand` gained **`systemInitiated: boolean`**, alongside the
existing `periodStart`/`periodEnd` dates. That is the refinement-phase-7 scheduler flag: it distinguishes
a batch the recurring job generated from one an admin generated, which is what keeps the
"generation is automatic, processing is not" rule auditable.
### Phantom — 1
| Client call | REQ | Note |
| --- | --- | --- |
| `POST /api/v1/admin_payouts/{id}/transfer_reference` | REQ-036 | Deferred. The reference is *readable* on `PayoutDto.transferReference`; what is missing is a route to record one manually |
## Generation is automatic; processing is not
This is a hard platform rule, not a convention:
- A **scheduled job may generate** a `draft` batch. `IRecurringJob` + `RecurringJobSchedulerHostedService`
do this weekly, in-process (refinement-phase-7).
- The **irreversible `process` step is always an explicit admin action.** No job, no schedule, no retry
loop may trigger a real transfer.
Which is why `POST batches` is unwired (the job does it) and `POST batches/{id}/process` is unwired (the
console has no button yet — that is the gap, and it is deliberate that nothing automated fills it).
## The money rules
- **`UNIQUE` on booking id: one payout per booking, ever.** The DB constraint is the authority; the handler
does not rely on an `if`.
- **The net balance is SIGNED and must never be clamped to zero.** A nurse with a clawback larger than
their eligible earnings has a **negative** `netAmountIrr`. Rendering it as 0 tells them they have nothing
owing when in fact they owe. The client displays the signed value.
- **Clawback netting is whole-clawback greedy**, never partial: a clawback either fits in this batch or
waits for the next. See [refunds.md](refunds.md).
- **Payout dates are resolved against the bank-holiday calendar server-side.** The client never computes
one — see [admin.md](admin.md). `nurse_payout_interval_days` and `payout_satna_threshold_irr` are config;
the threshold selects PAYA vs SATNA.
- **A verified primary IBAN gates payment, not accrual.** `EligibleNurseEarningsDto.hasVerifiedPrimaryIban`
is what the console checks; a nurse without one accrues a balance and is not paid. See
[nurse.md](nurse.md).
- The IBAN is **always masked** on every read model, nurse-side and admin-side alike.
## A resolved drift
`payouts/apis/clientApi.ts` states that `NursePayoutHistoryDto` "carries **no** `failureReason` — that field
lives on the admin-only `PayoutDto` … until REQ-025 adds it". **The live wire has it**: `failureReason` is
on `NursePayoutHistoryDto` *and* `NursePayoutDetailDto` *and* `PayoutDto`. The comment predates the
delivery; a `failed` payout can show its reason to the nurse today.
The client also sends `Idempotency-Key` on process/retry. **The server does not read it there** — only
`PaymentsController` and `CheckoutBnplController` do. Harmless (those writes are idempotent by
constraint), but the header is decorative on this domain. See
[../api-contract.md](../api-contract.md#idempotency).
## Enums
| Vocabulary | Values |
| --- | --- |
| `PayoutStatus` | `pending` `submitted` `paid` `failed` |
| `PayoutBatchStatus` | `draft` `processing` `partially_failed` `completed` `failed` |
| `EarningsState` | `pending` `eligible` `paid` `clawback_applied` |
`PayoutStatus` and `PayoutBatchStatus` are verified identical to `Entities/Payouts/*.cs` and are
forward-only through `PayoutStatusTransitions`. `partially_failed` is a real batch outcome — some
destinations settled, some did not — and drives the single-payout `retry`; `Seams:BankTransfer:FailIban`
exists to make it testable.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-025 | delivered | The four-bucket balance, per-booking earnings list and payout detail are all served (including `failureReason`, above) |
| REQ-036 | deferred | No single-payout preview, no `holidayShifted` flag, no record-transfer-reference route → 1 phantom |
| REQ-053 | open | No payout forecast (next batch date + expected eligible amount). The nurse dashboard's forecast line renders nothing on the real path |
@@ -0,0 +1,57 @@
# profiles — customer and nurse profiles
> Client seam `client/src/services/profiles/` · `USE_PROFILES_MOCK = false` (**real**) · 7 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
One client domain over two server controllers, because the client's profile screens are one feature with
two actor variants. Identity and roles come from [auth.md](auth.md)'s `/me`, not from here.
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/customer_profiles/me` | wired |
| POST | `/api/v1/customer_profiles/upsert` | wired |
| POST | `/api/v1/customer_profiles/avatar` | **unwired** — the client's avatar upload calls the nurse route only |
| GET | `/api/v1/nurse_profiles/me` | wired |
| POST | `/api/v1/nurse_profiles/upsert` | wired |
| POST | `/api/v1/nurse_profiles/avatar` | wired |
| POST | `/api/v1/nurse_profiles/set_accepting_bookings` | wired |
All `[Authorize]`. No phantoms.
> **`customer_profiles/avatar` exists and is unused.** The nurse avatar upload is wired; the customer one
> is not, so a customer cannot set a photo even though the endpoint is live. A one-line client gap, not a
> contract gap.
## Shape rules the JSON does not express
- **Avatar upload is `multipart/form-data`.** This is the one place the client must **not** set
`Content-Type``clientFetch` detects a `FormData` body and lets the browser write the multipart
boundary. A manual JSON content-type there breaks the upload. See
[../api-contract.md](../api-contract.md).
- **`avatarUrl` is served by the object-storage seam.** `Seams:ObjectStorage:Provider` = `local` (default,
writes under `RootPath`) or `s3`. In the deployment `RootPath` is a **named docker volume** — without it,
uploads vanish on the next `up --build`. See [../topology.md](../topology.md).
- **`isAcceptingBookings` is a real toggle with real consequences.** It is one of the four conditions the
search index's `is_searchable` requires — flipping it off removes the nurse from discovery. See
[search.md](search.md).
- **`isVerified` on `NurseProfileDto` is derived, never writable.** It is written **only** by the
verification finalize transaction when the aggregate reaches `approved`. Never set it from a profile
write. See [verification.md](verification.md).
- **`averageRating` / `totalReviews` / `totalCompletedBookings` are recomputed from source**, not
incremented. A moderation change that hides a review recomputes the aggregate. See
[reviews.md](reviews.md).
- **`specializationsJson` is a string containing JSON**, like the other `*Json` fields on this wire.
- `preferredLanguage` on `CustomerProfileDto` (REQ-007, delivered) alongside the name update.
- `defaultEmergencyContactName`/`Phone` are the customer-level fallback used when a booking supplies none.
## Enums
None of its own. `educationLevel` and `educationField` are free strings. Gender lives on the user
(see [auth.md](auth.md)), not on the profile.
## Open REQs
None. REQ-006 (avatar/object-storage upload route) and REQ-007 (name + preferred language) were both
delivered in refinement-phase-3.
@@ -0,0 +1,88 @@
# refunds — cancellation, reversal, clawbacks, invoices
> Client seam `client/src/services/refunds/` · `USE_REFUNDS_MOCK = true` (**mock is primary**) · 8 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Money going back. The customer half is thin and real; the admin half is largely deferred, which is why the
seam is mocked.
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/refunds/by_booking/{bookingId}` | `[Authorize]` | wired — the customer's refund for a booking |
| GET | `/api/v1/refunds/{id}/status` | `[Authorize]` | wired |
| GET | `/api/v1/admin_refunds` | admin · `sensitive` | wired · paginated |
| POST | `/api/v1/admin_refunds` | admin · `sensitive` | **unwired** — creates **and executes** in one call; the client wants preview → approve (REQ-035) |
| POST | `/api/v1/admin_refunds/{id}/confirm_settlement` | admin · `sensitive` | **unwired** — the manual-channel settlement confirm |
| POST | `/api/v1/admin_refunds/{id}/mark_failed` | admin · `sensitive` | **unwired** |
| POST | `/api/v1/admin_clawbacks/{id}/write_off` | admin · `sensitive` | **unwired** — no console screen |
| POST | `/api/v1/admin_invoices` | admin · `sensitive` | **unwired** — owner-issue an invoice (REQ-018's fallback path) |
Also wired by this domain, documented with their owner ([bookings.md](bookings.md)):
`POST bookings/{id}/cancel` (cancel **and** refund, REQ-019) and
`GET bookings/{id}/cancellation_policy` (the pre-cancel preview, REQ-020).
### Phantom — 4
| Client call | REQ | Note |
| --- | --- | --- |
| `POST /api/v1/admin_refunds/preview` | REQ-035 | Deferred. Today's single `POST admin_refunds` creates+executes with no preview step |
| `POST /api/v1/admin_refunds/{id}/approve` | REQ-035 | Deferred |
| `POST /api/v1/admin_refunds/{id}/reject` | REQ-035 | Deferred |
| `GET /api/v1/refunds/my` | REQ-048 | The customer's "all my refunds" list. The wallet «استردادها» tab renders empty on the real path |
## The money rules
Four, and none of them are the client's to compute:
1. **A refund is a reversal leg, not a deletion.** `ledger_entries` is append-only; every posting group
balances. Nothing is ever edited or removed.
2. **Fee-leg decomposition is served, not derived.** `RefundStatusDto` carries
`platformFeeRefundedIrr` and `nursePayoutRefundedIrr` separately (REQ-021, delivered) — a partial refund
does not necessarily refund the commission and the payout in the same proportion. Never split a total.
3. **Pre-payout and post-payout fork.** If the nurse has not been paid, the payout leg is simply reduced.
If they have, the platform raises a **clawback**, which the payout engine nets against the nurse's next
batch — whole-clawback greedy netting, never a partial. See [payouts.md](payouts.md).
4. **VAT is on commission only**, so a refund's VAT leg follows the commission leg, never the payout.
## Shape rules the JSON does not express
- **`refundChannel` decides the mechanics and is not a display detail**: `psp_card` reverses through the
acquirer, `bnpl_revert` calls the BNPL provider's revert (see [bnpl.md](bnpl.md)), `manual` is a bank
transfer an admin confirms with `confirm_settlement`. Each has a different ETA, and
`expectedCustomerRefundEta` is server-computed per channel — the client displays it verbatim.
- **`CancellationPolicyPreviewDto` is the complete pre-cancel answer** (REQ-020, delivered):
`cancellable`, `cancellationPolicyCode`, `refundPercentageApplied`, `feePercentage`, `refundAmountIrr`,
`feeAmountIrr`, `refundableAmountIrr`, the two fee legs, `appliesTo`, `leadTimeLabel`, `refundChannel`,
`expectedCustomerRefundEta`, and a **per-session** breakdown. The client shows it and asks for
confirmation; it computes none of it.
- **Cancellation tiers are config rows**, not code — `cancellation_tier1/2/3_refund_rate` in
[admin.md](admin.md) — and the applied rate is **snapshotted onto the refund** at compute time, so a
later tier change is not retroactive.
- **`refundPercentage` on `CancellationPolicyDto` is a rate, `refundPercentageApplied` on the refund is the
snapshot.** They can legitimately differ; that is the point.
- The crash-window fix in refinement-phase-6 wired the previously unreachable BNPL and manual settlement
paths — a refund created against those channels now actually clears.
## Enums
| Vocabulary | Values |
| --- | --- |
| `RefundStatus` | `requested` `approved` `processing` `succeeded` `failed` `rejected` |
| `RefundChannel` | `psp_card` `bnpl_revert` `manual` |
| `ClawbackStatus` | `pending` `recovered` `written_off` |
| `CancellationPolicyCode` | `free_24h` `partial_under_24h` `customer_no_show` |
| `CancellationLeadTime` | `gt_24h` `lt_24h` `started` |
| `CancellationScope` | `whole_booking` `remaining_sessions` |
| `CancelReasonCategory` *(client)* | `changed_mind` `schedule_conflict` `found_other_care` `other` |
The first three are verified identical to `Entities/Refunds/RefundStatus.cs` and `ClawbackStatus.cs`.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-035 | deferred | No admin preview / approve / reject → 3 phantom routes. The one live route creates and executes together, which the console will not call |
| REQ-048 | open | No customer "all my refunds" list → 1 phantom. The wallet tab renders empty on the real path |
| REQ-033 | partially delivered | `totalIrr` is on `InvoiceDto`; the partner per-booking commission invoice list is deferred. See [partner-center.md](partner-center.md) |
@@ -0,0 +1,85 @@
# reviews — ratings, tags, moderation
> Client seam `client/src/services/reviews/` · `USE_REVIEWS_MOCK = false` (**real**) · 8 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Customer reviews of a completed booking, pre-screened and then human-moderated before they are public.
Clinical care records are a different domain — [patient-records.md](patient-records.md).
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/bookings/{bookingId}/review_eligibility` | `[Authorize]` | wired |
| GET | `/api/v1/bookings/{bookingId}/my_review` | `[Authorize]` | wired |
| POST | `/api/v1/bookings/{bookingId}/review` | `[Authorize]` | wired |
| GET | `/api/v1/nurses/{nurseProfileId}/reviews` | **anonymous** | wired · paginated (**`page`/`pageSize`**) |
| GET | `/api/v1/nurses/{nurseProfileId}/review_tags` | **anonymous** | **unwired** — the client reads tags off the review rows |
| PATCH | `/api/v1/reviews/{reviewId}/status` | `[Authorize]` | wired — the moderation decision |
| POST | `/api/v1/reviews/{reviewId}/tags` | `[Authorize]` | **unwired** — no console screen edits tags |
| GET | `/api/v1/admin/reviews/moderation_queue` | admin | wired · paginated |
No phantoms. Note `PATCH` — one of only two `PATCH` verbs in the whole API (the other is on
[partner-center.md](partner-center.md)); everything else mutates with `POST`. Note also the hardcoded
nested `admin/reviews/` route segment — see [index.md](index.md#route-shape-exceptions).
## Aggregates are recomputed, never incremented
`averageRating`, `totalReviews` and `totalCompletedBookings` on the nurse profile are **recomputed from
source** whenever a review's moderation status changes. Hiding a published review must lower the average;
an incrementing counter cannot do that correctly. Never `+= 1` a review aggregate. See
[profiles.md](profiles.md).
## The moderation gate
```
submit ──AI pre-screen──▸ pending_moderation ──admin──▸ published
│ │
banned word hit unpublish ──▸ hidden
rejected
```
- **A review is not public on submit.** `IReviewModerationService` pre-screens; by default clean text
returns a human-review **flag**, keeping the gate on. `Seams:ReviewModeration:AutoApproveClean` makes
clean text auto-publish; `BannedWords` (default `scam`, `fraud`, `کلاهبردار`) forces `reject`. Both are
mock knobs — a real classifier ignores them.
- **A low rating raises a support alert** (`low_rating`), linked as `lowRatingAlertId` on the queue item.
See [admin.md](admin.md).
- **`unpublish` is a distinct action from `hide`** in the client's `ModerationAction` union even though both
land on `hidden` — the audit trail records which was chosen.
## Shape rules the JSON does not express
- **`ReviewEligibilityDto` is `{ canReview, reason }`** and the reason is a **code**, not a message:
`not_completed` `already_reviewed` `not_owner` `not_found`. The client maps it to an i18n key. Eligibility
is server-decided — the client must not infer it from booking status.
- **The author is masked** (REQ-026, confirmed): a public review carries `authorMasked`, never a full name.
This is a privacy decision, not a display choice.
- **`tagCodes` is on `ModerationQueueItemDto`** (REQ-037, delivered) so the queue shows what the reviewer
tagged without a second fetch.
- **Review tags are codes; labels are i18n keys.** Never render a tag code, and never build a display
string from one.
- `moderationReason` is admin-facing free text and is **not** returned on the public review read.
- The public reviews list is **anonymous and paginated with `page`/`pageSize`** (camelCase — unlike
`search/nurses`, which uses `page_size`). See [../api-contract.md](../api-contract.md#pagination).
## Enums
| Vocabulary | Values |
| --- | --- |
| `ModerationStatus` | `pending_moderation` `published` `hidden` `rejected` |
| `ModerationAction` | `publish` `hide` `reject` `unpublish` |
| `ReviewIneligibilityReason` | `not_completed` `already_reviewed` `not_owner` `not_found` |
`ModerationStatus` and `ModerationAction` are both defined in
`Entities/Reviews/ReviewModerationStatus.cs` and verified identical to the client's unions — the file
carries the four states and the four actions together.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-026 | delivered | Eligibility + my-review-for-booking reads, and masked-author confirmation |
| REQ-037 | delivered | `tagCodes` on the moderation queue item |
| REQ-040 | open | No `topReviewTag` on the search index row — the C2 card renders without the tag chip. Owned by [search.md](search.md) |
@@ -0,0 +1,81 @@
# search — nurse discovery
> Client seam `client/src/services/search/` · `USE_SEARCH_MOCK = false` (**real**) · 2 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Two endpoints, both anonymous, both reading a **projected index** rather than joining live tables.
## Endpoints
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/search/nurses` | **anonymous** | wired · paginated — **`page` + `page_size`** |
| GET | `/api/v1/nurses/{nurseId}/profile` | **anonymous** | wired |
No phantoms. `POST /api/v1/admin_search/rebuild_index` is the index rebuild one-shot and lives in
[admin.md](admin.md); `nurses/{id}/reviews` and `/review_tags` are in [reviews.md](reviews.md);
`nurses/{id}/trust_badge` is in [verification.md](verification.md).
## The one endpoint with snake_case query params
`GET /api/v1/search/nurses` is the **only** endpoint whose query parameters are snake_case:
```
service_category_id city_id district_id nurse_gender min_price max_price page page_size
```
`service_category_id` and `city_id` are required. This matters because it is also the only endpoint where
paging is declared as **`page_size`**, not `pageSize` — and model binding is case-insensitive, not
separator-insensitive, so sending `pageSize` here binds nothing and silently yields the default page size.
The client's `searchClientApi` already sends `page_size` correctly. See
[../api-contract.md](../api-contract.md#pagination).
## `is_searchable` — the four conditions
A nurse variant appears in results only when **all four** hold. Anything that flips one of them must
maintain the index in the same unit of work (`ISearchIndexMaintainer`):
1. the nurse's verification aggregate is `approved` — [verification.md](verification.md)
2. `isAcceptingBookings` is true — [profiles.md](profiles.md)
3. the variant is active — [catalog.md](catalog.md)
4. the nurse has at least one service area covering the queried city — [service-areas.md](service-areas.md)
**`districtId = null` means whole-city on both sides of the match.** A customer filtering by district *D*
must see a nurse whose area names *D* **and** a nurse whose area is whole-city. A query that drops the
nulls silently hides every whole-city nurse. See [geography.md](geography.md).
## Shape rules the JSON does not express
- **The index row is denormalised** (REQ-012, delivered): `nurseName`, `avatarUrl` and `distanceKm` are on
`NurseSearchResultDto`, so the result card needs no per-row fetch. `price` is a **digit string**.
- **`distanceKm` is nullable** — null when either side has no resolved coordinates. The card omits the
distance chip rather than showing 0.
- **`GET nurses/{id}/profile` aggregates** identity, bio, specialties, the full services list and the
latest review into `NursePublicProfileDto` (REQ-012). One request builds the whole profile screen.
- **`attributeChips` is a server-composed display list**, not a code vocabulary — render it, do not map it.
- **`inoMembership` on the public profile is a boolean summary** of the INO verification step, not the step
itself. The per-step detail is the still-open REQ-043.
- The index is maintained **inline inside each source write's transaction**, not by a background job — so a
profile change is visible in search immediately, and a failed index write fails the source write.
- `Search:Backend` selects the implementation: unset or `sql``SqlNurseSearch`. Any other value **throws
at startup** — Elasticsearch is deferred, and the config fails loudly rather than silently degrading.
## Enums
| Vocabulary | Values |
| --- | --- |
| `NurseGender` *(filter + result field)* | `male` `female` |
| `SearchSort` *(client)* | `rating` — the only sort implemented |
`nurse_gender` accepts `male`/`female`; **omit the param for "any"** — there is no `any` value on this
filter, unlike `requiredCaregiverGender` on a booking request.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-040 | open | No `topReviewTag` on the index row. The C2 card renders without the tag chip |
| REQ-041 | open | No free-text `q` over nurse/variant/category names. Discovery is filter-only |
| REQ-042 | open | `NursePublicProfileDto` has no `nurseGender` — the *index row* has it, the profile does not, so the C3 screen cannot show the gender chip |
| REQ-066 | open, **narrower than filed** | `search/nurses` is **already anonymous**. What is missing is the rate limit — `SearchController` carries no `[EnableRateLimiting]`, so guest browse falls to the 100/min global per-IP limiter |
| REQ-067 | open, **narrower than filed** | `nurses/{id}/profile` is **already anonymous**. What is missing is the privacy review of the payload for unauthenticated callers |
@@ -0,0 +1,53 @@
# service-areas — nurse coverage
> Client seam `client/src/services/serviceAreas/` · `USE_SERVICE_AREAS_MOCK = false` (**real**) · 3 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Where a nurse will travel. Three endpoints, one rule that everything else depends on.
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/nurse_service_areas/list` | wired · paginated |
| POST | `/api/v1/nurse_service_areas/add` | wired |
| DELETE | `/api/v1/nurse_service_areas/remove/{id}` | wired |
All `[Authorize]`. No phantoms. The domain maps 1:1. There is no update — coverage is add/remove.
## `districtId = null` means whole-city
A service area is `(cityId, districtId?)`. **`districtId = null` is the affirmative claim "I cover this
entire city"** — not missing data, not "no district".
The consequences reach three other domains:
- **Search** must match a whole-city area against a district-filtered query, in both directions. A query
that drops nulls silently hides every whole-city nurse. See [search.md](search.md).
- **`is_searchable`** requires at least one area covering the queried city — coverage is one of its four
conditions. See [search.md](search.md).
- **Geography** owns the ids and the same null convention. See [geography.md](geography.md).
The client models this as a **single control**: the coverage editor offers "whole city" as a first-class
choice alongside individual districts, so a nurse can never accidentally express it as an empty district
list. Do not reintroduce a two-step "city, then optionally districts" flow — an empty selection is
ambiguous in a way `null` is not.
## Shape rules the JSON does not express
- **A duplicate area is a `409`.** Adding `(city, null)` when district rows for that city already exist —
or the reverse — is a conflict the server resolves; the client surfaces it rather than pre-checking.
- **Removing the last area for a city removes the nurse from search in that city** in the same
transaction, via `ISearchIndexMaintainer`. There is no lag and no reconciliation job.
- Coverage is independent of `isAcceptingBookings`: a nurse can keep coverage while pausing bookings, and
both are conditions of `is_searchable`.
- `list` is paginated even though the practical row count is small — the platform paginates every
unbounded list without exception.
## Enums
None. Both fields are geography ids; `districtId` is nullable and the null is meaningful.
## Open REQs
None. REQ-008/REQ-009 concern addresses, not coverage — see [addresses.md](addresses.md).
@@ -0,0 +1,91 @@
# tickets — coordination, support, emergency
> Client seam `client/src/services/tickets/` · `USE_TICKETS_MOCK = false` (**real**) · 11 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
Threaded messaging between customer, nurse and staff. Also the emergency channel. The in-app notification
feed is a separate domain — [notifications.md](notifications.md).
## Endpoints
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/tickets` | wired · paginated · `bookingId` filter |
| POST | `/api/v1/tickets` | wired |
| GET | `/api/v1/tickets/{id}` | wired — **stamps `last_read_at`** |
| POST | `/api/v1/tickets/{id}/messages` | wired · optional `clientMessageId` |
| POST | `/api/v1/tickets/{id}/close` | wired |
| POST | `/api/v1/tickets/{id}/reopen` | wired |
| POST | `/api/v1/tickets/emergency` | **unwired** — no emergency entry point in the UI yet |
| POST | `/api/v1/tickets/{id}/participants` | **unwired** |
| DELETE | `/api/v1/tickets/{id}/participants/{userId}` | **unwired** |
| GET | `/api/v1/admin/tickets` | wired · paginated — the staff queue |
| GET | `/api/v1/admin/tickets/{id}` | wired — the staff thread, **internal notes visible** |
All `[Authorize]`; the two `admin/tickets` routes are `DynamicPermission`. Note the hardcoded nested
`admin/` route segment — see [index.md](index.md#route-shape-exceptions).
### Phantom — 1
| Client call | REQ | Live? |
| --- | --- | --- |
| `POST /api/v1/tickets/{id}/assign` | REQ-063 | **Yes — this domain's mock is off.** Gated behind `TICKET_LIFECYCLE_ENABLED`, default **off**, so nothing calls it today |
> **`tickets/constants.ts` is stale on this.** It says the gate is off because "the backend has no
> close/reopen/assign routes yet (REQ-063)". `close` and `reopen` **exist and are wired.** Only `assign`
> is missing. The gate could be turned on for close/reopen alone.
## `isInternal` is a query-layer boundary
The hardest rule in this domain, and the easiest to get wrong in a UI:
- `TicketMessageDto` carries `isInternal`, so the field **is** on the wire shape.
- **The filtering is not.** A non-staff caller's thread query never returns an internal row, and a
non-staff caller can never *set* one. Both are enforced at the query layer, in the handler — **never in
the UI**.
- Consequence for the client: it must not model internal notes as "rows to hide". They do not arrive. A
client-side filter would be a second, weaker gate that hides a leak rather than preventing one.
- Consequence for the server: any new ticket read must repeat the filter. There is no global interceptor
doing it.
## Shape rules the JSON does not express
- **Unread is computed server-side against `last_read_at`**, which is stamped **when the participant
fetches the user-facing thread** (`GET /tickets/{id}`) — not by a separate mark-read call. So opening a
thread is what clears its badge. `unreadCount` counts non-internal messages from *others* after that
stamp, and is **0 on the admin queue** by definition.
- **`clientMessageId` is optimistic-send idempotency** (REQ-028): a retried send with the same key returns
the original message and echoes the key back on `PostMessageResult`. This is what makes the client's
retry-in-place send safe. It is **not** the `Idempotency-Key` header — it is a body field, and it is the
only place in the API that works this way.
- **The message author is a role label, not a name** — confirmed intentional, for privacy.
`TicketMessageDto` carries `senderId` only; the client derives the author label from the participant
role. No raw identity is exposed, and none should be added.
- **`referenceCode` is the human-facing id** shown to users and quoted in support. Treat it as opaque.
- `bookingId` and `refundId` on the summary link a ticket to what it is about; the `bookingId` query
filter is how the client jumps from a booking to its coordination thread.
- Ticket bodies are **encrypted at rest** (refinement-phase-9).
## Enums
| Vocabulary | Values | Note |
| --- | --- | --- |
| `TicketStatus` | `open` `closed` | matches `Entities/Messaging/TicketCodes.cs` |
| `TicketCategory` | `coordination` `support` `refund` `emergency` | matches |
| `TicketAuthorRole` | `customer` `nurse` `admin` **`system`** | **the one cross-side mismatch in the API** |
| `MessageSendStatus` *(client-only)* | `sent` `sending` `failed` | optimistic-send UI state, never on the wire |
> **`system` is client-only.** `TicketCodes` defines `customer`, `nurse`, `admin`. The client's
> `TicketAuthorRole` adds `system` for platform-generated messages. Widening a union on the *reading* side
> is safe — the client can render a value the server never sends — but it means a reader of the client
> types would wrongly conclude the server emits `system`. Either the server should define it or the client
> should drop it; today it is a documented asymmetry.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-028 | delivered | `unreadCount` + `lastMessageAt` on the summary, `bookingId` filter, `clientMessageId` dedupe, role-label authors — all present |
| REQ-059 | open | No last-message preview and no author role on the summary, and no unread-*total* read. The real inbox shows subject + status + time with no preview |
| REQ-060 | deferred | No message photo attachments. The affordance is designed and gated off |
| REQ-063 | open, **narrower than filed** | `close` and `reopen` are delivered and wired. Only `assign` is missing → 1 phantom |
@@ -0,0 +1,117 @@
# verification — the nurse trust pipeline
> Client seam `client/src/services/verification/` · `USE_VERIFICATION_MOCK = true` (**mock is primary**) · 17 server ops
> Last verified: 2026-07-30 against commit `d3ec723` and swagger.v1.json (2026-07-29).
The largest domain, and the one the whole marketplace's trust claim rests on. A nurse is not bookable until
this pipeline says so.
## Endpoints
### Nurse-facing
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/nurse_verification` | wired — **the one cached status query** |
| POST | `/api/v1/nurse_verification/submit` | wired |
| POST | `/api/v1/nurse_verification/steps/{stepId}/upload_url` | wired — presigned PUT |
| POST | `/api/v1/nurse_verification/steps/{stepId}/documents` | wired — confirm the upload |
| POST | `/api/v1/nurse_verification/steps/identity_kyc/run` | wired |
| POST | `/api/v1/nurse_verification/steps/shahkar_match/run` | wired |
| POST | `/api/v1/nurse_verification/steps/bank_account_verification/run` | wired |
| POST | `/api/v1/nurse_verification/credential_details` | **unwired** — the write half of REQ-011; the client has no read-back (REQ-056) |
### Public
| Method | Path | Auth | Verdict |
| --- | --- | --- | --- |
| GET | `/api/v1/nurses/{nurseId}/trust_badge` | **anonymous** | wired |
### Admin — all `DynamicPermission` + `sensitive`
| Method | Path | Verdict |
| --- | --- | --- |
| GET | `/api/v1/admin_verifications` | wired · paginated |
| GET | `/api/v1/admin_verifications/{nurseVerificationId}` | wired |
| POST | `/api/v1/admin_verifications/steps/{stepId}/decide` | wired — **per-step** decision |
| POST | `/api/v1/admin_verifications/{nurseVerificationId}/suspend` | **unwired** |
| POST | `/api/v1/admin_verifications/scan_expiring` | **unwired** — an ops one-shot; the scheduler runs it |
| GET | `/api/v1/admin_verification_step_types` | **unwired** — the step catalogue is data-driven; no editor |
| POST | `/api/v1/admin_verification_step_types` | **unwired** |
| DELETE | `/api/v1/admin_verification_step_types/{id}` | **unwired** |
### Phantom — 3
| Client call | REQ | Note |
| --- | --- | --- |
| `GET /api/v1/admin_verifications/documents/{id}/url` | REQ-034 | Deferred. No on-demand signed document URL, so the queue cannot open an uploaded document |
| `POST /api/v1/admin_verifications/{id}/approve` | REQ-034 | Deferred. Today approval is **per-step** only — the aggregate flips when the last required step passes |
| `POST /api/v1/admin_verifications/{id}/reject` | REQ-034 | Deferred |
## The two rules that must not be broken
1. **`status` is the source of truth; `nurse_profiles.is_verified` is derived.** The boolean is written
**only** by the finalize transaction when the aggregate reaches `approved`, as one guarded
cross-aggregate flip: load both tracked, mutate through one pure domain helper, commit once. Never set
`is_verified` from a profile write, a controller, or out of band. See [profiles.md](profiles.md).
2. **The step catalogue is data, not code.** `verification_step_types` rows define which steps exist,
which are required, and which are automated. Adding a step is a data change. The client must not
hardcode the step list — it renders whatever `VerificationStatusDto.steps` contains.
## Shape rules the JSON does not express
- **`VerificationStatusDto` is `{ status, isBookable, blockingSteps, steps }`** — the server computes
`isBookable` and names the `blockingSteps`. The client does not derive bookability from the step array.
This is why the client keeps **one cached status query** and every screen reads it.
- **`expiresAt` on a step is real.** MoH competency licences and INO membership lapse; `scan_expiring`
reverts a lapsed step to `expired`, which re-gates bookability. `expired` is therefore a normal state,
not an error.
- **Document upload is a two-step presign flow**: `upload_url` returns a presigned PUT, the client uploads
**directly to storage**, then `documents` confirms. The document bytes never transit the API.
`Seams:ObjectStorage:PresignExpirySeconds` (900) bounds the window.
- **The three `run` steps are seams, each independently switchable**
`Seams:IdentityKyc:Provider`, `Seams:Shahkar:Provider`, `Seams:BankOwnership:Provider`, all `mock` by
default, all `finnotech` for the real bridge (sharing `Seams:Finnotech` credentials). Designated test
values make each failure path reachable: `SharedSimPhone` `09120000000`,
`MismatchNationalId` `1111111111`, `FailNationalId` `0000000000`,
`MismatchIban` `IR0000…0000`.
- **`shahkar_match` failing as *shared SIM* is a distinct outcome** from a plain phone↔national-id
mismatch, and the UI must say which — a shared family SIM is a common, innocent case.
- **National id and licence numbers are encrypted at rest**; the admin queue sees them only where the
decision requires it.
- The nurse's INO membership field is **locked once submitted** (it feeds the public trust badge).
## Two vocabularies for one thing
`GET /me` reports a *nurse verification summary* using `not_started` `in_progress` `pending_review`
`verified` `rejected`. This domain's aggregate uses `not_started` `pending` `in_review` `approved`
`rejected` `suspended`. **They are two read models over the same source of truth, not a drift** — but they
are not interchangeable strings. Map deliberately. See [auth.md](auth.md).
## Enums
| Vocabulary | Values |
| --- | --- |
| `VerificationStatus` (aggregate) | `not_started` `pending` `in_review` `approved` `rejected` `suspended` |
| `VerificationStepStatus` | `not_started` `pending` `in_review` `passed` `failed` `expired` |
| `StepTypeCode` | `identity_kyc` `shahkar_match` `moh_competency_license` `ino_membership` `criminal_record` `bank_account_verification` |
| `CredentialType` | `moh_competency_license` `ino_membership` `criminal_record` |
| `VerificationMethod` | `manual` `portal` `api` |
| `BadgeState` *(client, from `TrustBadgeDto`)* | `verified` `unverified` `expired` |
The first two are C# enums serialised as snake_case codes (`Entities/Verification/VerificationStatus.cs`,
`VerificationStepStatus.cs`); the rest are string constants in `VerificationStepTypeCodes.cs`. All verified
identical to the client's unions.
`TrustBadgeDto` is `{ nurseId, isVerified, approvedAt, credentialTypes }` — a summary, with no per-step
detail. That absence is REQ-043.
## Open REQs
| REQ | Status | Effect |
| --- | --- | --- |
| REQ-034 | deferred | No nurse-grouped queue, no on-demand document URL, no whole-verification approve/reject → 3 phantom routes. This is the main reason the seam is mocked |
| REQ-043 | open | `TrustBadgeDto` has no per-step detail (step codes + decision dates), so the public verification panel shows a summary only |
| REQ-055 | open | No `submittedAt` on `VerificationStatusDto` — the real B6 screen omits the timestamp line |
| REQ-056 | open | No nurse-facing read-back of submitted credential details. `credential_details` writes; nothing reads. The real form degrades to blank |
| REQ-062 | open | No name/phone search and no per-status counts on the admin queue |
+152
View File
@@ -0,0 +1,152 @@
# Integration — the client↔server seam
The two projects have **no shared build**. Everything that crosses between them is described here, as one
thing. This page is the whole seam; the four files below are the detail.
> Last verified: 2026-07-30 against commit `d3ec723`, `swagger.v1.json` (2026-07-29, 178 paths / 186
> operations) and the code in `client/src/lib/api/`, `client/src/services/*/`, `server/src/API/`.
| File | Covers |
| --- | --- |
| [api-contract.md](api-contract.md) | Envelope, casing, pagination, errors, idempotency, auth, money, rate limits |
| [domains/](domains/index.md) | 22 files, one per client `services/` domain — every endpoint, verdicted against the live swagger |
| [openapi/](openapi/README.md) | The machine contract + how to regenerate it |
| [config-matrix.md](config-matrix.md) | Every env var and appsettings key: client, server, docker, the bot |
| [topology.md](topology.md) | The runtime dependency graph — 3 containers, Caddy, remote SQL, the OTP relay |
---
## Transport
HTTP/JSON. The client reads one base URL — `NEXT_PUBLIC_API_URL` — and prefixes every path with
`/api/v1/`. Locally that is `http://localhost:5002` (**plain HTTP**; `launchSettings.json` binds no TLS);
deployed it is `https://api.balinyaar.ir`, which Caddy terminates and forwards to `balinyaar-api:8080`.
**gRPC exists and the client does not use it.** `Baya.Web.Plugins.Grpc` serves exactly one service (User)
over HTTP/2 on the same port. Nothing in `client/` speaks it. Treat it as an internal affordance.
**Two OpenAPI documents are served, `v1` and `v1.1`.** All 55 controllers declare `[ApiVersion("1")]`, and
`ApiVersionDocumentProcessor` keeps only paths whose URL contains the document's version segment — so
**`v1.1` is served but contains zero paths.** `v1` is the contract.
## The envelope
Every response — success *and* failure — is `ApiResult`. The payload is always under `data`.
```json
{ "isSuccess": true, "statusCode": 200, "message": "Success",
"requestId": "0af7651916cd43dd8448eb211c80319c", "code": null, "data": { } }
```
`requestId` is the **W3C trace id** of the request (`Activity.Current.TraceId`), the same id
OpenTelemetry traces on — so a support ticket maps 1:1 to a trace. `code` is an optional stable
machine-readable error code (`otp_locked`, …), omitted from the wire when null.
The client never unwraps centrally: `clientFetch`/`serverFetch` return the **raw envelope**, and each
service calls `unwrap()` from [`client/src/lib/api/types.ts`](../../client/src/lib/api/types.ts).
## Casing — camelCase bodies, snake_case URLs
Verified mechanically against the live swagger: of **427 distinct property names, 0 contain an underscore
and 0 are PascalCase.** JSON bodies are camelCase. URL *segments* are snake_case, produced by the server's
`SnakeCaseParameterTransformer` from `[controller]`/`[action]` tokens (`SetPrimary``/set_primary`).
Three routes break the snake_case rule with hardcoded hyphens — `admin/partner-centers`, `admin/tickets`,
`admin/reviews` — see [domains/index.md](domains/index.md#route-shape-exceptions).
## Pagination
`{ items, total, page, pageSize }` (`total`/`page`/`pageSize` are int32). 29 operations are paginated.
The **declared** query-param names are not uniform — 25 declare `Page`/`PageSize`, 3 declare
`page`/`pageSize`, and `GET /search/nurses` declares `page`/`page_size`. Model binding is
case-insensitive so the first two are interchangeable; `page_size` is a *different name* and is not.
Full table in [api-contract.md](api-contract.md#pagination).
## Errors
`200` · `400` validation (field errors under `data`) · `401` unauthenticated · `403` forbidden ·
`404` not found (**also returned for a tenancy mismatch**, deliberately, so a 403 never confirms a row
exists) · `409` state-machine/idempotency conflict · `422` · `424` · `429` rate-limited · `5xx`.
`clientFetch` behaviour: **401** → one silent refresh + retry, then clear cookies, toast, redirect to
login (no throw); **403** and **5xx** → toast + throw `ApiError`; **other 4xx** → throw without toasting
(the calling hook owns the message); **network failure** → toast + `ApiError(0)`.
## Auth
**Bearer header, not cookie auth.** The JWE access token is *stored* in a cookie the client reads itself
and *sent* as `Authorization: Bearer <token>`. The server's CORS policy therefore does **not** allow
credentials, and the client sends no `credentials: 'include'`.
The token is opaque to the client (signed + AES-encrypted). Role and identity come from `GET /api/v1/me`;
a multi-role user picks one with `POST /api/v1/me/select_role`. On a 401 the client runs one
single-flight silent refresh (`POST /api/v1/auth/refresh`) — the server rotates the pair and detects
reuse, so a replayed refresh token kills the session. `/auth/refresh`, `/auth/request_otp` and
`/auth/verify_otp` are excluded from the retry.
**20 of 186 operations are anonymous** — the OTP pair, catalog + geo reference reads, the public nurse
search/profile/reviews/trust-badge reads, `ping`, the three webhooks, and Development's
`GET /api/v1/dev/last_otp/{phone}`. Full list in [api-contract.md](api-contract.md#the-anonymous-surface).
## Idempotency
`Idempotency-Key` is a request header. The server reads it on exactly **two** endpoints:
| Endpoint | Key scope |
| --- | --- |
| `POST /api/v1/bookings/{bookingRequestId}/payments` | one key per payment *attempt*, reused across retries of that attempt |
| `POST /api/v1/checkout_bnpl/initiate` | same, per BNPL attempt |
The client also sends it on `admin_payouts` process/retry, where **the server does not read it** — see
[domains/payouts.md](domains/payouts.md). Webhooks do not use the header; they dedupe on the provider's
`external_event_id`. The header is allowed through CORS but is **not declared in swagger** (it is read
from `Request.Headers`, not bound as a parameter).
## Money
**IRR Rials, integer, no floats, anywhere.** On the wire the direction matters, and it is consistent:
- **Outbound (DTOs/results): a digit string.** All 68 money properties on read models are `type: string`.
Parse with the `@/utils` BigInt helpers — never `Number()`.
- **Inbound (commands): `integer/int64`.** All 3 money properties on command bodies.
Toman is display-only and is converted **only** inside a provider adapter at its boundary
(`ICurrencyNormalizer`, `Seams:Currency:TomanToIrrMultiplier`). `gross = commission + payout` always,
and VAT is on Balinyaar's commission only. See [docs/rules/server/money.md](../rules/server/money.md)
and [docs/rules/client/services.md](../rules/client/services.md).
## Enums
**Swagger declares no string enums** — 1 of 339 schemas has an `enum`, and it is the integer
`ApiResultStatusCode`. Every status/code field is a bare `string` on the wire. The vocabulary therefore
lives in the domain files here, cross-checked against the server's `Baya.Domain` code sets and the
client's string-literal unions. **All 18 shared vocabularies match on both sides** as of this stamp; the
one exception is noted in [domains/tickets.md](domains/tickets.md).
---
## What the server owes the client
1. The `ApiResult` envelope on every response, with `requestId` populated and `code` set on any failure
the client must branch on.
2. camelCase bodies; snake_case URL segments; `{ items, total, page, pageSize }` on every list.
3. `404`, never `403`, for a row the caller does not own.
4. Money as a digit string outbound; the three-amount split guaranteed server-side.
5. A masked address and only unencrypted `customerNotes` before a booking is confirmed; full care
instructions only after, only to the assigned nurse and admin.
6. `is_internal` filtered at the query layer — a non-staff caller can never read or set one.
7. Idempotent behaviour on the two keyed endpoints, and `409` (not `500`) on a converged replay.
8. Reachability: `/healthz/live`, `/healthz/ready`, and CORS origins that list the client's real origin.
## What the client owes the server
1. `Authorization: Bearer <token>` on every authenticated call — and nothing else for auth. No cookies
cross the wire.
2. `Accept-Language` (`fa` default) on every call, from the active locale segment.
3. `Content-Type: application/json`**except** `FormData` bodies, where the browser sets the multipart
boundary itself.
4. One stable `Idempotency-Key` per payment/BNPL attempt; a new attempt gets a new key.
5. `page`/`pageSize` within the server's cap; never an unbounded list request.
6. IRR integers inbound; Toman conversion only at the UI boundary.
7. Exactly one silent refresh per 401, single-flighted, and never on the refresh/OTP endpoints.
8. No derived money. The client never computes commission, VAT, refund amounts or payout dates.
@@ -0,0 +1,79 @@
# OpenAPI snapshot
The **machine contract**. The server generates it with NSwag; this folder holds the published snapshot
so the client can generate or verify types without booting the backend.
> Last verified: 2026-07-29 against commit `c99e3f4` (server code last changed in `5885280`, 2026-07-28).
| | |
| --- | --- |
| File | [`swagger.v1.json`](swagger.v1.json) |
| Taken | 2026-07-29 |
| Commit | `c99e3f4` |
| **Paths** | **178** |
| **Operations** | **186** |
| Component schemas | 339 |
| Generator | NSwag v14.7.1.0 · OpenAPI 3.0.0 |
| Size | 612 K |
## How to regenerate
The API binds **plain HTTP** on port 5002 (`launchSettings.json`), despite what most prose in this repo
says — see contradiction C-3. Use whatever port you bind; the document is the same.
```bash
cd server
ASPNETCORE_ENVIRONMENT=Development dotnet run \
--project src/API/Baya.Web.Api/Baya.Web.Api.csproj
# from another shell, once "Now listening" appears:
curl -s --noproxy '*' http://127.0.0.1:5002/swagger/v1/swagger.json \
-o docs/integration/openapi/swagger.v1.json
```
Two things that cost time the first run:
- Booting in `Development` **migrates and seeds** against the DB in `appsettings.Development.json`
currently a *remote* SQL Server. First boot takes ~40 s and logs
`Demo world already seeded — no-op.` when the world is present.
- `--noproxy '*'` matters. With a proxy configured in the environment, `curl` to `localhost` returns
**502** rather than the document.
Then update the table above — date, commit, and endpoint count — in the same change. A snapshot whose
provenance is unrecorded is what this chain exists to stop.
## Scope — `v1`, and why `v1.1` is empty
Both documents **are** registered: `Program.cs` calls `AddSwagger("v1", "v1.1")`, so
`/swagger/v1.1/swagger.json` is served. It contains **zero paths**.
`ApiVersionDocumentProcessor` removes every path whose URL does not contain the document's own version
segment, and all **55 controllers declare `[ApiVersion("1")]`** with the route template
`api/v{version:apiVersion}/…`. So every URL contains `v1` and none contains `v1.1`.
That resolves contradiction **C-9**: the old README's "publishes `v1` and `v1.1`" was literally true and
substantively empty. `v1` is the contract. Only `v1` is worth committing.
## One provenance wrinkle
The document's `servers` block reads `http://127.0.0.1:5099`, while the regeneration command above uses
port 5002. NSwag records whichever host answered the request, so this only means the phase-0 snapshot was
taken from a run bound to 5099. **The document content is port-independent** — no path, schema or parameter
depends on it — so this is a provenance note, not a defect. Re-taking the snapshot from 5002 would change
that one string and nothing else.
## The human contract
The prose half — one file per domain — is in [`../domains/`](../domains/index.md): **22 files, one per
client `services/` domain**, with all 186 operations assigned to exactly one of them. **The two must
agree**: this JSON is the wire truth, the markdown explains it. When they disagree, the JSON wins and
the markdown is wrong.
Two things the JSON **cannot** tell you, which is why the markdown exists:
- **Enum vocabularies.** Exactly 1 of 339 schemas has an `enum`, and it is the integer
`ApiResultStatusCode`. Every status/code field is a bare `string`. The vocabularies live in the domain
files, cross-checked against `Baya.Domain`'s code sets.
- **`Idempotency-Key`.** Two endpoints require it, and neither declares it — it is read from
`Request.Headers`, not bound as a parameter. See
[`../api-contract.md`](../api-contract.md#idempotency).
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
# Topology — the runtime dependency graph
What talks to what at runtime, what breaks when each hop is down, and which file configures it. This is the
answer to a deploy question; the deploy *procedure* is [DEPLOY.md](../../DEPLOY.md) and every key is in
[config-matrix.md](config-matrix.md).
> Last verified: 2026-07-30 against commit `d3ec723`, `docker-compose.yml`, `deploy/Caddyfile` and
> `server/src/API/Baya.Web.Api/Program.cs`.
---
## Deployed
```mermaid
graph LR
browser["Browser<br/>balinyaar.ir"]
subgraph net["caddy_net (external docker network)"]
caddy["Caddy<br/><i>pre-existing, not in this repo</i><br/>TLS terminator"]
web["balinyaar-web:3000<br/>Next.js 16"]
api["balinyaar-api:8080<br/>ASP.NET Core 10"]
relay["balinyaar-otp-relay:5010<br/>Node, zero deps"]
proxy["hysteria-client:8081<br/><i>pre-existing</i>"]
end
sql[("Remote SQL Server<br/>87.107.152.16:1433<br/><b>not containerised</b>")]
vol[["api-object-storage<br/>docker volume"]]
tg["api.telegram.org"]
rails["External rails<br/>PSP · BNPL · Finnotech · Neshan · مودیان<br/><i>all mocked by default</i>"]
browser -->|"HTTPS"| caddy
caddy -->|"HTTP :3000"| web
browser -->|"HTTPS api.balinyaar.ir<br/><b>every API call</b>"| caddy
caddy -->|"HTTP :8080<br/>+ X-Forwarded-For"| api
api -->|"TCP 1433"| sql
api -->|"HTTP + X-Api-Key"| relay
api --> vol
api -.->|"per seam config"| rails
relay -->|"HTTP CONNECT"| proxy
proxy --> tg
web -.->|"builds absolute URLs only<br/><b>no server-to-server calls</b>"| api
```
**The single most important edge is the dotted one.** The browser calls the API directly at
`https://api.balinyaar.ir`; the `web` container does **not** proxy or fetch on the browser's behalf. That is
why `NEXT_PUBLIC_API_URL` must be the **public hostname**, never the container name — a container name is
unresolvable from a browser. It is also why every `NEXT_PUBLIC_*` change needs an image rebuild: the value
is compiled into the bundle.
## Every edge
| # | From → To | Carries | Breaks if down | Configured in |
| --- | --- | --- | --- | --- |
| 1 | browser → Caddy | All HTTPS for both hostnames | Everything. Caddy is the only TLS terminator and the only published port | `deploy/Caddyfile` (pasted into the pre-existing Caddy) |
| 2 | Caddy → `balinyaar-web:3000` | The Next.js app shell, RSC payloads, static assets | The site does not load. **The API keeps working** — they are independent hostnames | `Caddyfile` · `docker-compose.yml` |
| 3 | browser → Caddy → `balinyaar-api:8080` | Every `/api/v1/*` call, bearer-authenticated | The site loads and every screen shows its error state. No data, no login | `NEXT_PUBLIC_API_URL` (build-time) · `Cors:AllowedOrigins` |
| 4 | Caddy → API, `X-Forwarded-For` | The real client IP | The rate limiter partitions every request onto **Caddy's** IP, so one noisy client 429s everyone | `ForwardedHeaders:KnownNetworks` — the docker bridge ranges |
| 5 | API → remote SQL Server | All application data + the Serilog sink | **Total outage.** `/healthz/ready` fails and the API will not start | `ConnectionStrings:SqlServer` / `:logDb` |
| 6 | API → `balinyaar-otp-relay:5010` | OTP codes, `X-Api-Key` authenticated | **Nobody can log in.** Every other authenticated screen keeps working for existing sessions | `Seams__Sms__Telegram__BaseUrl` (compose) · `Seams:Sms:Telegram:ApiKey` |
| 7 | relay → `hysteria-client:8081` → Telegram | The code delivery itself | Same as 6 — codes are generated but never arrive. Fails **at relay boot** with a clear message, not silently per-OTP | `TELEGRAM_PROXY_URL` |
| 8 | API → `api-object-storage` volume | Verification documents, avatars | Uploads fail; `/healthz/ready` fails (it does a real write probe). **Without the volume, existing documents vanish on the next `up --build`** | `Seams__ObjectStorage__RootPath` + the named volume |
| 9 | API → external rails | Payments, BNPL, KYC, geocoding, e-invoicing | **Nothing, by default** — every rail is `mock`. Real behaviour begins the moment a `Provider` selector changes | `Seams:<rail>:Provider` — see [config-matrix.md](config-matrix.md#the-seam-selectors) |
| 10 | PSP / BNPL / transferor → API webhooks | Payment capture, BNPL settlement, payout reconciliation | Payments are taken but **bookings are never confirmed** — the webhook is what creates the booking | `webhook` rate-limit policy, anonymous routes, per-provider signing secrets |
## Ports
| Port | Where it applies | Note |
| --- | --- | --- |
| `443` | The host | Caddy. **The only published port on the machine** |
| `3000` | `caddy_net` only | Next.js. Not published |
| `8080` | `caddy_net` only | The API **in-container** |
| `5002` | A developer laptop only | The local `launchSettings.json` port — **plain HTTP**, and nothing in the deployment uses it |
| `5010` | `caddy_net` only | The OTP relay |
| `1433` | Outbound to `87.107.152.16` | Remote SQL Server |
> **5002 vs 8080 catches people out.** Most prose in this repo says 5002 because that is the local port.
> The container binds 8080 (the ASP.NET default in a container) and the Caddyfile points there.
## Startup order
`depends_on: [otp-relay]` puts the relay before the API, but that only orders *container start*, not
readiness. What actually gates the API's boot:
1. **`StartupSecretsGuard`** — refuses to start on a missing or placeholder secret, before any service reads
config.
2. **A reachable SQL Server** — required to start, full stop.
3. **In Development** (which is what the deployment runs): apply migrations, then seed default users,
payment gateways, the demo world and the demo lifecycle. All idempotent — a re-boot logs
`Demo world already seeded — no-op.` **First boot takes ~40 s** against the remote DB.
4. **When deployed as a non-Development environment**: DDL is a separate one-shot
(`dotnet Baya.Web.Api.dll migrate`); boot only *checks* the schema is current and fails fast on a pending
migration, then seeds roles.
The relay refuses to start without `API_KEY` (min 16 chars) or with an unreachable proxy — both fail loudly
at boot rather than per-request.
## Request pipeline inside the API
Order matters, and two placements are deliberate:
```
UseForwardedHeaders ← first, so the resolved client IP is in place before anything reads it
UseSwaggerAndUi
UseRouting
UseCors ← after routing, BEFORE the rate limiter and auth, so a pre-flight OPTIONS
UseRateLimiter is answered rather than rejected as 429 or 401
UseAuthentication
UseAuthorization
MapControllers
UseMetrics · UseHealthChecks
ConfigureGrpcPipeline
```
## What is not in the graph
| | Why |
| --- | --- |
| A database container | The DB is remote and pre-provisioned. `RUNBOOK.md`'s local-SQL-in-Docker path describes a **different world** — an unseeded one (contradiction **C-5**) |
| Redis | `ICacheService` / `IDistributedLock` are single-process today. When a multi-instance deployment needs a real one, a `redis` readiness check gets added alongside it |
| A job runner | The weekly payout batch is generated by an **in-process** `IRecurringJob` scheduler. No external cron, no queue |
| A message broker | Nothing is asynchronous across a process boundary |
| An OTLP collector | `OpenTelemetry:Otlp:Endpoint` is unset, so nothing is exported. Prometheus scrapes `/metrics` directly |
| A second API instance | Single instance. Multi-instance needs the distributed lock to become real first |
## Local development
Same code, a different graph — no Caddy, no containers, and the **same remote database**:
```
localhost:3000 (npm run dev) ──▸ localhost:5002 (dotnet run, plain HTTP)
├──▸ 87.107.152.16:1433 (the same remote DB)
└──▸ 127.0.0.1:5010 (the relay, if you run it)
```
Three things to know before the first run:
- **The API is plain HTTP locally.** `dotnet dev-certs https --trust` is not needed and there is no
certificate to trust (contradiction **C-4**).
- **You share the deployment's database.** Booting in Development migrates and seeds against it. The
seeders are idempotent, but you and the demo site are looking at the same rows.
- **If you run the relay locally**, copy `Seams:Sms:Telegram:ApiKey` from `appsettings.Development.json`
into your own `telegram-otp-bot/.env`. The value in `.env.example` is published and
`TelegramSmsSender` deliberately rejects it.
+140
View File
@@ -0,0 +1,140 @@
# Deferred — recorded, not re-decided
> Last verified: 2026-08-02 against commit `51e86a1`. Populated by phase 5 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
**51 items** carry deferred status in [backlog.md](../status/backlog.md): the 43 in its dedicated
["Deferred" section](../status/backlog.md#deferred-43) (BL-220BL-262), plus 8 more filed by severity
elsewhere in the same file whose Status column also reads `deferred` (BL-050, BL-075, BL-082, BL-096,
BL-163, BL-168, BL-201, BL-219). This file **records them faithfully** — it does not re-litigate any of
them. A deferral with a trigger is a decision; every row below has one, preserved in substance from its
source. Grouped by theme, as backlog.md itself groups its Deferred section.
**How to read "size" below**: this column is this document's own estimate (S / M / L / XL), not carried from
any source file — backlog.md doesn't size deferred items, so sizing them is this phase's proposal, made to
help a future reader triage, and open to revision.
---
## The 8 unbuilt product tables
Named individually per the phase brief, cross-referenced from [implemented.md](../status/implemented.md):
| Table | Backs | BL-### | Pull-trigger |
| --- | --- | --- | --- |
| `organizations` | Employer/company account model | [BL-224](../status/backlog.md#deferred-43) | Product pulls it |
| `organization_nurses` | Employer↔nurse membership | [BL-224](../status/backlog.md#deferred-43) | Product pulls it |
| `fraud_flags` | ML-scored fraud signals | [BL-225](../status/backlog.md#deferred-43) | Product pulls it (manual suspension + support alerts cover this today) |
| `recurring_booking_schedules` | Recurring/subscription bookings | [BL-226](../status/backlog.md#deferred-43) | Product pulls it |
| `bnpl_settlement_entries` | Tranched BNPL settlement | [BL-227](../status/backlog.md#deferred-43) | Product pulls it, or a future BNPL provider tranches settlement (one settlement row covers today's provider) |
| `nurse_availability_slots` | Soft scheduling-guidance windows | [BL-228](../status/backlog.md#deferred-43) | Product pulls it |
| `nurse_availability_exceptions` | Time-off exceptions to the above | [BL-228](../status/backlog.md#deferred-43) | Product pulls it |
| `incidents` | First-class incident entity | [BL-244](../status/backlog.md#deferred-43) | Product pulls it (support alerts cover this today) |
All eight are, per refinement-phase-9's own framing (item 9.11 in that phase's handoff, distilled in
[decisions.md](../status/decisions.md)), **pure additive migrations** — nothing in the current schema needs
to change first when one is pulled forward. The same source item 9.11 also grouped in customer national-ID
KYC ([BL-229](#product-scope-cuts-not-building-yet-by-decision)) and geo bulk-import
([BL-230](#product-scope-cuts-not-building-yet-by-decision)) alongside the tables; backlog.md split those into
their own rows below since neither is a table.
---
## Scale-later infrastructure
Correct for a single instance / MVP load; each has a concrete, measurable trigger, not a date.
| Item | Why deferred | Pull-trigger | Size | Decided |
| --- | --- | --- | --- | --- |
| [BL-220](../status/backlog.md#deferred-43) — Redis (shared cache, cross-instance scheduler/money lock) not deployed | Every current seam (the in-process scheduler, rate limiting) is correct only for one running instance | Running more than one instance | M | refinement-phase 7/8/9 handoffs |
| [BL-221](../status/backlog.md#deferred-43) — Elasticsearch `INurseSearch` + outbox feeder not built | `SqlNurseSearch` is real, correct, and sufficient at MVP scale | SQL search shows real strain (latency/throughput on `nurse_search_index`) | L | backend-phase-7 follow-up, refinement-phase 9.7 |
| [BL-252](../status/backlog.md#deferred-43) — no short-TTL cache over hot search-result pages | Shipped no-cache at MVP; premature to cache before there's a measured hot path | Search read latency becomes a real problem | S | backend-phase-7 follow-up |
## Product-scope cuts (not building yet, by decision)
| Item | Why deferred | Pull-trigger | Size | Decided |
| --- | --- | --- | --- | --- |
| [BL-222](../status/backlog.md#deferred-43) — `IAnalyticsSink` / analytics warehouse not built | Events write to `ops.SystemEvents` (real, queryable) fire-and-forget; no cross-event pipeline yet | Product needs cross-event analytics beyond SQL queries | L | backend-phase-1, refinement-phase 9.8 |
| [BL-223](../status/backlog.md#deferred-43) — no real holiday-calendar feed | `IHolidayCalendar` reads a real, seeded, manually-maintained table; a yearly ops top-up is an acceptable MVP alternative to an automated feed | Product pulls it (manual refresh becomes a burden) | M | backend-phase-1, refinement-phase 9.9 |
| [BL-229](../status/backlog.md#deferred-43) — customer national-ID KYC collection not built | Deliberate: never gate browsing/booking on it | **Never, by design** — informational only | — | backend-phase-3, refinement-phase-9, business/01 |
| [BL-230](../status/backlog.md#deferred-43) — geography bulk-import feed (`IGeoDataImporter`) not built | The idempotent seed + admin CRUD is sufficient for MVP | Product pulls it | M | backend-phase-4 follow-up |
| [BL-231](../status/backlog.md#deferred-43) — holiday/surge pricing, a distinct Companionship tier, tiered per-category commission | All explicitly out of MVP scope in business/03 | Product pulls it | M (pricing rule) / L (new tier) | backend-phase-5 follow-up, business/03 |
| [BL-232](../status/backlog.md#deferred-43) — GPS-radius "nurses near me" map discovery | Coverage stays named-district-only by design | **None — permanent product decision** | — | backend-phase 4/7, business/04 |
| [BL-233](../status/backlog.md#deferred-43) — automated MoH/INO license lookup + professional-liability-insurance step type | Manual verification is the MVP path; no B2B lookup API is confirmed to exist yet | A lookup portal is confirmed to exist, or product pulls the insurance step | M | backend-phase-6, business/02 |
| [BL-239](../status/backlog.md#deferred-43) — dedicated MoR center-settlement payout path (تسهیم split leg) | Per decision 6.6; the platform pays nurses directly today regardless of MoR | Product pulls it | M | refinement-phase 6/8 follow-ups |
| [BL-241](../status/backlog.md#deferred-43) — on-demand/instant nurse payout, per-nurse payout frequency | MVP is one fixed weekly cadence for everyone | Product pulls it | M | backend-phase-13, business/10 |
| [BL-242](../status/backlog.md#deferred-43) — automated clawback recovery beyond next-batch netting | Simple greedy netting covers MVP | Product pulls it | M | backend-phase-13 |
| [BL-243](../status/backlog.md#deferred-43) — two-way double-blind reviews with timed reveal | One-way customer review is MVP scope per business/11 | Product pulls it | L | backend-phase-14, business/11 |
| [BL-246](../status/backlog.md#deferred-43) — automated eNamad/MoH license verification for partner centers | Manual-approve is MVP scope per business/13 | Product pulls it | M | backend-phase-15 |
| [BL-247](../status/backlog.md#deferred-43) — no telephony seam for emergencies | The emergency contact is an out-of-platform `tel:` link, by deliberate design | **None — permanent product decision** | — | backend-phase-15 |
| [BL-248](../status/backlog.md#deferred-43) — SMS/push notification channels not built | Only in-app notifications are real; deliberate MVP scope (business/14) | Notification UX demands out-of-app reach | M | refinement-phase-9, refinement-phase 9.10 |
| [BL-251](../status/backlog.md#deferred-43) — PWA/offline caching (Workbox) unbuilt | Marked "maybe" in the original product backlog — optional from the start | Product pulls it | M | product/notes/open-questions.md |
| [BL-254](../status/backlog.md#deferred-43) — partner dashboard's sponsored-nurse list capped at 50, no pagination | No center has exceeded 50 sponsored nurses yet | A center exceeds 50 sponsored nurses | S | backend-phase-15 follow-up |
| [BL-257](../status/backlog.md#deferred-43) — no true desktop search layout | The phone-width frame is the whole app's deliberate design; a full responsive pass was explicitly deferred post-chain | Product decides to support desktop | L | ui-phase-12 follow-up |
| [BL-258](../status/backlog.md#deferred-43) — Persian OG image is Latin-only (also [BL-202](../status/backlog.md#minor-115)) | Deliberate scope cut on `/welcome` and other share cards | Persian social sharing becomes a priority | S | ui-phase-13 follow-up |
| [BL-259](../status/backlog.md#deferred-43) — no list-row EVV "currently checked in" indicator | Needs a product/API decision to avoid an N+1 read; not free to add | Product prioritizes a list-level EVV signal | S | ui-phase-5 follow-up |
| [BL-260](../status/backlog.md#deferred-43) — no web-push for new nurse booking requests | The 15-second poll is the only freshness mechanism today | Push infra (service worker + VAPID + dispatch rail) is built | L | ui-phase-7 follow-up, REQ-054 |
| [BL-262](../status/backlog.md#deferred-43) — no real payment-gateway/Shaparak logos near the pay CTA | No licensed assets yet; a generic lock-icon notice stands in | Licensed gateway assets obtained | S | ui-phase-6 follow-up |
## Vendor / integration deferrals
| Item | Why deferred | Pull-trigger | Size | Decided |
| --- | --- | --- | --- | --- |
| [BL-235](../status/backlog.md#deferred-43) — SMS.ir/Ghasedak adapters not built | Only Kavenegar's real-SMS path is wired; either alternative throws at startup by design rather than silently mocking | A second SMS vendor is needed | M | refinement-phase-8 follow-up |
| [BL-236](../status/backlog.md#deferred-43) — Finnotech/Moadian token-exchange refresh + signing certificate not wired | Both are deploy-time actions that need real credentials, which don't exist yet | Going to a real Moadian integration ([pre-launch.md §5](pre-launch.md#5-legal--tax-items-that-are-code-side)) | S once credentials exist | refinement-phase-8 handoff |
| [BL-238](../status/backlog.md#deferred-43) — per-provider-code BNPL revert incomplete (always drives SnappPay) | Only one BNPL provider is live today | A second BNPL provider goes live | S | refinement-phase-8 follow-up |
## Scheduled-ops deferrals (manual today, by choice or by not-yet-built)
| Item | Why deferred | Pull-trigger | Size | Decided |
| --- | --- | --- | --- | --- |
| [BL-234](../status/backlog.md#deferred-43) — credential-expiry scan + EVV no-show sweep are manual-only endpoints | No cron calls either yet | A scheduled-ops phase | S | backend-phase 6/9 follow-ups |
| [BL-237](../status/backlog.md#deferred-43) — Moadian reconciliation poll + refund-settlement poll are manual | No scheduled cron for either | A scheduled-ops phase | S | backend-phase-11, refinement 6/7/8 follow-ups |
| [BL-240](../status/backlog.md#deferred-43) — payout batch *generation* is automatic; *processing* a batch stays a deliberate, explicit admin action | Not a bug — moving real money should have a human in the loop at this scale | **None — permanent product decision** | — | refinement-phase-7, backend-phase-13 |
## Cleanup-of-convenience and data-integrity hygiene
| Item | Why deferred | Pull-trigger | Size | Decided |
| --- | --- | --- | --- | --- |
| [BL-219](../status/backlog.md#minor-115) — rename the `SET_VIA_USER_SECRETS_OR_ENV` placeholder sentinel | The name is a load-bearing sentinel `StartupSecretsGuard` checks for; renaming touches several files for a cosmetic gain | None — cleanup-of-convenience | S | phase 2 (`open-contradictions.md` C-2) |
| [BL-250](../status/backlog.md#deferred-43) — the ESLint unused-vars gate is a repo-wide no-op | Config patches an export path that doesn't carry the rule; fixing it is a dedicated infra task, not a drive-by | A dedicated infra task | S | frontend-phase-13 follow-up |
| [BL-253](../status/backlog.md#deferred-43) — payment-webhook confirm path uses two DB commits instead of one transaction | Kept safe today via idempotency + a forward-only guard; a real fix needs `IUnitOfWork` to grow a transaction scope first | `IUnitOfWork` grows a transaction scope | M | backend-phase-10 follow-up |
| [BL-255](../status/backlog.md#deferred-43) — `Bookings`/`Invoices.partner_center_id` have no DB-level FK | Only `nurse_profiles.partner_center_id` got one, per that phase's own Definition of Done | A data-integrity pass on partner-center columns | S | backend-phase 11/15 follow-ups |
| [BL-256](../status/backlog.md#deferred-43) — skeleton→content crossfade never retrofitted onto every list/detail page | Exists as a one-line-per-screen pattern; applying it everywhere is a dedicated visual pass, not a drive-by | A dedicated visual-polish pass | M | ui-phase-12 follow-up |
| [BL-249](../status/backlog.md#deferred-43) — legacy `UserRefreshTokens` (gRPC auth path) still exists alongside real session-based REST auth | Removing it means confirming nothing still depends on the gRPC path | gRPC moves to sessions, or is dropped | S | backend-phase-2 follow-up |
---
## Deferred at the item level — gated behind a console that isn't prioritized yet
These 7 are filed by severity (major/minor) rather than in backlog.md's thematic Deferred section, because
each is a specific, already-scoped gap rather than a standing product decision — but each is genuinely blocked
on the same thing: an admin console this roadmap hasn't prioritized building yet. Recorded here for the same
reason: each has a trigger, and none should be picked up piecemeal ahead of its console.
| Item | Why deferred | Pull-trigger | Size | Decided |
| --- | --- | --- | --- | --- |
| [BL-050](../status/backlog.md#major-86) — no preview/approve/reject route for admin refunds (`POST admin_refunds` creates+executes in one call) | The three-step admin `RefundPanel` console has no real backend to call | Admin refund console prioritized | M | phase 3 flow gap |
| [BL-075](../status/backlog.md#major-86) — `recordTransferReference` targets a route that doesn't exist | The payout batch-detail reconcile field would 404 on flip | Payout reconciliation console prioritized | S | phase 3 flow gap, REQ-036 |
| [BL-082](../status/backlog.md#major-86) — `approveVerification`/`rejectVerification`/`getDocumentSignedUrl` target routes that don't exist | The admin verification case page's approve/reject CTAs would 404 the moment BL-001 (RBAC) is fixed | Verification-admin console prioritized | M | phase 3 flow gap, REQ-034 |
| [BL-096](../status/backlog.md#major-86) — tier (c) of the public front door (guest search + public nurse profiles) unbuilt | Needs a backend phase (anonymous rate-limited search read, privacy-reviewed profile shape) **and** an explicit privacy sign-off on the nurse-profile field list — a decision, not a coding task | Privacy sign-off obtained **and** REQ-066/REQ-067 delivered | L | ui-phase-13, product/notes/open-questions.md |
| [BL-163](../status/backlog.md#minor-115) — `POST tickets/{id}/assign` is a phantom endpoint the client already targets | Client-side assign UI exists; server route doesn't | Ticket assignment prioritized | S | phase 3 flow gap, REQ-063 |
| [BL-168](../status/backlog.md#minor-115) / [BL-261](../status/backlog.md#deferred-43) — ticket attachments are fully designed/built client-side, gated off behind a flag | Waiting on the upload/signed-URL backend | Attachment backend delivered | M | phase 3 flow gap + REQ-060 (**note:** these are the same underlying fact filed as two BL-### by two different harvest passes — treat as one item when picking it up) |
| [BL-201](../status/backlog.md#minor-115) — center self-onboarding (write-then-masked IBAN) never exercised on a real route | Deferred pending partner-center de-mock generally | Center self-service onboarding prioritized | M | phase 3 flow gap |
---
## Verification note
[BL-245](../status/backlog.md#deferred-43) is not listed above: its own trigger was "phase 5 verification
pass," which this phase executed directly against the code rather than deferring further — see
[decisions.md](../status/decisions.md) for the finding (2 of 3 admin actions confirmed built; only
`FlagConcern` is a genuine, small, unfiled gap).
## What this file is not
This is a record, not a plan — nothing here is re-ranked or newly triggered by this phase. The proposed order
of work that **isn't** deferred is [next-up.md](next-up.md); what the deferrals above cost to keep deferring
is partly covered in [tech-debt.md](tech-debt.md) where a deferral and a debt item are the same underlying
fact (e.g. Redis/BL-220 appears in both, because "not built yet" and "costs more the longer it's not built"
are two different questions about the same gap).
+88
View File
@@ -0,0 +1,88 @@
# Roadmap — where it goes next
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
A **proposal**, not a commitment. The ordering is reasoned and can be overruled; what is not negotiable is
that every item traces to a `BL-###` in [status/backlog.md](../status/backlog.md), so nothing here was
invented — it was triaged first, in phase 4.
**This page marks its own judgement calls.** Two kinds of claim appear below: **recorded** (came from
`backlog.md`, `CLAUDE.md`, `DEPLOY.md`, or a `product/` decision — check the source, not this page's
reasoning) and **proposed** (this phase's own synthesis — the ranking, the grouping into units, the sizing).
Where the two could be confused, it's labeled.
## The four documents
| File | Answers | Ordered? |
| --- | --- | --- |
| [pre-launch.md](pre-launch.md) | What must be true before real money moves | Yes — but it's a **gate**, not a priority list. Everything in it blocks launch; none of it is optional |
| [next-up.md](next-up.md) | What to build next, and why that and not something else | Yes — 5 units, sequenced by the principle below |
| [deferred.md](deferred.md) | What's deliberately not being built, and what would change that | No — a record, picked up by its own trigger, not a schedule |
| [tech-debt.md](tech-debt.md) | What's getting more expensive the longer it waits | No — each item has its own trigger; not a queue |
---
## The sequencing principle
**Recorded** — stated in the phase brief that produced this roadmap, not derived by this phase:
> The product's own promise is trust-first and money-holding. So the order is (1) anything that makes a
> *money or trust* flow lie to a user, (2) anything that makes a built flow unusable, (3) anything that makes
> a mocked flow real, (4) new surface area.
This principle was given, not derived — it isn't this phase's invention. **Applying it to the actual 262 open
backlog items** — which item is a "lie" versus merely "unusable," which five units to carve out of 86 major
items, how to size each — **is this phase's proposal**, in [next-up.md](next-up.md).
## The proposed order — **this phase's proposal**
| # | Unit | Category | Closes (blockers) | Size |
| --- | --- | --- | --- | --- |
| 1 | [Nurse verification stops lying about status](next-up.md#unit-1--nurse-verification-stops-lying-about-verification-status) | 1 — trust lie | BL-010 | L |
| 2 | [Checkout stops lying about money and time](next-up.md#unit-2--checkout-and-the-payment-window-stop-lying-about-money-and-time) | 1 — money lie | BL-006 | M |
| 3 | [Admin RBAC: give seeded admins the claim the code checks for](next-up.md#unit-3--give-the-seeded-admins-the-claim-the-code-already-checks-for) | 2 — unusable | BL-001, BL-002 | M |
| 4 | [De-mock refunds and BNPL](next-up.md#unit-4--de-mock-refunds-and-bnpl-onto-the-server-thats-already-built) | 3 — mocked→real | BL-007, BL-008, BL-009 | L |
| 5 | [De-mock nurse payouts](next-up.md#unit-5--de-mock-nurse-payouts-including-the-one-action-with-no-ui-at-all) | 3 — mocked→real | BL-012, BL-016 | L |
**One tension, stated plainly rather than hidden:** unit 3 (RBAC) has zero prerequisites and unlocks more
downstream work than any other single item — 11 of 14 business areas, per
[implemented.md](../status/implemented.md#cross-cutting-the-rbac-finding). A reader who weighs "unlocks the
most" above "is technically a lie versus merely unusable" would be right to run it first. It's ranked third
here because the stated principle was applied literally. **This is exactly the kind of call this document
exists to make visible and overridable** — see [next-up.md](next-up.md) for the full reasoning per unit,
including two strong candidates (patient/care records, partner-center) that didn't fit the 3-5 slot cap and
are named as the next two in line.
Running in parallel with all five, on its own track: everything in [pre-launch.md](pre-launch.md). None of it
is feature work, so none of it competes with the units above for the same engineering time in the same way —
but none of it is optional before real users arrive, regardless of which unit above ships first.
## What we're not doing, and why — pointer
The standing answer to "what are we not doing" is [deferred.md](deferred.md): **51 items** (43 recorded as a
themed group in `backlog.md`, 8 more filed by severity elsewhere but also carrying deferred status), each with
the trigger that would pull it forward, plus the **8 unbuilt product tables** named individually. None of them
were re-decided here — phase 5's job was to record them faithfully, not re-litigate phase 4's or `product/`'s
calls. The one exception: [BL-245](../status/backlog.md#deferred-43)'s own trigger named this phase directly
("phase 5 verification pass"), so it was executed rather than deferred again — see
[decisions.md](../status/decisions.md) for the finding.
## One item already known for `pre-launch.md`**recorded**, from root CLAUDE.md and DEPLOY.md
The repo **contains live credentials on purpose** — a deliberate pre-launch trade for a demo deployment,
documented in [root CLAUDE.md §6](../../CLAUDE.md) and [DEPLOY.md](../../DEPLOY.md). Before onboarding real
users those values must be rotated and the secret half moved out of git.
One value is load-bearing and must **never** be rotated in place: `Seams:FieldEncryption:Key` and `:HashKey`
decrypt every encrypted column and derive the `PhoneHash` every login looks up. Changing them without a
migration locks every account out — [pre-launch.md §1](pre-launch.md#1-rotate-the-committed-credentials)
spells out what that migration actually requires, since "never change it" and "never be able to change it"
are different problems.
## How to use this page
Pick the next piece of work from the table above, or from [pre-launch.md](pre-launch.md) if a launch date is
what's driving the decision. If neither fits, [tech-debt.md](tech-debt.md) names what's quietly getting more
expensive, and [deferred.md](deferred.md) names what's already been decided against — check there before
proposing something new, since it might already have a recorded reason and a trigger.
+210
View File
@@ -0,0 +1,210 @@
# Next up — the opinionated part
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
Five units, each spec'd enough that a fresh agent session could start one without re-deriving context. The
order applies the sequencing principle from [index.md](index.md#the-sequencing-principle): (1) stop a money
or trust flow from lying to a user, (2) fix a built flow that's unusable, (3) make a mocked flow real, (4) new
surface area. Nothing here conflicts with any `product/` business rule — every unit below is closing an
implementation gap against a rule the business docs already decided, not proposing new behavior.
---
## Unit 1 — Nurse verification stops lying about verification status
**Goal.** Flip the `verification` client domain's mock off for the nurse-facing and public/search-visibility
surfaces, so the app renders the server's real, already-correct status everywhere it matters: the nurse's own
verification screen, the activation checklist's "go live" gate, search's `is_searchable` filter, and the
public trust badge.
**Why now.** Verification *is* the platform's trust promise. Today the mock doesn't show stale data — it
actively contradicts a real, server-computed truth: a server-verified nurse renders as unverified everywhere
the client reads her status ([BL-010](../status/backlog.md#blockers-18)). Under the sequencing principle this
is a category-1 item: a trust flow lying to a user, ranked above anything merely unbuilt.
**What it unblocks.** A trustworthy "go live" CTA; the real search-visibility gate becomes observable instead
of hidden behind fabricated client state; closes the one confirmed production-seam breach
(`nurse/verification/page.tsx` unconditionally imports the mock module regardless of its render gate —
[BL-080](../status/backlog.md#major-86)); establishes the de-mock pattern units 4 and 5 reuse.
**Technical prerequisites.** None blocking. Scope this unit to the nurse-facing and public halves only — the
admin approve/reject/suspend actions stay behind
[BL-082](../status/backlog.md#major-86) (the routes don't exist yet, independent of RBAC), and are naturally
picked up alongside unit 3 once both the routes and RBAC exist.
**Affected flows.** [nurse-verification](../flows/nurse-verification.md) (mocked → partial),
[onboarding-nurse](../flows/onboarding-nurse.md), [search-and-discovery](../flows/search-and-discovery.md)
(real gate becomes visible), [public-front-door](../flows/public-front-door.md) (trust badge becomes real).
**Rough size:** L — a 100%-mocked, 14-operation domain with real DTO reconciliation.
**Backlog ids closed:** [BL-010](../status/backlog.md#blockers-18) (blocker), BL-080, BL-081, BL-084, BL-190,
BL-191, BL-192, BL-214. Worth fixing in the same pass, same trust-truth theme, different root cause:
[BL-098](../status/backlog.md#major-86) (an unverified nurse's profile is still readable and asserts
"verified" anonymously by id). BL-083 (admin catalog UI for suspend/scan-expiring) is explicitly out of scope
— it's an admin surface, deferred with unit 3.
---
## Unit 2 — Checkout and the payment window stop lying about money and time
**Goal.** Fix the cluster of checkout-and-payment defects that tell a customer or nurse something false: the
timezone-less deadline that can render a 30-minute window as ~4 hours and expire silently
([BL-006](../status/backlog.md#blockers-18)), the checkout summary's missing `nurseVerified` field
(BL-058), the payment confirmation that can't deep-link to its booking and hides the tracking code
(BL-059), and the invoice screen's client-computed row that's wrong by exactly the VAT amount instead of
showing the server's real total (BL-060). Close the two related blind spots while in the same code: no
payment-history read (BL-061) and no escrow-ledger read surface at all (BL-062).
**Why now.** Category-1 by the letter of the sequencing principle: a countdown that shows hours when minutes
remain, and an invoice wrong by a fixed, known amount, are exactly "a money/trust flow lying to a user" —
not a missing feature, a false one.
**What it unblocks.** The one flow every paying customer touches becomes honest; removes a standing violation
of the repo's "client never computes money" rule ([BL-060](../status/backlog.md#major-86), called out
directly in [decisions.md](../status/decisions.md)); a trustworthy countdown is also a quiet prerequisite for
ever safely flipping [BL-005](../status/backlog.md#blockers-18) (the dead card-payment rail) to a real PSP —
no reason to give a real gateway a lying clock.
**Technical prerequisites.** None. Every item here is a DTO addition or a client display fix; nothing depends
on RBAC or a mock flip elsewhere.
**Affected flows.** [checkout-and-payment](../flows/checkout-and-payment.md),
[booking-request](../flows/booking-request.md) (shares the deadline field).
**Rough size:** M. Mostly additive DTO fields plus client display fixes; budget extra time for BL-006 since
`DateTime``DateTimeOffset` touches a shared type used beyond this one screen.
**Backlog ids closed:** [BL-006](../status/backlog.md#blockers-18) (blocker), BL-042, BL-058, BL-059, BL-060,
BL-061, BL-062.
---
## Unit 3 — Give the seeded admins the claim the code already checks for
**Goal.** `DynamicPermissionService.CanAccess` grants only the literal role `admin`; no seeded account holds
it, so every seeded admin 403s on every `DynamicPermission`-gated controller
([BL-001](../status/backlog.md#blockers-18)). Either broaden the check to also honor the roles actually
seeded (`super_admin`/`finance`), or add the missing step that writes a `DynamicPermission` claim when those
roles are granted — and seed at least one account holding the literal role so the fix is testable out of the
box ([BL-002](../status/backlog.md#blockers-18)).
**Why now.** Category-2 under the stated principle (it makes a built, real, already-coded admin backoffice
completely unusable, rather than lying about anything) — but it is the single highest-leverage fix in the
whole backlog: one root cause independently degrades **11 of 14 business areas**
([implemented.md](../status/implemented.md#cross-cutting-the-rbac-finding)). Worth naming the tension
directly: nothing stops this from running before units 1-2 except the sequencing principle's letter — it has
zero prerequisites of its own, same as they do. It's ranked third here because the principle is applied
literally; a reader who weighs "unlocks the most downstream work" more heavily than the letter of the
principle would be right to run this first, and that's exactly the kind of overrule this document exists to
make easy.
**What it unblocks.** Admin verification review (once BL-082's routes exist), refund preview/approve/reject
(unit 4), payout `mark_failed` and batch processing (unit 5), reviews ever leaving moderation
([BL-017](../status/backlog.md#blockers-18)), ticket assignment, cancellation-policy admin edits, the admin
user/role directory ([BL-029](../status/backlog.md#major-86)) — essentially every admin-side item in units
4-5 and in [deferred.md](deferred.md#deferred-at-the-item-level--gated-behind-a-console-that-isnt-prioritized-yet).
**Technical prerequisites.** None.
**Affected flows.** [admin-backoffice](../flows/admin-backoffice.md) (mocked → real), plus the admin half of
nurse-verification, cancellation-and-refunds, messaging-tickets, nurse-earnings-and-payouts, partner-center,
reviews, booking-request, booking-lifecycle-evv, and nurse-catalog-and-pricing — the 11 areas
[implemented.md](../status/implemented.md#cross-cutting-the-rbac-finding) names.
**Rough size:** M. The fix itself is small and surgical; the size is in re-verifying admin surfaces across 11
areas afterward, since several have never been reachable long enough to know what else breaks once they 200.
**Backlog ids closed:** [BL-001](../status/backlog.md#blockers-18), BL-002 (both blockers). Directly unblocks
without itself closing: BL-017, BL-029, BL-050, BL-082, BL-116, BL-117, BL-125, BL-163.
---
## Unit 4 — De-mock refunds and BNPL onto the server that's already built
**Goal.** Flip the `refunds` and `bnpl` client domains off their mocks. Both currently read a retired/wrong
bookings-mock store that would break, not just look stale, on a naive flip: refunds would render a
10000%-scale refund amount ([BL-009](../status/backlog.md#blockers-18)); the BNPL wizard 404s for every real
booking id ([BL-008](../status/backlog.md#blockers-18)). Seed a real `Bnpl` payment-gateway row so BNPL calls
stop 400ing ([BL-007](../status/backlog.md#blockers-18)). Fix the DTO drift the flow atlas already catalogued
— BL-047 (refund channel pinned to a fixture id), BL-048 (client discards six real fee-split fields), BL-049
(three enum mismatches) — *before* flipping, not after, exactly the order phase 3/4's analysis already sets up.
**Why now.** Both are money flows. Both are among the five domains
[implemented.md](../status/implemented.md) flags as most at risk of breaking, not just showing stale data, on
a flip — the reconciliation work is already scoped by the flow docs, so this is executing a known plan, not
discovering one.
**What it unblocks.** A customer sees a real cancellation/refund preview instead of a fabricated one; the
platform's second payment rail (BNPL) becomes exercisable end-to-end locally for the first time.
**Technical prerequisites.** None to start the customer-facing halves. The admin sides —
[BL-050](../status/backlog.md#major-86) (refund preview/approve/reject route doesn't exist) and the
BNPL-adjacent admin surfaces — stay deferred regardless of this unit, consistent with their own
already-recorded "gated on a console" status in [deferred.md](deferred.md).
**Affected flows.** [cancellation-and-refunds](../flows/cancellation-and-refunds.md) (mocked → partial),
[bnpl-installments](../flows/bnpl-installments.md) (mocked → partial).
**Rough size:** L. Two domains, DTO reconciliation on both, a gateway-seeding change. Worth solving
[BL-131](../status/backlog.md#deferred-43) (nothing fires the BNPL webhook locally) in the same pass — without
it, a real BNPL order can be initiated but never observed reaching `settled` in dev.
**Backlog ids closed:** [BL-007](../status/backlog.md#blockers-18), BL-008, BL-009 (all three blockers),
BL-047, BL-048, BL-049, BL-052, BL-053, BL-054, BL-126, BL-127, BL-128, BL-129, BL-130.
---
## Unit 5 — De-mock nurse payouts, including the one action with no UI at all
**Goal.** Flip the `payouts` client domain to real. Build the client UI for `process` — the irreversible step
that actually executes a payout batch, which currently has **zero client caller anywhere in the app**. Fix
`DeriveEarningsState`'s wrong "paid" logic, which marks a booking paid whenever it merely has a payout *link*,
regardless of that payout's actual status, and reconcile the four earnings buckets against the ledger
([BL-012](../status/backlog.md#blockers-18)).
**Why now.** This is the flow where a nurse's trust in ever actually getting paid lives, and it currently
tells her she's been paid when the payout may not have succeeded — a money lie hiding inside a mocked flow.
It's category-3 (de-mock) and category-1 (stop a lie) at the same time, which is why it's grouped with the
other de-mock units rather than ranked above unit 3 on category-1 grounds alone: fixing the lie and fixing the
mock are the same commit here, unlike units 1-2 where the lie could be fixed without a mock flip.
**What it unblocks.** A truthful earnings/payout history for nurses; the one missing admin action (batch
processing); [BL-076](../status/backlog.md#major-86) (`mark_failed` has no client op) becomes reachable once
this unit and unit 3 (RBAC) have both landed.
**Technical prerequisites.** Unit 3 (RBAC), for the admin-side `process`/`mark_failed` actions specifically —
the nurse-facing earnings/history half has no such dependency and can proceed independently. This is the one
unit in this list with a real cross-unit dependency; sequence its admin half after unit 3 regardless of where
the two land in a sprint.
**Affected flows.** [nurse-earnings-and-payouts](../flows/nurse-earnings-and-payouts.md) (mocked → partial).
Worth fixing [BL-016](../status/backlog.md#blockers-18) in the same pass — a booking swept to `missed` never
reaches a payable state today, so it can never enter a payout batch regardless of this unit's other fixes.
**Rough size:** L. Mock removal, a wholly new admin-action UI, a state-derivation bug fix, and a
ledger-reconciliation check.
**Backlog ids closed:** [BL-012](../status/backlog.md#blockers-18), BL-016 (both blockers), BL-073, BL-074,
BL-076, BL-179, BL-180, BL-181, BL-182, BL-183, BL-184. Unblocks without closing: BL-075.
---
## Considered, and deliberately not in this list
- **[BL-013](../status/backlog.md#blockers-18) — partner-center (100% mocked, zero tenancy).** Just as mocked
as the four domains above, but structurally different: 5 core routes
(`centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`) **don't exist
server-side at all**. This isn't a de-mock, it's a small backend phase followed by a de-mock — a bigger unit
than the others, and one where the "flip the mock" playbook from units 1/4/5 doesn't directly apply. Next
in line after these five.
- **[BL-011](../status/backlog.md#blockers-18) — patient/care records (100% mocked, server real).** Same
shape as units 1, 4, and 5 (server real, client mocked, DTO drift already catalogued) and just as legitimate
a candidate — it didn't fit this document's 3-5 cap, not a judgment that it matters less. Directly next in
line if a sixth unit is wanted.
- **[BL-096](../status/backlog.md#major-86) — tier (c) of the public front door (guest search + public nurse
profiles).** New surface area (category 4), and explicitly named in the phase brief as ranking below making
the authenticated flows honest regardless of visibility. Also blocked on a decision this document can't
make: an explicit privacy sign-off on the nurse-profile field list, plus REQ-066/067 delivered. Stays in
[deferred.md](deferred.md), not here.
+177
View File
@@ -0,0 +1,177 @@
# Pre-launch — the hard gate before real users touch this
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
Everything below must be true before a real user with real money uses the platform. This is a gate, not a
backlog — items here are not ranked by convenience, they're ranked by what "real user, real money" requires.
Four things put you here: **committed credentials**, **mocked money/identity rails**, **Development running in
production**, and **legal/tax code that isn't finished**. A fifth — **every Phase 4 blocker** — is included
because "blocker" means *the product is wrong or unusable*, and that bar applies with or without real money.
---
## 1. Rotate the committed credentials
The repo contains live credentials **by deliberate pre-launch decision**
([root CLAUDE.md §6](../../CLAUDE.md), [BL-003](../status/backlog.md#blockers-18)): DB `sa`, both
JWE/field-encryption key halves, Kavenegar, Neshan, Finnotech, and the Telegram bot token. All of them must be
rotated and the secret half moved out of git before onboarding real users.
| Credential | Rotation is | What it takes |
| --- | --- | --- |
| DB `sa` password | a straight rotation | Change on the SQL Server, update `appsettings.Production.json`. No data migration. |
| Kavenegar / Neshan / Finnotech API keys | a straight rotation | Issue new keys with each vendor, update config. No data migration. |
| Telegram bot token | a straight rotation | Regenerate via BotFather, update `docker-compose.yml` (`otp-relay.environment`) and the appsettings copy — the two must match ([DEPLOY.md](../../DEPLOY.md)). |
| `IdentitySettings:SecretKey` / `Encryptkey` (JWE) | a straight rotation | Safe to rotate any time — the only side effect is signing everyone out. |
| **`Seams:FieldEncryption:Key` / `:HashKey`** | **not** a rotation — see below | **Never** change without the migration in the next section. |
### The one item that is a project, not a config edit
`Seams:FieldEncryption:Key` decrypts every encrypted column (phone numbers, addresses, IBANs, clinical
notes); `:HashKey` derives `users.PhoneHash`, which every login looks up. Changing either value in place makes
all existing encrypted data unreadable and locks every account out — this is stated as a permanent invariant
in [DEPLOY.md](../../DEPLOY.md) and [decisions.md](../status/decisions.md), and it is correct **as long as the
key never needs to change**. It will need to change eventually (security incident, key-management policy,
routine hygiene), and at that point it is its own migration project:
1. Decrypt every affected column with the old key, in place, inside a maintenance window or behind a
dual-key read path.
2. Re-encrypt with the new key.
3. Recompute `PhoneHash` for every user with the new `HashKey`, since it's a one-way derivation — there is no
way to "re-key" a hash without the plaintext.
4. Decide the cutover strategy: a maintenance-window rewrite (simplest, requires downtime sized to the data
volume) versus a dual-read migration (no downtime, more code to write and then delete).
**Rough size: large.** Nothing about this is hard, but nothing about it is a config edit either — track it as
its own piece of work with its own testing pass, not a line item inside a generic "rotate credentials" task.
---
## 2. Still-mocked production seams
Exactly one `Seams:*:Provider` is set anywhere in the repo — `Seams:Sms:Provider = telegram`, and the Telegram
relay is [documented as the pre-launch demo rail](../../CLAUDE.md), not a production SMS gateway. Every other
rail runs on its mock, in both development and the deployed stack
([docs/flows/index.md](../flows/index.md#mock-vs-real-map--server-seams)):
| Rail | Mock today | Real adapter | What flipping takes |
| --- | --- | --- | --- |
| SMS / OTP | `LoggingSmsSender` (real path is `telegram`, a demo relay) | `KavenegarSmsSender` — already coded | Set `Seams:Sms:Provider = kavenegar` + real API key/sender. Attempting `smsir`/`ghasedak` throws at startup by design — those adapters don't exist ([BL-235](../status/backlog.md#deferred-43)). |
| Card PSP | `MockPaymentProvider` (redirects to a non-existent host — [BL-005](../status/backlog.md#blockers-18)) | `ZarinPalPaymentProvider` — already coded | Real merchant credentials from a licensed PSP — which requires e-namad (§5) — plus a reachable webhook endpoint. **The selector is a plain string check, not an enum** — any non-`mock` value silently selects ZarinPal, so a typo in this setting picks a real gateway by accident. |
| Settlement split (تسهیم) | `MockSettlementSplitProvider` | `ProviderSettlementSplitProvider` — already coded | Real settlement-provider credentials. |
| BNPL | `MockBnplProvider` (no gateway row even seeded — [BL-007](../status/backlog.md#blockers-18)) | `SnappPayBnplProvider` / `DigipayBnplProvider` — already coded | Real provider credentials, a seeded `Bnpl` gateway row, and a webhook receiver reachable from the provider (nothing fires it in dev today — [BL-131](../status/backlog.md#minor-115)). |
| Object storage | `LocalDiskObjectStorage` (`GetUrl` returns a `file://` URI no browser can fetch — [BL-024](../status/backlog.md#major-86)) | `S3ObjectStorage` — already coded | A real bucket + credentials. Flipping this also happens to fix BL-024, since S3's `GetUrl` returns an actual HTTP(S) URL. |
| Geocoder | `MockGeocoder` (server) / keyless grid stand-in (client, `NEXT_PUBLIC_NESHAN_KEY` unset — [BL-025](../status/backlog.md#major-86)) | `NeshanGeocoder` — already coded | A real Neshan API key on **both** sides — the client key is separate from any server-side one. |
| **Bank transfer (payouts)** | `MockBankTransferProvider` | **none — moves no money** | Nothing to flip. This is the one rail with no real adapter written at all; even after every other rail above goes real, **payouts still cannot move real money** until a real bank-transfer integration is built from scratch. Size this as new work, not a config change. |
| e-invoicing (مودیان) | `MockMoadianClient` | `MoadianClient` — already coded | Token-exchange refresh and the Moadian signing certificate are not wired ([BL-236](../status/backlog.md#deferred-43)) — both are deploy-time actions once real credentials exist, not code changes. |
| Shahkar · e-KYC · IBAN ownership | mocks | Finnotech adapters — already coded | Real Finnotech credentials. |
| Credential (MoH/INO) · eNamad · review moderation | mock, always | **none — deliberate** | These stay manual/mocked by product decision, not a launch gap — see [deferred.md](deferred.md). |
| Search | *(no mock)* | `SqlNurseSearch` | Already real. Not a launch item. |
**Net: of 10 rails with a real adapter, 9 are a credentials-and-config flip; 1 (bank transfer) doesn't have a
real adapter yet.** Two rails throw at startup rather than silently falling back to the mock if misconfigured
(SMS providers other than Kavenegar, and any `Search:Backend` other than `sql`) — that fail-fast behavior is
intentional and should stay.
---
## 3. Running as Development in production
[DEPLOY.md](../../DEPLOY.md) documents this as a **deliberate trade** for a demo deployment among trusted
people. Every consequence below stops being acceptable the moment strangers can reach the site:
| Consequence | Why it matters | Fix |
| --- | --- | --- |
| The developer exception page is public | Any unhandled 500 on `api.balinyaar.ir` returns a stack trace and configuration detail to the caller | Switch to `Production` environment |
| `GET /api/v1/dev/last_otp/{phone}` is live and anonymous ([BL-004](../status/backlog.md#blockers-18)) | Anyone who knows a registered phone number can read its login code and sign in as that user — **the single biggest exposure today** | Same — the endpoint is Development-gated in code; it disappears once the environment flips |
| Swagger served at `/swagger` | Full API surface exposed | Same |
| Seeders re-run on every boot; migrations auto-apply on boot | Safe today (idempotent), but not how a production release process should work | Run migrations as a one-shot command instead (`docker compose run --rm api dotnet Baya.Web.Api.dll migrate`) |
| gRPC reflection enabled; the demo `bookings/convert` payment-capture simulator is wired | Extra attack surface + a fake-payment code path reachable in a real deployment | Same — disappears with the environment flip |
**What must change**, per [DEPLOY.md "Going to Production"](../../DEPLOY.md#going-to-production-later):
1. Set `ASPNETCORE_ENVIRONMENT: Production` in `docker-compose.yml`.
2. Create `appsettings.Production.json` (it does not exist yet) with real `IdentitySettings:SecretKey` /
`Encryptkey``StartupSecretsGuard` rejects any value containing `not-for-production` outside Development,
so today's dev keys refuse to boot in Production by design. Keep `Seams:FieldEncryption` byte-identical to
the Development file (§1).
3. Run migrations as a one-shot instead of on boot.
4. Swap the OTP rail to `kavenegar` (§2) — the Telegram relay broadcasting every code to a fixed recipient
list stops being acceptable once someone outside that list can request a code.
---
## 4. Every Phase 4 blocker
"Blocker" means *the product is wrong or unusable* — that bar holds regardless of whether money is involved.
All 18 are pre-launch gate items; full detail and code traces are in
[backlog.md](../status/backlog.md#blockers-18).
| ID | One line | Rough effort |
| --- | --- | --- |
| BL-001 | Admin RBAC grants only the literal role `admin`; every seeded admin 403s everywhere | M — see [next-up.md](next-up.md) unit 3 |
| BL-002 | No seeded account holds the literal `admin` role, so even a fixed BL-001 is untestable out of the box | S — bundled with BL-001 |
| BL-003 | Committed live credentials, unrotated | See §1 |
| BL-004 | Dev-only OTP-read endpoint live on the production domain | See §3 |
| BL-005 | Card payment is a dead end everywhere — mock redirects to a non-existent host, no local webhook | M — see [next-up.md](next-up.md) unit 2 |
| BL-006 | Booking deadlines ship without a timezone; a 30-min window can render as ~4h and expire silently | S — see [next-up.md](next-up.md) unit 2 |
| BL-007 | No `bnpl` gateway row ever seeded; every BNPL call 400s | S — see [next-up.md](next-up.md) unit 4 |
| BL-008 | BNPL wizard's mock cross-imports the bookings-mock store; dead end for every real booking id | M — see [next-up.md](next-up.md) unit 4 |
| BL-009 | Refunds mock reads a retired store and would show a 10000%-scale refund on flip | M — see [next-up.md](next-up.md) unit 4 |
| BL-010 | Verification is 100% client-mocked; a server-verified nurse renders unverified everywhere | L — see [next-up.md](next-up.md) unit 1 |
| BL-011 | Patient/care records are 100% mocked; real DTO shapes would break on a naive flip | M |
| BL-012 | Nurse payouts: mock hides 4 working endpoints; no UI for the irreversible "process batch" step; "paid" status computed wrong | M — see [next-up.md](next-up.md) unit 5 |
| BL-013 | Partner center 100% mocked, zero real tenancy; 5 core routes don't exist server-side | L |
| BL-014 | Search results are index rows, not de-duplicated nurses; trust dossier mocked; unverified nurse profile page asserts "verified" | M |
| BL-015 | Editing an address silently nulls recipient name/phone/postal code on every save | S |
| BL-016 | A booking swept to `missed` never reaches a payable state | M |
| BL-017 | Reviews can never leave moderation on the live stack (sits behind BL-001; `AutoApproveClean` unset) | S once BL-001 lands |
| BL-018 | Zero option groups outside Development — every builder collapses to two steps, deployed | M |
---
## 5. Legal / tax items that are code-side
From [product/business/13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md),
confirmed the platform's weakest business area in [implemented.md](../status/implemented.md):
- **Terms & Privacy still ship placeholder legal copy behind a draft banner** — flagged for human/legal review
since ui-phase-3 and still unreviewed ([BL-097](../status/backlog.md#major-86)). Swapping in
counsel-reviewed copy (and removing the banner) is a pre-launch item, not a nice-to-have.
- **مودیان (e-invoice) integration is mocked past the point the business doc calls MVP.** The `invoices` model,
VAT split, and reference fields exist correctly (§ commission/VAT model below), but the Moadian
token-exchange refresh and signing certificate are unwired ([BL-236](../status/backlog.md#deferred-43)), and
seeded partner-center invoices carry null Moadian reference/PDF fields
([BL-200](../status/backlog.md#major-86)). These are deploy-time actions once real Moadian credentials
exist — but they don't exist yet, and مودیان readiness is explicitly named as MVP scope in the business doc.
- **The commission/VAT split itself is already correct and does not need code work**: platform commission is
15% of gross, VAT is 10% of the *commission only* — this became the single source of truth in
refinement-phase-3 ([decisions.md](../status/decisions.md)) and matches the business doc's nurse-as-
taxable-seller / platform-as-commission-seller model. The one open defect is a **display** bug, not a model
bug: the invoice screen computes a client-side row that's wrong by exactly the VAT amount instead of
rendering the server's real total ([BL-060](../status/backlog.md#major-86) — folded into
[next-up.md](next-up.md) unit 2).
- **`partner_centers` as merchant-of-record is the business doc's launch-critical legal vehicle** ("the fast,
legal go-to-market is to partner with already-licensed centers") — and it is 100% mocked with zero real
tenancy today ([BL-013](../status/backlog.md#blockers-18)). Until this is real, there is no functioning
legal invoice-issuer path for a booking routed through a partner center.
- **e-namad is a business/licensing prerequisite, not a code task, but it gates one**: per
[legal-landscape.md](../../product/research/legal-landscape.md), a monetized Iranian site needs e-namad to
obtain an online payment gateway at all — meaning the real ZarinPal flip in §2 cannot complete until the
launch entity holds e-namad, independent of anything in this codebase.
- **VAT-exempt-or-0% is a live legal question, not yet a code gap**: the business doc notes medical services'
own VAT treatment is unconfirmed in Iran and asks for the rate to stay config-driven so it can land either
way. Confirm with an Iranian tax advisor before launch, then confirm the rate is read from a
`platform_configs` row (the repo's config-is-rows convention) rather than a hardcoded constant — this
wasn't independently re-verified this phase and is worth a direct check before relying on it.
---
## Not in this file
Everything here is a **gate**, not a roadmap. What comes after the gate — the highest-leverage next units of
work, what's deliberately deferred and why, and what technical debt is accruing — is
[next-up.md](next-up.md), [deferred.md](deferred.md), and [tech-debt.md](tech-debt.md) respectively. Several
pre-launch items and next-up items overlap on purpose (e.g. BL-006, BL-060): the same fix both closes a
backlog item worth doing regardless of launch timing, and clears a launch gate.
+142
View File
@@ -0,0 +1,142 @@
# Tech debt — what it costs to not pay this yet
> Last verified: 2026-08-02 against commit `cd8144e`. Populated by phase 5 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
Debt is different from [backlog.md](../status/backlog.md): a backlog item blocks a specific flow. A debt item
doesn't block anything today — it raises the cost of *everything that comes after it*. The phase brief named
six candidates to assess (items 2, 3, 4, 5, 7, 8 below); two of those (3, 4) turned out less severe than the
framing assumed once checked against the code, and this phase adds two more (1, 6) found while doing that
checking. Each entry below states what it costs now, what it costs at meaningfully more usage, and the
trigger that should cause it to get paid.
---
## 1. No contract-generation pipeline — client DTOs hand-drift from the server
**What it is:** `docs/integration/openapi/swagger.v1.json` is a regenerated, dated snapshot, but nothing turns
it into client types. `client/package.json` has no `openapi-typescript`/NSwag/orval step — every service
domain's TypeScript types are hand-written and hand-kept in sync with the real server DTOs.
| Now | At meaningfully more usage |
| --- | --- |
| The cost has already been paid twice, expensively: phase 2 found **24 phantom client-side endpoints** nobody had checked against swagger; phase 3 found **34 client seam operations that would 404 on a mock→real flip** and several DTO shape mismatches severe enough to *break*, not just show stale data (verification, refunds, payouts, patient-records, partner-center — see [implemented.md](../status/implemented.md)). Both discoveries took a dedicated full-session audit to surface. | Every future domain repeats this discovery cost, and nothing stops a **new** drift from being introduced tomorrow — there is no CI check comparing client types to the live contract. This compounds with domain count, not with traffic: the more domains behind mocks, the more of these audits eventually needed. |
**Trigger to pay it:** don't wait for the next silent break — the next 2-3 units in [next-up.md](next-up.md)
are exactly "flip a mocked domain to real." Wiring a generated-types step (even scoped to just the domains
being de-mocked next, not a big-bang rewrite) pays for itself on the very next flip.
**Rough size: M.**
## 2. Single-instance in-process scheduler
**What it is:** weekly payout-batch generation, and (once cron'd) the credential-expiry scan and EVV no-show
sweep, all run as in-process jobs with no distributed lock. Correct today because exactly one API instance
runs. Same root cause as [BL-220](deferred.md#scale-later-infrastructure) (Redis not deployed).
| Now | At meaningfully more usage |
| --- | --- |
| Zero cost — one instance, no race. | A rolling deploy that briefly runs two instances already risks a **duplicate weekly payout batch** — real money, generated twice. Horizontal scaling for any other reason (traffic, availability) can't happen without this being solved first; it's a hidden prerequisite baked into "just add another container." |
**Trigger to pay it:** the moment a second instance is even considered, for any reason — not just load.
**Rough size: M** (a distributed lock via Redis, per BL-220's own scoping).
## 3. Testing-convention asymmetry between the two projects
**Reassessed — this is not a raw-count gap.** Server test files (119, spanning `Baya.Test.Api` WebApplication-
Factory integration tests and `Baya.Test.Foundation` handler/unit tests) and client test files (125, Jest) are
close in count, and money-path server coverage is genuinely strong: Payments, Bookings, BNPL, Payouts, and
Refunds each have dedicated API-integration **and** handler-level test files. The real asymmetry is in
**enforcement**:
- [docs/rules/server/cqrs.md](../rules/server/cqrs.md) makes testing a mandatory step for every new
feature: "Add handler unit tests **and** at least one `WebApplicationFactory` integration test for the
area: happy path 200, unauthenticated 401, validation 400."
- Root [CLAUDE.md](../../CLAUDE.md)'s own client gate is reactive, not mandatory: "`npm run test:ci`
**if you touched a tested component**." New client code has no enforced testing bar at all.
| Now | At meaningfully more usage |
| --- | --- |
| 125 client tests exist, but — consistent with the flow atlas's own mock-vs-real accounting — coverage almost certainly concentrates on the domains that were real early and thins out on the ones still mocked. | As the mocked domains in [next-up.md](next-up.md) flip to real, the newly-real client code lands with no enforced test the way a newly-shipped server handler always does. Regressions there have no automated net; the flow atlas's manual, one-session verification (§4 below) is the only thing that has ever caught them. |
**Trigger to pay it:** natural to pair with each domain de-mock in [next-up.md](next-up.md) rather than run
as its own initiative — write the rule once, backfill tests as each domain is touched anyway.
**Rough size: S** to write the convention, **M per domain** to backfill.
## 4. No automated test crosses the client↔server boundary
**Reassessed — "absence of E2E tests over the money paths" overstates it.** The money paths have real
automated coverage at the handler and API-integration layers (§3). What's genuinely missing is anything that
drives a **real browser against a real running API** — no Playwright, no Cypress, nothing in
`client/package.json`'s scripts beyond Jest. That gap is exactly why phase 3's flow atlas had to boot the
whole stack and manually click/curl through all 23 flows to get a trustworthy status — a deliberate,
one-session, human/agent-driven substitute for automated E2E.
| Now | At meaningfully more usage |
| --- | --- |
| Absorbed as a one-time cost per audit — expensive (a full session), but infrequent. | This cost doesn't scale with traffic, it scales with **how often you need to trust a full-stack claim** — every future audit like phase 3's repeats the same manual walkthrough from scratch, because nothing keeps the previous one's findings mechanically re-checkable. |
**Trigger to pay it:** before the next full-stack audit is needed, or the first time a money-path regression
reaches production undetected — whichever comes first. **Rough size: M** — the server side is already well
tested, so this only needs to close the browser↔API gap: a handful of Playwright specs over the critical path
(login → search → book → pay → cancel), not a rewrite of anything existing.
## 5. Search without Elasticsearch
`SqlNurseSearch` over `nurse_search_index` is real, correct, and — per
[decisions.md](../status/decisions.md) — the deliberate MVP implementation, same item as
[BL-221](deferred.md#scale-later-infrastructure).
| Now | At meaningfully more usage |
| --- | --- |
| Fine at MVP data volume; no measured strain. | A wide denormalized SQL index degrades on filter/sort/paging combinations well before a purpose-built search engine would. |
**Trigger to pay it:** measured latency/throughput strain on `nurse_search_index` — not a date.
**Rough size: L.**
## 6. The ESLint unused-vars gate is a no-op
[BL-250](deferred.md#cleanup-of-convenience-and-data-integrity-hygiene): the config patches an export path
that doesn't carry the rule, so `@typescript-eslint/no-unused-vars` never actually runs — despite
`client/CLAUDE.md`'s own golden rule 11 claiming it does.
| Now | At meaningfully more usage |
| --- | --- |
| A safety net that looks present but isn't — dead code accumulates invisibly, with no signal to anyone that it's happening. | The longer this stays broken, the more has silently piled up by the time someone finally looks — this is a debt that compounds purely with time and commit count, independent of traffic or scale. |
**Trigger to pay it:** a dedicated infra task (this item's own stated trigger). **Rough size: S** — it's a
config-path bug, not a redesign.
## 7. Two remaining raw-state admin forms
[BL-217](../status/backlog.md#minor-115): `GrantRoleDialog` and `PreviewBatchDialog` are the only two
survivors of an otherwise-complete app-wide react-hook-form migration.
| Now | At meaningfully more usage |
| --- | --- |
| Trivial — two isolated components. | Still trivial. This is the one item on this list that genuinely doesn't get worse with scale; it's listed only because it's a rough edge for anyone reading the code expecting one form pattern everywhere. |
**Trigger to pay it:** whenever either dialog is next touched for an unrelated reason — not worth a dedicated
pass. **Rough size: S.**
## 8. Windows-generated client lockfile
Documented directly in [DEPLOY.md](../../DEPLOY.md#known-wrinkle-the-client-lockfile-is-windows-generated):
`client/package-lock.json`, generated on Windows, omits wasm32-only optional packages Linux's npm wants,
so a bare `npm ci` fails in the container build. Absorbed today by a `npm install --package-lock-only` step
baked into the client Dockerfile before `npm ci`.
| Now | At meaningfully more usage |
| --- | --- |
| Zero operational cost — the workaround runs on every image build without incident. | Still zero cost at scale; this is environment debt, not scale debt. The real risk is a future edit: anyone who "cleans up" the Dockerfile without knowing *why* that line is there reintroduces a build failure that only reproduces on Linux/CI, not on the Windows machine that likely made the edit. |
**Trigger to pay it:** DEPLOY.md already gives the exact fix — regenerate the lockfile on Linux once and
commit it, then delete the workaround line. **Rough size: S.** Cheapest item on this list to close permanently.
---
## What didn't make this list
[`archive/clarify-chain/README.md`](../../archive/clarify-chain/README.md)'s own diagnosis (260+ markdown files, ~10 rule sources, contract drift) is the
documentation debt this entire phase chain exists to retire — phases 0-4 already paid most of it down, and
phase 6/7 finish the job. It isn't repeated here because it isn't *code* debt, and because tracking it twice
would just be two ledgers again, the exact problem this chain was created to end.
+184
View File
@@ -0,0 +1,184 @@
# Client auth
Cookies, the session lifecycle, silent refresh, `RoleGuard`, and the middleware gate — plus an explicit
statement of what is *not* a security boundary.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The credential is phone-OTP
There is **no username/password anywhere**, and email is never a login key. The flow lives in
`src/components/auth/` (`LoginFlow``PhoneStep``OtpStep`) at `/login`, over the `services/auth` domain
(`requestOtp` / `verifyOtp` / `refresh` / `logout` / `getMe` / `selectRole`).
`useWebOtp` is the WebOTP autofill seam — on a supporting browser the code fills itself from the SMS.
---
## 2. The two cookies
| Cookie | Constant | TTL | Written by |
| --- | --- | --- | --- |
| `access_token` | `COOKIE_NAMES.ACCESS_TOKEN` | 15 min | `persistAuthTokens` (`lib/auth/session.ts`) — via `useVerifyOtp`, `useRefresh`, `useSelectRole`, and the fetch-layer silent refresh |
| `refresh_token` | `COOKIE_NAMES.REFRESH_TOKEN` | 7 days | same |
Lifecycle:
- **Written** by `persistAuthTokens` after verify / refresh / select-role, which also dispatches `LOG_IN` so
`AuthContext` stays in sync without a reload.
- **Deleted** by `useLogout()` — the single logout path: revoke the server session, clear both cookies,
`LOG_OUT`, drop the `/me` cache, redirect. Also cleared by `clientFetch` when a 401 can't be recovered.
- **Read** server-side by `serverFetch` / `getServerAuthState` via `getServerCookie`; client-side by
`clientFetch` via `getClientCookie`, to attach `Authorization: Bearer`.
The `refresh_token` cookie's 7-day TTL is **shorter** than the server session default (30 days). Aligning the
cookie `maxAge` to the server's `refreshExpiresAt` is a known follow-up, not a bug to fix blind.
All cookie access goes through the manager — see [services.md](services.md) §6.
---
## 3. Session state
`AuthContext` (`src/context/auth/`) carries `SessionUser { id?, phone, roles: AppRole[] }`.
The root layout resolves the session **on the server** with `getServerAuthState()` (`lib/auth/server.ts`),
which reads the `access_token` cookie and checks the JWT `exp` via the shared `isTokenAlive`
(`lib/auth/token.ts`), and passes it to `<AuthProvider initialState={…}>`. So the very first render already
knows whether the user is authenticated.
**Roles are not derivable from the opaque JWE token server-side.** The server therefore seeds
`isAuthenticated` only; `useSessionRoleSync()` — mounted in the `(private-routes)` layout — hydrates
`currentUser.roles` from `/me`. That is the single source the shells read via `useActorRole()`.
`invalidateQueries(authKeys.me())` runs on login; `removeQueries(authKeys.all)` on logout.
---
## 4. Where the user lands: the role router
After a successful verify, `RoleRouter` reads `/me` and navigates — customer → the family app, nurse → the
nurse app, admin → the console, empty roles → `/select-role`. It shows the branded splash while `/me` loads,
**so the wrong shell never flashes.**
The decision itself is the **pure, unit-tested** `resolveRoleDestination(me, intendedRole)` in
`services/auth/routing.ts`. That function is the single "which app" source — every other place that needs to
send a user to their home calls it rather than re-deriving.
The middleware owns the auth *gate*; the router only decides which app.
---
## 5. `RoleGuard`: resolved vs. pending
Every private shell — `(customer)`, `nurse`, `admin`, `partner` — wraps its layout in **`RoleGuard`**.
It exists because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
nurse/admin role"**. A `/me` in flight used to fall through the `DEFAULT_ROLE = customer` fallback and flash
a nurse the customer app — or strand them there if `/me` failed.
`RoleGuard` reads **`useRoleHydration()`** (`services/auth`), a discriminated `loading | error | ready` over
`useMe`:
| State | Behaviour |
| --- | --- |
| **loading** | A neutral brand splash. **Never the customer shell as a stand-in** |
| **error** (`/me` failed — API down) | `AuthAccountError` with retry. **Never a silent customer fallback** — a transient error must not downgrade a nurse or an admin |
| **role mismatch** | Redirect to the caller's real app via `resolveRoleDestination`, with a `guard_denied` toast — rather than rendering a shell they lack the role for |
A shell passes `expected={APP_ROLES.*}`. **The partner portal passes no `expected`** — a partner-centre admin
is not an `AppRole`. It self-gates on `useMyPartnerCenter` (a 403/404 renders a non-leaking access-denied
state, never a raw id), so `RoleGuard` there only hardens hydration.
`useActorRole()`'s `DEFAULT_ROLE` fallback is now a last resort only — the guard ensures roles are hydrated
before a shell renders — never the loading state.
**`RoleGuard` is UX and chrome, not security.** The server authorizes every endpoint. A dual customer+nurse
session holds both roles and moves freely between the two apps (`ActorSwitcher`).
---
## 6. Silent refresh
`clientFetch` attempts one **single-flight** `attemptTokenRefresh` (`lib/api/refresh.ts`) on a 401 and
retries the request once. A failed refresh — unknown, expired, or reused token, at which point the server
revokes the session — clears tokens and redirects to `/login`.
The refresh and OTP endpoints are **excluded** from this retry, or a failing refresh would recurse.
This mirrors the server's rotation + reuse-detection: a replayed refresh token revokes **all** the user's
sessions.
---
## 7. Middleware
`middleware.ts` runs in this order, and the order is load-bearing:
1. **next-intl locale normalization.** If it is issuing a 307/308, return immediately.
2. **The guest front door.** An **unauthenticated** exact-match on `/` is `NextResponse.rewrite()`d to
`/{locale}/welcome`**never a redirect**, so the URL and the SEO canonical stay `/`.
3. An **authenticated** hit on `/welcome` redirects to `/`.
4. **The auth gate.** A non-public path without a live token redirects to `/login`, appending the attempted
locale-stripped path + query as **`?next=`** (`RETURN_URL_PARAM`) so a deep link — an SMS booking link, a
shared nurse profile — survives the round trip.
5. Otherwise: pass through, stamping the resolved locale on the request headers and preserving next-intl's
response headers (the `Link: alternate` hreflang set).
`LoginFlow` reads `?next=` and `RoleRouter` resolves it via **`resolvePostLoginDestination`**
(`services/auth/routing.ts`), which accepts **same-origin-relative and role-permitting destinations only**
and otherwise falls back to `resolveRoleDestination`. **Never an open redirect.**
### Two traps in this file
**The matcher must list bare `'/'` explicitly** alongside the catch-all regex:
```ts
matcher: ['/', '/((?!_next|_vercel|api|.*\\..*).*)']
```
This Next 16 / Turbopack build does **not** reliably invoke middleware for the literal root through the
negative-lookahead pattern alone — `/` skipped middleware entirely and 404'd, while every other path matched.
It is load-bearing for the guest front door, which only fires on an exact `/` match. (Verified in dev; a
production `next build && next start` confirmed the intended behaviour end to end, so the underlying quirk is
dev-server-only — but the explicit entry stays.)
**Never append `ROUTES.HOME` (`'/'`) to `PUBLIC_PATHS`.** `PUBLIC_PATHS` is matched with `startsWith`, so
`'/'` would silently make **every route public**. The guest-facing root is handled by the exact-match rewrite
above instead. To add a genuinely public route, append it to `PUBLIC_PATHS` and the middleware picks it up
automatically.
---
## 8. Security posture — what is and isn't a boundary
The design above is deliberate, and some of its hardening needs *server* coordination. **Don't silently "fix"
these client-only.**
- **Tokens are non-httpOnly cookies** (JS-readable) so `clientFetch` can attach the bearer header. That
trades XSS hardening for the bearer pattern. Real hardening — httpOnly cookies set by the server plus a
same-origin proxy — spans both projects.
- **The middleware check is UX-only, not a security boundary.** It decodes the JWT and checks `exp`; it does
**not** verify the signature. The API is the only authority. **Never gate real authorization on the
middleware or on `isTokenAlive`.**
- **Role gating is coarse for chrome, fine for the backoffice.** Shells pick chrome from the collapsed
`currentUser.roles` (`useActorRole`). `useAdminCapabilities()` (`@/hooks`) is a memoized selector over the
session's **fine-grained** `roleCodes` (`super_admin` / `admin` / `support` / `finance` / `moderation`)
returning per-console booleans — `canVerify`, `canRefund`, `canPayout`, `canModerate`, `canConfig`,
`canManageAlerts`, `canManageTickets`, `canManagePartners`, `canViewAudit`, `canManageRoles`. `AdminLayout`'s
nav and every admin action hide or disable on it **so a role never sees a control that will 403** — but it
is a **display convenience only; the server authorizes every command.** Never gate real authz on it.
- **Cross-actor route access is not hard-guarded client-side.** Add route guards when a feature needs them.
- **The partner portal is a separate scope** — its pages resolve the caller's own centre via
`useMyPartnerCenter()`, never a raw id.
- **Signed URLs are fetched on demand, never cached long-lived.** Verification documents load via a
short-lived signed URL from `useVerificationDocumentUrl(documentId)` (short `staleTime`, `retry: false`);
`DocumentViewer` re-requests on expiry or error rather than reading an embedded URL out of the long-lived
case query. **Reuse this pattern for any short-lived signed asset** — invoice PDFs included.
- **Refresh-token rotation is wired** client-side (the fetch-layer silent refresh plus `useRefresh`), matching
the server's rotation and reuse-detection.
Admin sub-roles are **server-granted and never self-selectable**; `POST me/select_role` accepts only
`customer` / `nurse` and returns 403 for anything else. Don't build a UI that implies otherwise.
+266
View File
@@ -0,0 +1,266 @@
# Client components, shells and icons
What to reach for before writing something new, and the layout system every screen lives in.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. There is one layout: a phone
`AppFrame` (`src/layout/AppFrame.tsx`) renders **every** screen inside a centered
`APP_FRAME_MAX_WIDTH` (480px) column on a `--bal-frame-canvas` backdrop, at **every viewport**. A wider
window gets more canvas, never a wider app — so you design one set of states and verify one set of states.
**Do not add a `≥md` branch that widens a shell, restores a sidebar, or lays a screen out in columns.**
`AppFrame` owns four structural guarantees, and is the only place any of them is solved:
1. **The width cap.** No shell stretches a header, a nav bar, or a content column across a monitor.
2. **The frame, not the document, owns the scroll.** A single scrolling `<main>` fills the frame; header and
footer are pinned over it and reserve their own space through `<main>`'s padding, so **no page needs a
top offset of its own**.
3. **Horizontal scroll is structurally impossible.** `overflowX: hidden` + `minWidth: 0` on the column mean
an over-wide child clips instead of dragging the whole app sideways. Genuinely wide content (a data
table) scrolls **inside its own container** — see `AdminDataTable`'s `TableContainer`.
4. **Above `sm` the column floats** as a rounded, shadowed card with a gutter all round. On a phone it fills
the viewport edge to edge — there is no canvas to float on.
Two mechanics that follow from (2) and are easy to get wrong:
- **The chrome is `position: absolute` against the frame, never `fixed`.** A viewport-fixed bar would break
out of the centered column and span the whole window. The frame itself never scrolls (only `<main>` does),
so on a phone — where the frame *is* the viewport — the two are visually identical.
- **`AppFrame` publishes `--bal-chrome-top` and `--bal-chrome-bottom`** on the scroll container, so any
`position: sticky` element anywhere in the tree can clear the bars without importing a constant or knowing
which shell it is in. Both already include `env(safe-area-inset-*)`, and both resolve to `0px` in a
chrome-free shell — which is why a sticky consumer can read them unconditionally. `StickyActionBar` is the
reference consumer.
### Shell dimensions are constants
`src/layout/config.ts` holds them, and they are measured rather than guessed:
| Constant | Value | Note |
| --- | --- | --- |
| `APP_FRAME_MAX_WIDTH` | 480 | Mirrored by `components/config.ts`'s `CONTENT_MAX_WIDTH` — a page column can never be wider than the frame containing it |
| `TOP_BAR_HEIGHT` | 56 | One height at every viewport; the frame never changes width, so the old mobile/desktop split had nothing to switch on |
| `TOP_CHROME_HEIGHT` | 72 | Total space the floating header occupies. Deliberately equal to `BOTTOM_NAV_HEIGHT` — the two bars are the same object mirrored |
| `BOTTOM_NAV_HEIGHT` | 72 | Total space the floating nav occupies. `AppFrame` reserves exactly this as `<main>` padding, so nothing hides behind the bar. **Keep in sync with `BottomBar`** |
| `FLOATING_BAR_SX` | — | The ONE definition of the two bars' shared shape, so header and footer cannot drift apart |
---
## 2. The shells
**One authenticated shell.** `MobileShell` = `AppFrame` + a contextual `TopBar` + `BottomBar` +
`ErrorBoundary` + `RouteFadeIn` + `PageTitleProvider`. The four actor layouts — `CustomerLayout`,
`NurseLayout`, `AdminLayout`, `PartnerLayout`, each wrapped in `RoleGuard` — supply only `tabs` and
`headerActions`.
**Add a destination by adding a tab or a hub row — never by forking the shell.**
| Shell | For |
| --- | --- |
| `MobileShell` (via the four actor layouts) | Every authenticated screen |
| `PublicLayout` | Unauthenticated: the frame and **nothing else**, no top bar — so the login card's own `BrandMark` is the only mark on screen |
| `FocusedLayout` | Framed but chrome-free, for flows a user must not tab away from mid-setup: onboarding, `/select-role`. A slim logo strip and content, no bottom nav. The route group above it still applies `RoleGuard` |
| `PrivateLayout` | An authenticated passthrough wrapper; actor chrome lives in the shells above |
### Navigation is the bottom bar. There is no drawer.
- Tabs are `LinkToPage` arrays built with `useTranslations('nav')`, **35 of them**, and by convention the
last is a settings/«بیشتر» hub.
- Active state comes from the shared **`matchActivePath`** (longest-prefix, winner-takes-all) run over each
tab's own path **plus its `matchPaths` claims**. Use `matchPaths` when a tab owns a destination outside
its own URL subtree — `/nurse/finance` owning `/nurse/earnings`. **Never hand-roll
`pathname.startsWith`**, which lights up a sibling tab as often as the right one.
- `BottomBar` **floats**: inset from the frame edges, fully rounded (`--bal-radius-pill`), elevated — not an
edge-to-edge slab sealing off the bottom of a 480px screen. It is **icon-only**: at five tabs the caption
was the widest thing in the bar and cost a whole line, so the label survives as `aria-label`/`title`. Each
tab is a fixed 44px circle that is simultaneously the target, the hover/press tint and the active fill,
laid out `space-around` so the target keeps one size at any tab count.
- `TopBar` is **not** an `AppBar` — no filled surface, no rule, no elevation of its own. `AppFrame` wraps it
in `FLOATING_BAR_SX`, so it is the bottom bar mirrored. It shows a brand lockup on a tab's own path and a
back chevron + `useRouteTitle()` on anything deeper.
### Group roots are real pages
A nav group's root is a page, not a drawer section: a short summary of that domain — read **only off
queries that already answer it, never a fabricated figure** — over a `NavHubList` of its destinations.
`/nurse/practice`, `/nurse/finance`, `/admin/trust`, `/admin/system` are the references. A count that is
still in flight is **omitted, never faked**.
### Chrome carries no preferences, and no identity
Language and appearance live in `SettingsPanel` (`@/components/settings`), mounted in each actor's settings
hub **and nowhere else**. Identity lives in each actor's «بیشتر»/account hub, one tap away on the nav. The
top bar is for the page title and at most a notification bell.
`/admin/system` is always present in the admin nav even when every console inside it is denied, because it
is the only route out of the app (settings + sign-out).
### Navigation goes through `@/i18n/navigation`
**All chrome navigation** uses `Link` / `usePathname` / `useRouter` from `@/i18n/navigation`
(`createNavigation(routing)`). `usePathname` is locale-stripped, so unprefixed `ROUTES.*` compare directly,
and `Link`/`router` add the locale automatically — **no manual `` `/${locale}` `` prefixing, and no
middleware redirect hop.** Never a raw `next/link` for chrome.
Inside a *page*, `AppLink`/`AppButton`'s `to` is a plain `next/link` and still needs the prefix.
Prefer MUI breakpoints in `sx` for the little responsive branching that remains, over `useIsMobile()`
(`@/hooks`) — the hook is JS/post-hydration and caused a real SSR flash. Reach for it only for genuinely
non-structural, JS-only behaviour.
---
## 3. Reach for these before raw MUI
Shared primitives live in `src/components/`, barrel `@/components`. Prefer the `App*` wrapper over the bare
MUI component — the wrappers carry the house defaults.
| Component | Use for | Notes |
| --- | --- | --- |
| `AppButton` | all buttons and button-links | default `variant="contained"`; pass `to`/`href` to render as a link; `startIcon`/`endIcon` accept an **icon name string** or a node |
| `AppIconButton` | icon-only actions | takes an icon name, `title`, `to`/`onClick` |
| `AppIcon` | any icon | `icon="home"` by registered name (§4); `size`, `color` |
| `AppLink` | internal/external links | locale-aware; default underline `hover` |
| `AppAlert` | inline alerts | defaults to a calm `severity="info"`, `variant="standard"` — a genuinely error-severity call site passes `severity="error"` explicitly |
| `AppLoading` | loading state | circular, `primary`, `3rem` |
Defaults live in `src/components/config.ts``APP_BUTTON_VARIANT`, `APP_ICON_SIZE` (24),
`APP_ICON_STROKE_WIDTH` (1.75), `APP_BUTTON_ICON_SIZE` (20), `CONTENT_MAX_WIDTH` (**480**),
`CONTENT_MIN_WIDTH` (320), the alert/link defaults. **Change a default there, not per call site.**
**Concrete MUI primitives stay MUI.** Use `Button`, `Avatar`, `Paper`, `TextField`, `Box`, `Stack`,
`Container`, `Grid`, `Card` directly (or the existing `App*` wrappers) — never invent a new root-level
Button or Avatar. Use the `spacing`/`sx` system (theme unit = 8px); never inline pixel margins for rhythm.
**Composite, shareable components** built from primitives and reused in more than one place belong at the
right *shared* level (`src/components/…`), not inline in a page and not buried in a leaf. Page-only,
never-reused composition can stay in the page.
### The state kit — one pattern per state, and they are not optional
| Primitive | The one pattern for |
| --- | --- |
| `EmptyState` | "nothing here" — icon + title + body + action. Replaces every hand-rolled dashed-border `Paper` |
| `ErrorState` | "this query failed" — `message` + a **required** `retryLabel` + `onRetry` |
| `QueryStateGate` | A query's branching, in the fixed **skeleton → error → empty → children** order. Also requires `retryLabel` |
| `PageHeader` | title + subtitle + `actions` (buttons) + `meta` (a chip row) + a back affordance (`backTo`, or `onBack` which takes precedence and pairs with `useAdminBackToList` for `router.back()`-with-fallback) |
| `ConfirmDialog` | Any destructive confirm. Required-reason gating, busy-disable, and `requireTypedConfirmation` (confirm stays disabled until the typed value matches) — the guard for an irreversible money-moving action, e.g. the admin payout run |
| `SurfaceCard` | A flat `Paper` wrapper; `padding: 'sm' \| 'md' \| 'lg'` |
| `AccentCard` | `SurfaceCard` + a semantic `tone` for a **stateful** panel |
| `Money` | The one money-rendering primitive (`amountIrr`, `size` incl. `xl`, `tone`, `deduction`, `hideUnit`, `strikethrough`) |
| `StatusTimeline` | An ordered `TimelineNode[]` (completed/current/pending/failed) with an animated pulse on `current` |
| `JalaliDatePicker` / `JalaliDateField` / `JalaliDateIntentPicker` | Any Persian-calendar date input. Never a native `type="date"` |
| `StickyActionBar` | A scrolling screen's primary CTA, offset off `--bal-chrome-bottom` |
| `Pager` | The shared prev/next "page X of Y" control. Never a per-screen inline pager |
| `NavHubList` | The grouped destination list a group-root page is built from |
| `InitialsAvatar` | A person with no photo — deterministic name hash → one of six `--bal-avatar-*` pairs, `aria-hidden` beside a visible name |
| `FormDialogShell` | A form dialog: full-screen below `sm`, with a dirty-gated discard confirm |
| `RouteFadeIn` | Route-content motion. Already mounted in all five shells |
**An error state is never an empty state.** A failed query renders `ErrorState`; a successful query with
no rows renders `EmptyState`. Collapsing the two hides outages.
Two `AccentCard` details worth knowing: its colored **edge stripe is gone** — a column of striped cards read
as a row of loose vertical rules down the RTL side of the screen. `tone` survives as the semantic label
(reaching the DOM as `data-accent-tone`), and state is carried by the `StatusChip`, icon and copy inside the
card. **Do not reintroduce the stripe.**
### Presentational purity in `components/common`
`next-intl` (and its `use-intl` dependency) ship ESM-only builds. `jest.config.ts` widens `next/jest`'s
`transformIgnorePatterns` to let them through, but that only fixes real imports — it doesn't make the
dependency free. **Any component at the top of the `@/components/common` barrel that imports `next-intl` at
module scope forces every test file that transitively imports the barrel to deal with it**, including tests
that never touch translations.
So `ErrorBoundary` and `ErrorState` are deliberately **caller-owned**: they take `title`/`body`/`retryLabel`/
`message` as required string props instead of calling `useTranslations` internally, specifically to stay
import-safe at the top of the barrel. `QueryStateGate` inherits the same `retryLabel` requirement by
composition. `Money` is the sanctioned exception — it already had 30+ call sites depending on its
locale-aware API before this was noticed, so the fix went the other way.
When adding a new `common` primitive: **prefer the caller-owned-copy pattern by default**, and reach for
`useTranslations` inside it only if the component is genuinely leaf-level. Keep next-intl-importing
primitives *below* the presentational ones in the barrel so the poisoning risk stays visible in review.
### New shared component
`src/components/<Name>/<Name>.tsx` + an `index.tsx` barrel + a **co-located `<Name>.test.tsx`** (mandatory
for anything imported in more than one place — see [testing.md](testing.md)). Follow the `App*`
prop-spreading and JSDoc style of `AppButton.tsx`.
---
## 4. Icons are a name registry
`src/components/common/AppIcon/config.ts` maps **lowercase** names → components. Render with
`<AppIcon icon="home" />`, or pass the name to `AppButton`/`AppIconButton` (`startIcon="search"`).
**One visual family: Lucide.** Every registered icon comes from `lucide-react` — a contemporary outline
family on a 24px grid with round caps and joins, which reads far lighter than filled glyphs at the small
sizes a phone-width app actually uses. **`@mui/icons-material` is no longer a dependency; never reintroduce
it.** The house stroke weight is `APP_ICON_STROKE_WIDTH` (1.75 — Lucide ships at 2, which competes with
Mikhak's lighter Persian strokes).
**The mapping is semantic, not incidental.** A name describes the domain concept ("verification",
"earnings", "coverage") and the glyph depicts *that*, so swapping the underlying glyph never leaks into call
sites. Related concepts share a visual root on purpose: trust names are shields, money names are coins or
cards, clinical names are a pulse or a cross. Around 110 names are registered — read `AppIcon/config.ts`
rather than duplicating the list.
- **`size` drives real `width`/`height`** (Lucide sizes off SVG attributes), so `size={48}` is 48px with no
`fontSize`/`1em` indirection. Icons default to `flexShrink: 0` — an icon squashed by a flex sibling was
the one layout bug this component kept quietly reintroducing on narrow rows.
- **Directional icons mirror automatically.** Names authored for LTR that must flip under RTL are listed in
`DIRECTIONAL_ICONS` (`back`, `chevron_start`, `chevron_end`, `forward`, `send`). `AppIcon` stamps
`data-icon-directional`, and one CSS rule in `globals.css` does
`[dir='rtl'] [data-icon-directional] { transform: scaleX(-1); }`. Adding a directional icon is a one-line
registry addition — **never hand-roll a per-component flip.**
- **A new icon** is an import from `lucide-react` into `config.ts` plus a lowercase `ICONS` key. Custom SVGs
(the brand mark) go in `AppIcon/icons/` and must accept the same `size`/`color`/`strokeWidth` contract
(`AppIcon/utils.ts`'s `IconProps`). An unregistered name logs a dev-only warning and falls back to
`default`. **Never pass a raw icon component where a name is expected.**
---
## 5. Constants, not magic values
Every magic string or configurable value is a named constant. A value is "magic" if its meaning isn't
obvious from the literal alone: cookie names, event names, route paths, query-param names, numeric
timeouts, API slugs, repeated dimensions.
| Kind | Home |
| --- | --- |
| Cookie names and options | `src/lib/cookies/constants.ts` |
| Feature-scope | a `constants.ts` co-located with that feature |
| App-wide | `src/constants/<concern>.ts``routes.ts`, `roles.ts`, `headers.ts`, `policy.ts` |
| Shell dimensions | `src/layout/config.ts` |
| Component defaults | `src/components/config.ts` |
`constants/policy.ts` is the pattern applied to legally-sensitive numbers that trust-critical copy states
in plain language — the payout dispute-window hours, the cancellation lead-time hours, the refund ETA day
range. They are real server config with no public read yet, single-sourced here and fed into message keys
as ICU params rather than baked into a string. See [i18n.md](i18n.md).
Import the constant; **never copy-paste the literal.** When renaming, change the definition and the rest
follows.
---
## 6. Toasts
| From | Use |
| --- | --- |
| A component or hook | `useSnackbar()``enqueueSnackbar('…', { variant: 'success' })` |
| Outside React (a plain function, the fetch layer) | `dispatchToast('…', 'error')` from `@/lib/toast` — it fires an `app:toast` window CustomEvent that `ToastBridge` picks up |
`ToastBridge` is already rendered in the root layout. **Do not add another instance.**
**Every mutation whose failure isn't already surfaced inline or by the fetch layer needs an `onError`
toast.** A mutation that only handles `onSuccess` is a defect. But don't toast 401/403/5xx in a hook —
`clientFetch` already does. See [services.md](services.md).
+116
View File
@@ -0,0 +1,116 @@
# Client forms
Every form with more than one field is a react-hook-form form. This is how you build one.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The rule, and why it exists
**Any form with more than one field uses react-hook-form.** A single-field control — a search box, a filter
select, a message composer — does not: that is state, not a form.
This is not a style preference. The pattern it replaced was one `useState` per input **plus** a parallel
`useState` per error flag. That meant every keystroke re-rendered the whole screen — including the
query-backed cards, price previews and uploaders sitting beside the field — and left "is this form valid?"
spread across ad-hoc `if` blocks at the top of each submit handler.
react-hook-form gives uncontrolled fields plus per-field subscriptions, so a keystroke re-renders one
input.
28 files currently import it. Any multi-field form that still holds its state in `useState` is a defect to
be migrated when that screen is next substantially touched.
---
## 2. How to build one
1. **`useForm<Values>({ mode: 'onTouched', defaultValues })`.** `onTouched` is the house default: an error
appears once a field has been visited, never while it is first being typed into.
2. **Wrap the subtree in `<FormProvider {...form}>` and bind fields with the `@/components/common/form`
wrappers.** They read `control` off the provider, so it is threaded once.
**Never call `register` or `useController` at a call site.**
3. **Put the rule on the field it governs**`rules={{ validate: … }}` — returning the **translated**
message. Cross-field rules read `validate`'s second argument (all values); that is how the C4 request
form's past-date guard reads the chosen start time.
4. **Render `<Stack component="form" noValidate onSubmit={handleSubmit(submit)}>`** and make the primary
button `type="submit"`. Enter-to-submit then works for free.
5. **Async defaults come from a mounted-when-ready child, not an effect.** When the initial values depend on
a query — the verification credentials read-back, the C4 variant/address defaults — keep the loading
branch in the *parent* and mount the form component only once the data has resolved, so `defaultValues`
**is** the server state instead of being copied into it later.
Point 5 is the one that gets skipped and then costs an afternoon: seeding a form from an effect means the
form has two sources of truth for a moment, and a user who types during that moment loses the keystroke.
---
## 3. The wrappers
| Wrapper | For |
| --- | --- |
| `RhfTextField` | any `TextField`, including `select`. `transform` normalizes keystrokes **into form state** (digit-stripping, max length) so the *stored* value is canonical, not just the displayed one. A rule message replaces `helperText` |
| `RhfChipSelect` | a chip group over stable codes — `string[]` (multi) or `string \| null` (single). `allowCustomValues` keeps a stored code that isn't in the option list visible |
| `RhfJalaliDateField` | a Jalali date field; stores the wire ISO (Gregorian) string, or `null` |
| `RhfControlGroup` | **any** non-input control — `GenderToggle`, `RatingInput`, `CascadingRegionSelect`, the map-pin picker, a `Switch`, a `Checkbox`. Gives it the same label/hint/error shell the text fields get |
Every wrapper falls back to the enclosing `FormProvider`'s `control`, so a form wires it once. All four are
tested.
### Two conventions worth knowing
- **A control that renders its own error text gets a message-less rule**`validate: (v) => cond`, no
string. `RhfControlGroup` then flags the field without printing a second identical line. `AddressForm`'s
region and pin fields are the reference.
- **When the displayed value isn't the stored value, drop to a bare `Controller`.** Exactly two cases exist
and both are commented at the call site: the variant builder's display-name (stored = the override only;
blank means the server names it — shown = the live auto-generated name) and the admin refund channel
(stored = `""` until explicitly overridden; shown = the server's resolved channel).
---
## 4. Structure: `FormSection`
A long form is grouped into `FormSection`s — a heading, a one-line statement of *why* the group is being
asked for, and an optional/status marker.
The point is that **an optional group reads as skippable and a blocked submit has somewhere to attribute
itself.** A flat run of ten `TextField`s makes everything look equally mandatory, which is how a nurse ends
up abandoning a verification form over a field that was never required.
Applies to the nurse profile (معرفی / تجربه و تحصیلات / تخصص‌ها), the verification identity and credentials
screens, and the variant builder.
---
## 5. Making a gate honest
Five habits that came out of the verification and variant-builder rebuilds. They are what separates a form
that *validates* from a form a user can actually finish.
- **The submit gate is a real form field, not a caption near the bottom.** Verification B4's three asks
(national id / card photo / selfie) each became a `FormSection` with the card marked optional and the
selfie marked required — so the requirement is attached to the thing that satisfies it.
- **Disable Next with the unanswered required groups *named* under it.** Not "always enabled, error after
the tap".
- **Derive a wizard's step list from the loaded data.** A category with no option groups skips straight to
pricing rather than showing an empty middle step.
- **Recap the chosen values on the final step**, so the last step doubles as a review.
- **Never dead-end a returning user on a disabled button with no explanation.** If server-side state (an
already-uploaded document, an already-submitted registry number) satisfies part of the gate, the gate must
consider it — and a value the server won't read back by design should lock into a "recorded" row rather
than re-prompting for it blank.
## 6. Unsaved work
- A form hosted in `FormDialogShell` reports `dirty` via an **`onDirtyChange`** prop, which drives the
shell's discard-confirm on close, backdrop and escape.
- A **staged-but-unsaved upload** gets a `beforeunload` guard — the nurse-profile avatar is the reference.
- A destructive confirm goes through `ConfirmDialog`, whose destructive and dismiss labels must not be
swapped. (They were, once, on the cancel-request dialog; check yours reads correctly out loud.)
+215
View File
@@ -0,0 +1,215 @@
# Client i18n and Persian copy
next-intl v4 mechanics, the namespace map, and the binding Persian style guide — the last of which is
enforced by `npm run lint:copy`.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The rule
**No hard-coded user-facing strings.** Every user-visible string — label, placeholder, `aria-label`,
button text, error message — is a key in **both** `messages/en.json` and `messages/fa.json`, and the two
files stay in sync.
The one sanctioned exception is `app/global-error.tsx`, which replaces the root layout on a root-level crash
and therefore renders its own `<html>` and cannot use next-intl. Keep it minimal and bilingual.
Locales: **`fa` (default, RTL)** and `en`. `/en` is explicitly accessed; a bare `/` normalizes to the
default locale.
| Context | API |
| --- | --- |
| Client component | `const t = useTranslations('nav'); t('home')` |
| Server component | `const t = await getTranslations('nav'); t('home')` |
| Structured (array/object) values | `t.raw('terms_sections')` |
| Rich text with tags | `t.rich('consent_line', { terms: …, privacy: … })` |
Top-level keys are namespaces. Adding a translation means adding the key to both files — never one.
---
## 2. The namespace map
MVP namespaces are complete. Add a key to an existing namespace where it fits; seed a new namespace only
with a genuinely new surface, and seed it in both files at once.
| Namespace | Owns |
| --- | --- |
| `common` | Shared words — loading, retry, `currency_toman`, the brand wordmark |
| `nav` | The actor shells' tab labels — every shell builds its nav from here |
| `shell` | Actor-shell titles |
| `auth` | Phone-OTP login, the role router, `RoleGuard` states, select-role, the login-hero trust bullets, the consent line |
| `legal` | `/terms` and `/privacy`. **The one namespace with structured JSON values**`terms_sections`/`privacy_sections` are arrays of `{title, body}` read via `t.raw` |
| `welcome` | The public landing. Its `category_*` labels are **marketing copy, deliberately distinct from the live `catalog` category names** |
| `onboarding` | The A3→A4 wizard, plus the shared enum labels (relation / condition / gender codes → labels) |
| `home` | The family home — greeting, search entry, category grid, nudges |
| `profile` | Customer profile and emergency contact |
| `patients` | The care-circle list and CRUD |
| `records` | The care-record viewer and the nurse visit-note panel. Reuses `onboarding`/`patients` enum labels — never re-keyed |
| `geo` · `address` · `coverage` | The cascading region select · the customer address book · the nurse coverage editor |
| `catalog` | **Shared** catalog vocabulary — the five `price_unit` labels, count nouns, the estimated-total label. Read by `PriceDisplay` on both sides |
| `services` | The nurse services surface and the variant builder |
| `nurseProfile` | The nurse profile bootstrap and the public-profile preview |
| `activation` | The shared `ActivationChecklist` rows and its collapsed live state |
| `bank` | Nurse payout bank settings and the three ownership states |
| `verification` | The nurse trust flow — per-step and per-status labels keyed off the code, the honesty-sensitive manual-vs-auto copy, the journey group labels |
| `search` | Discovery C1/C2/C3 — filters, the same-gender facet, all four result states, card and profile labels |
| `booking` | The booking-request flow **and** post-payment engagement — `bstatus_*`, `sstatus_*`, EVV banners, care-instruction labels, `money_*`, the bookings list |
| `payment` | Checkout and invoice — the breakdown rows, the **verbatim escrow copy** (`escrow_notice`), the card-flow states, the confirmation and invoice screens, `pstatus_*`, مودیان states |
| `refunds` | Cancellation and refund status — policy tiers keyed off `cancellation_policy_code`, the refund-vs-fee breakdown, `step_*`/`rstatus_*`, per-channel ETA copy |
| `bnpl` | Installment checkout D1D5 — the ownership-truth copy, provider names keyed off `provider_{code}`, the plan/eligibility/schedule labels, the wallet due list |
| `payouts` | Nurse earnings and payout history — the balance header incl. the negative "owed back" state, the four buckets, `estate_*`/`pstatus_*`/`bstatus_*`, the cadence explainer |
| `reviews` | The review form, tag labels keyed off the code, moderation-status labels, the aggregate count |
| `tickets` | The messaging surface — inbox, thread, composer, author-role labels, and both emergency surfaces |
| `notifications` | The notification center and bell. Row `title`/`body` are **server-rendered copy, not keys** |
| `admin` | Every backoffice console, the Persian legal terms (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی), and the enum-label prefixes |
| `partner` | The partner-centre portal — a separate authz scope |
### Enum labels
**A label is keyed off the stable code, never derived from the wire value.** `status_pending_moderation`,
`pstatus_failed`, `tag_punctual` — the code is the key suffix, and the vocabulary of codes is a client
constant, not something read off a response. Shared enum labels are **reused** across namespaces, never
re-keyed.
This is what lets the server rename a display string without a client deploy, and lets the client show a
Persian label for a code it has never seen without falling back to raw English.
---
## 3. Numbers and interpolation
Persian digits (۰۱۲۳۴۵۶۷۸۹) everywhere on `/fa` — both hard-coded literals (`"۲۴ ساعت"`) and interpolated
numbers.
| Case | Do |
| --- | --- |
| A number inside an ICU message | Use the `number` sub-format — `{count, number}` — or a plain `#` inside a `plural` block. next-intl formats both through the active locale, so `fa` gets Persian digits automatically |
| A raw number built into a string in code | Route it through `formatNumber` (`@/utils`). **Never template a raw JS number into Persian text** |
| A date | `formatShamsiDate` / `formatShamsiDateTime` (`@/utils`) — UTC ISO in, Persian calendar out |
| Money | `<Money>` or the `@/utils` money helpers. Never a float, never a raw digit run |
### Policy numbers are never hard-coded into a string
Legally or financially sensitive numbers that the admin config panel can change — the dispute-window hours,
the cancellation lead-time hours, the refund ETA day range — **never** go into a message string. The message
key takes a parameter (`{hours}`, `{minDays}`/`{maxDays}`) and the call site interpolates from
`src/constants/policy.ts`, which single-sources them.
A config edit must never again silently make the UI copy lie. (These are real server config with no public
read yet; the constants file is the interim single source.)
---
## 4. Persian style — binding for `fa.json`
`npm run lint:copy` (`client/scripts/check-copy.mjs`, part of `npm run check`) greps every leaf string in
`fa.json` for the banned variants marked **linted** below, on every run. A regression fails the gate
immediately rather than needing to be re-discovered by a human.
`en.json` is hand-written and reviewed for idiom, not linted.
### 4.1 Brand name — **linted**
**«بالین‌یار» — ZWNJ (``) between بالین and یار, always.** Never a plain space («بالین یار»). The brand name
appears in money and trust copy — login, escrow, refunds — as often as anywhere else, and that is the worst
place to be inconsistent.
### 4.2 تأیید — hamza, always — **linted**
Write **تأیید** and its derived forms — **تأییدشده**, **تأییدیه**, **تأیید کردن** — every time. Never
تایید/تاییدشده/تاییدیه. This is the single most frequent word in a verification product: one spelling, no
exceptions, in every namespace.
### 4.3 جستجو — one form — **linted**
Standard form: **جستجو** (no ZWNJ, one word). Not «جست‌وجو», not «جست و جو». Applies to the noun and any
compound (`در جستجو`, `نتایج جستجو`).
### 4.4 ZWNJ (نیم‌فاصله)
Use ZWNJ — never a plain space, never nothing — in:
- **می‌ + verb stem** — می‌شود، می‌کند، می‌پردازید، می‌ماند. Never میشود or می شود.
- **Plural ها** — keep the ZWNJ before ها when the base ends in a consonant that would otherwise misread
(`شب‌ها`, not `شبها`); a word already ending in a vowel or silent-h takes it too (`بچه‌ها`).
- **Compound past-participle adjectives** — تأییدشده، لغوشده، ردشده، منتشرشده، پرداخت‌شده. One ZWNJ-joined
word: not two spaced words («تایید شده»), not fused with no separator.
- The brand name (§4.1).
### 4.5 Two other linted rules
- **The archaic passive می‌گردد is banned** — use می‌شود. (The check anchors on a leading space so the
entirely legitimate «برمی‌گردد», which fuses «بر» directly on, is never flagged.)
- **«بازی » is banned** — it catches an indefinite «ی» misattached to the wrong word.
### 4.6 Punctuation and quotes
- Persian prose uses **«…» guillemets** for quoted terms and labels. Prefer Persian «،» / «؛» inside new
multi-clause translated sentences; most existing short labels use plain Latin `,`/`;` — **don't retrofit
those**, just don't add more.
- English uses **straight** apostrophes (`don't`, `couldn't`) throughout — never curly (``). Don't
reintroduce curly quotes when editing English copy.
### 4.7 Domain glossary
| Term | Means | Never |
| --- | --- | --- |
| **بیمار** | the care recipient | «مددجو» — it appeared once and was dropped for the 99%-majority form |
| **پرستار** | the caregiver | «مراقب» as a noun for the person. «مراقب» survives only as an adjective/role qualifier — "جنسیت مراقب" = the caregiver's gender |
| **رزرو** | a confirmed, **paid** booking | calling a `booking_request` «رزرو» before it converts |
| **درخواست رزرو** | a pre-payment request | conflating it with رزرو |
| **ویزیت** | one scheduled visit/session within a booking | — |
| **شبا** | IBAN | «شماره شبا» for the field label, «شبا» alone elsewhere |
The رزرو / درخواست رزرو split mirrors the code's `bookings` vs `bookingRequests` and the server's
`Bookings` vs `Booking` areas. It is a money boundary, not a synonym.
### 4.8 Shell naming — one metaphor per audience class
- **End-user shells** (family, nurse — the apps people book or work through day to day) → **«اپلیکیشن»**:
«اپلیکیشن خانواده», «اپلیکیشن پرستار».
- **Back-office shells** (admin, partner-centre) → **«کنسول»**: «کنسول مدیریت», «کنسول همکار».
- Never «نما» (view) or «پرتال» (portal) for a whole shell name.
(`booking.evv_nurse_view` "نمای پرستار" is a different thing — a chip labelling *whose perspective* a shared
screen is rendered from, not a shell name. It correctly keeps «نما» in that narrower sense.)
### 4.9 Verification pipeline vs. the identity step
**«تأیید صلاحیت»** names the whole 7-step nurse trust pipeline — the nav entry, the hub title, its
start/progress/approved states, the admin queue. **«احراز هویت»** stays the name of the *one* KYC step inside
it (national ID + civil registry + liveness selfie), on both the nurse side
(`verification.step_identity_kyc`) and the admin side (`admin.step_identity_kyc`).
A nurse who passed the KYC step but still saw a pipeline titled «احراز هویت» marked incomplete in the nav
read that as a contradiction. They no longer share a name — keep it that way.
### 4.10 Status vocabulary — one nurse-facing form, one admin-facing form
For "this step/item was rejected" states that appear on **both** a nurse-facing and an admin-facing screen
for the *same underlying concept* (a verification step's outcome):
| Audience | Form | Matches its siblings |
| --- | --- | --- |
| Nurse-facing | **«رد شد»** (`verification.status_failed`) | the declarative sentence register of `status_passed` («تأییدشده») / `status_in_review` («در حال بررسی») |
| Admin-facing | **«ردشده»** (`admin.step_failed`, `agg_rejected`, `rstatus_rejected`, `mstatus_rejected`) | the admin namespace's compound-adjective pattern — `step_passed`/`agg_approved`/`center_state_verified` |
This does **not** extend to money-failure vocabulary. `payouts.pstatus_failed`, `refunds.rstatus_failed` and
`admin.batch_status_failed` all legitimately use «ناموفق»: a transfer *failing* is a different concept from a
document being *rejected*, and conflating them would blur a real distinction.
### 4.11 Register
Formal شما throughout, with polite imperatives (کنید) for actions and instructions. Never informal تو or bare
imperative stems (نکن, برو).
---
## 5. Reference data with two names
Server reference data that carries `name_fa`/`name_en` returns **both**, and the client picks by locale.
Don't ask the server for a locale-specific name, and don't translate a data row into a message key —
categories, provinces and cities are rows an admin can add, not vocabulary.
+221
View File
@@ -0,0 +1,221 @@
# Client services and data
The fetch layer, the `services/{domain}` pattern, caching, and the money rules that make the UI honest.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Fetch only through the two primitives
| File | Use from | Behaviour |
| --- | --- | --- |
| `lib/api/client.ts` | hooks, client components | `clientFetch<T>` — throws `ApiError` on error; silent-refreshes and retries once on 401 |
| `lib/api/server.ts` | RSCs, Server Actions | `serverFetch<T>` — throws `ApiError` on error |
| `lib/api/errors.ts` | anywhere | the `ApiError` class (`status`, `message`, `code`) |
| `lib/api/types.ts` | anywhere | `ApiEnvelope<T>` + `unwrap()`, `Paginated<T>`, `PageParams` |
| `lib/api/refresh.ts` | internal | `attemptTokenRefresh` — the single-flight refresh `clientFetch`'s 401 branch uses |
**Never call `fetch()` directly** in a component, hook, or service. Domain calls live in
`src/services/{domain}/apis/`.
### The `clientFetch` error contract
| Status | What happens |
| --- | --- |
| **401** | Toast "session expired", clear cookies, redirect to login. **No throw** — the page navigates away |
| **403** | Toast "forbidden", throw `ApiError` |
| **5xx** | Toast "server error", throw `ApiError` |
| Other **4xx** | Throw `ApiError`, **no toast** — the calling hook owns the user-facing message |
| Network failure | Toast "network error", throw `ApiError` |
So: **never toast 401/403/5xx inside a hook.** Only a domain-specific 4xx earns an `onError` toast. A
mutation that handles only `onSuccess` is still a defect — see [components.md](components.md) §6.
`serverFetch` throws on every error and toasts nothing (the server can't fire browser events). The RSC
caller decides whether to `notFound()`, `redirect()`, or let it reach an error boundary.
**Never mix `clientFetch` and `serverFetch` in one file.** Keep `clientApi.ts` and `serverApi.ts` separate;
Next enforces the environment boundary at build time.
### The wire envelope
The server wraps every response in `ApiEnvelope<T>``{ isSuccess, statusCode, message, requestId, data }`,
camelCase. `clientFetch` returns the raw body, so a real `clientApi` reads the payload via `unwrap()`.
Types mirror the wire **exactly** and are derived from the published contract in
[`docs/integration/`](../../integration/index.md) — never guessed. If a shape you need doesn't exist, say so
and mock behind the seam meanwhile (§3).
---
## 2. The `services/{domain}` pattern
Every one of the 22 domains has the same shape. Copy `auth` or `patients`.
```
services/{domain}/
├── types.ts wire types + the domain's `Api` interface — this interface IS the seam
├── keys.ts the React Query key factory, hierarchical
├── constants.ts the mock toggle + staleTime values (when the domain has a mock)
├── apis/
│ ├── clientApi.ts real, wraps clientFetch, unwraps the envelope
│ ├── mockApi.ts in-memory, same interface
│ ├── serverApi.ts serverFetch — only when an RSC needs it
│ └── index.ts selects real vs mock by config — the one line hooks import
├── hooks/
│ └── use{Action}.ts one hook per file — useQuery (deliberate staleTime) or useMutation (invalidates)
└── index.ts the barrel: re-exports HOOKS ONLY
```
Two hard boundaries on the barrels:
- **No top-level `src/services/index.ts`.** An import must name its domain:
`import { useLogin } from '@/services/auth'`, never `from '@/services'`.
- **A domain barrel exports hooks only** — never `types`, `keys`, or `apis/*`. Reaching past the hooks is
how a component ends up depending on a mock's internals.
### Caching is deliberate, not incidental
- Set a **`staleTime`** on reads, so revisiting a screen doesn't refetch.
- Mutations **invalidate** the affected list key (`queryClient.invalidateQueries`) or `setQueryData`
never leave the cache stale. See `services/patients/hooks/*`.
- **Reference data is cached for the whole session.** Rarely-changing lookups use an **Infinite
`staleTime`** plus a shared hierarchical key factory, so each level is fetched **once** and served from
cache across every consumer — never refetched on a dropdown open. Two domains do this: `geography` (the
province→city→district hierarchy, `geographyKeys`) and `catalog` (admin-seeded categories and a category's
option groups, `CATALOG_REFERENCE_*`). **Reuse the pattern; do not reinvent per-consumer fetching.**
- Contrast with mutable lists — addresses, coverage areas, the nurse's own variant list — which invalidate on
every mutation.
- **The filter object IS the query key.** `search` canonicalizes its filter set into the key
(`canonicalizeSearchFilters`), so identical or reverted filters reuse cache with zero network;
`keepPreviousData` avoids flashing. Filters and page belong **in the URL**, which is what makes the cache
key shareable and the back button work.
- Admin and partner queue pages use **`useAdminListState`** (`@/hooks`) for URL-synced worklist state:
draft-vs-applied filters plus page, with `apply`/`applyFilters`/`clear`/`goToPage`. It is
`useSearchParams`-based, so a caller needs a `<Suspense>` boundary.
- Prefer RSC prefetch or `initialData` where it removes a client round-trip.
### Re-render cost is part of correctness
Stable references (`useCallback`/`useMemo` only where it pays), `select` to subscribe to a slice rather than
a whole query, state colocated as low as it can go and lifted only when genuinely shared. **Don't put
fast-changing state in a high context provider** — a 1-second countdown belongs inside the component that
displays it, which is exactly what `CountdownTimer` does.
---
## 3. The mock seam
When a backend endpoint isn't live, implement the domain's `Api` interface **twice** — a real `clientApi.ts`
and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config flag (`USE_{DOMAIN}_MOCK`). Hooks
import the selected `api`; **the swap is one line and touches no caller.** Record every mock in
`docs/status/` per [code-quality.md](../shared/code-quality.md) §4.
### Current state — 15 real, 7 mocked
**Real** (`USE_*_MOCK = false`): `auth`, `geography`, `patients`, `profiles`, `nurse` (bank), `addresses`,
`serviceAreas`, `catalog`, `search`, `bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`,
`tickets`.
**Still mocked**, each blocked on a named contract gap:
| Domain | Blocked on |
| --- | --- |
| `verification` | the admin verification queue |
| `refunds` | the admin refund preview |
| `payouts` | the admin payout preview |
| `admin` | the RBAC role endpoints |
| `bnpl` | provider options / schedule / wallet installments |
| `partnerCenter` | the portal split reads + a `/me` centre signal |
| `patientRecords` | endpoints exist, but the client family-record `id` model is `string` vs the wire's `int` — the customer-edit PUT is **write-unsafe** until reconciled |
The open REQ numbers behind these live in `docs/status/backlog.md`. Before flipping a domain to real, check
that its `clientApi.ts` actually consumes the fields the server serves — flipping the flag is necessary but
not sufficient.
One coupled seam: **`EVV_GPS_MODE` auto-selects `off`** (real `navigator.geolocation`) once
`USE_BOOKINGS_MOCK` is `false`, so you don't get mock coordinates against real bookings.
---
## 4. Money and time on the client
The client **displays** money. It does not compute it.
| Rule | Why |
| --- | --- |
| Money crosses the wire as an **IRR digit string** and is parsed with integer-safe `BigInt` helpers (`formatIrrToToman` / `formatIrr` / `parseIrr` in `@/utils`). **Money is never a float** | IRR aggregates exceed JS's safe integer range, and float coercion on money is a correctness bug, not a rounding one |
| A **breakdown reconciles by construction**`PriceBreakdown` dev-guards `console.error` when rows don't sum to the total | A total the user can't derive from the rows they were shown is a trust failure |
| The client **never computes** a rate, an aggregate, a payout date, or a holiday shift | These are server truth. A commission rate is snapshotted server-side at compute time; a review aggregate is recomputed from source; a payout date shifts off the bank-closure calendar |
| A **server-frozen deadline is rendered, never recomputed**`CountdownTimer` takes the UTC instant and counts down to it | A client that recomputes a deadline from a config value will disagree with the server the moment the config changes |
| The **signed net payable balance is never clamped** — a negative reads as an explicit "owed back" state (magnitude only, never a bare minus) | Clamping to zero tells a nurse they owe nothing when they do |
| Toman is **display-only**; the boundary conversion happens once, at the field | Mixing units in the middle is how a price ends up 10× off |
| Dates arrive as **UTC ISO** and display through `formatShamsiDate(Time)`. Shamsi is a client concern | Except bank-closure math, which the server owns |
### Money-path mechanics
- **The caller owns the per-attempt `Idempotency-Key`** on payment initiate. Per *attempt*, not per booking.
- **Poll only while non-terminal**, with backoff and bounded attempts. `usePaymentOutcome`, `useBnplOrder`,
`useRefundStatus` and `useBookingRequest` all stop at a terminal state.
- **A 409 on the money path is benign convergence, never a toast.** It means the server already did what you
asked.
- **`invalidations.ts` is the one post-capture cache transition** per money domain — an explicit list of the
request/booking/summary/outcome keys that change. **Never a blanket refetch.**
---
## 5. Non-negotiable data rules
These encode business invariants, not preferences. Breaking one leaks data or misreports money.
**Clinical data**
- **`is_internal` is never modelled in the user-app ticket types.** Both mappers drop an internal message —
a server-strip mimic — and there is no internal affordance anywhere in a user-facing screen. The admin
ticket types carry `isInternal`; the user types deliberately do not.
- **The customer must never fire the care-instructions query.** The two-stage disclosure gate is proved by a
test on `BookingDetailView`.
- **A nurse's care-record access is append-only.** The nurse surface never wires the customer-edit mutation.
- **Access-denied is a first-class, non-leaking state, gated *before* any clinical fetch** — not an error
rendered after a 403 came back with a body in it.
- **Clinical text is never logged, never put in `localStorage`, never put in a query string.**
**Visibility and trust**
- **A `pending_moderation` review is never injected into a public list or aggregate**, and the client never
computes the aggregate.
- **Every search result is verified-by-invariant** — the server's index only contains searchable rows, so
the UI never re-filters. If an unverified nurse appears, that is a server bug, not something to paper over
client-side.
- **`districtId = null` means whole-city** — a real coverage choice, not missing data. Treating it as absent
drops a nurse's entire coverage.
- **A notification's `data_json` is a typed contract.** `parseNotificationData(type, dataJson)` returns a
discriminated union, tolerates snake/camel, and degrades to `{ kind: 'none' }` on anything malformed,
unknown, or missing an id. Never trust the blob; never index into it directly.
---
## 6. Cookies
**App and auth state goes through the cookie manager only** — never `document.cookie`, never `js-cookie`
directly, never `localStorage` or `sessionStorage`.
| File | Import from | Holds |
| --- | --- | --- |
| `lib/cookies/constants.ts` | anywhere, via the barrel | `COOKIE_NAMES`, `CookieOptions`, `AUTH_*_COOKIE_OPTIONS`, `COLOR_SCHEME_COOKIE_OPTIONS` |
| `lib/cookies/server.ts` | RSCs, Server Actions, Route Handlers **only** | `getServerCookie`, `getThemeMode`, `setServerCookie` |
| `lib/cookies/client.ts` | client components / effects **only** | `getClientCookie`, `setClientCookie`, `deleteClientCookie`, `getColorSchemeCookie` |
| `lib/cookies/index.ts` | anywhere | re-exports `constants.ts` **only** — a safe barrel |
Import constants via the barrel (`import { COOKIE_NAMES } from '@/lib/cookies'`) and the server/client
utilities **directly** from their file. Never import `server.ts` in a client component or `client.ts` in an
RSC.
`COOKIE_NAMES.COLOR_SCHEME = 'color-scheme'` is the single source of truth for the theme cookie name — do not
redeclare it anywhere. `CookieOptions.maxAge` is in **seconds** (converted to an `expires: Date` internally).
**Never read `localStorage` or `document.cookie` in a render function** — use an effect, or read server-side
via `next/headers`.
See [auth.md](auth.md) for the token cookies and the session lifecycle.
+185
View File
@@ -0,0 +1,185 @@
# Client structure
The route tree, the server/client boundary, and the page pattern every screen follows.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. `src/` at a glance
| Folder | Holds |
| --- | --- |
| `app/` | The App Router tree. Everything under `[locale]/` |
| `components/` | Shared UI — `common/` primitives plus one folder per domain composite family |
| `constants/` | App-wide named constants (`routes.ts`, `roles.ts`, `headers.ts`, `policy.ts`) |
| `context/` | React context providers — `auth/` (AuthContext + reducer) |
| `hooks/` | Cross-cutting hooks (`auth.ts`, `capabilities.ts`, `layout.ts`, `useAdminListState.ts`) |
| `i18n/` | next-intl wiring: `routing.ts`, `request.ts`, `navigation.ts` |
| `layout/` | The one mobile app shell: `AppFrame`, `MobileShell`, the four actor layouts, chrome components |
| `lib/` | Infrastructure: `api/` (fetch), `auth/` (token/session), `cookies/`, `query/`, `toast/` |
| `services/` | 22 domain services, one folder each — the data layer |
| `theme/` | Palette, tokens, typography, the pre-built themes |
| `utils/` | Money, dates, numbers, CSV, text helpers |
Plus `messages/{en,fa}.json`, `middleware.ts`, and `next.config.mjs` (which only wires the next-intl
plugin and `reactStrictMode`) outside `src/`.
This is a **pattern map, not a file listing**. `git ls-files client/src` enumerates files for free and
never goes stale; what follows is the shape those files have to fit.
---
## 2. The one absolute rule: no layout above `[locale]`
**`src/app/[locale]/layout.tsx` IS the root layout.** It renders `<html>` and `<body>`. There is no
`src/app/layout.tsx`, and adding one — or any layout above the `[locale]` segment — breaks both locales.
**Why**, because this is worth understanding rather than obeying: a layout above `[locale]` is *shared*
between `/fa` and `/en`. Next.js statically caches it at build time with `defaultLocale` (`fa`) and never
re-renders it on a client-side locale switch, because the segment it is keyed on doesn't change. Its
`lang`, `dir`, messages, providers, and fonts therefore **freeze on `fa`/`rtl` for every route, including
`/en`**. The `[locale]` layout is the lowest boundary keyed on the locale param, so it is the only place
`<html lang dir>` can reliably track the active locale.
### What the root layout owns
- The locale, sourced from the **URL param** (`params.locale`), validated against `routing.locales` with a
fallback to `defaultLocale`. **No header reads.**
- `<html lang dir>``dir` from `getDirection(locale)` — plus `data-mui-color-scheme` from
`getThemeMode()`.
- The per-locale font class (Mikhak on `fa` only — see [theme.md](theme.md)).
- `setRequestLocale(locale)`, so server components deeper in the tree can call `getLocale()` /
`getTranslations()` reliably. **Never remove it** — without it, deeper RSCs always see `defaultLocale`.
- `getMessages({ locale })` with the locale passed **explicitly**, so `getRequestConfig` receives it via
`Promise.resolve(locale)` rather than through the `React.cache` read — which avoids a cache-ordering
race. **Never call `getMessages()` bare.**
- The providers: `NextIntlClientProvider`, `AuthProvider` (seeded with `getServerAuthState()`),
`ThemeProvider`, `NotistackProvider` + `ToastBridge`.
- `generateStaticParams`, so Next can enumerate locale routes at build time.
- `generateMetadata` — the `'%s | بالین‌یار'` / `'%s | Balinyaar'` title template, the default title and
description, and `metadataBase: new URL(SITE_URL)` so child pages' relative OG/canonical URLs resolve
absolute. `SITE_URL` comes from `src/config.ts`, never a hard-coded origin.
**Never add `notFound()` to the `[locale]` layout.** Unknown locales are handled by middleware; a hard 404
there breaks the fallback.
### The two files that legitimately sit above `[locale]`
| File | Why it's allowed |
| --- | --- |
| `app/global-error.tsx` | It *replaces* the root layout on a root-level crash, so it renders its own `<html>` — which means it **cannot use next-intl**. It is the one sanctioned static-string exception: keep it minimal and bilingual (fa + en) |
| `app/robots.ts`, `app/sitemap.ts` | Route handlers, not layouts. They enumerate the public surface across both locales |
---
## 3. The server/client boundary
| Never import | From |
| --- | --- |
| `next/headers` | a client component |
| `next-intl/server` | a client component |
| `@/lib/cookies/server` | a client component |
| `@/lib/cookies/client` | an RSC |
The build fails on the first three. The fourth fails at runtime, quietly, which is worse.
Route-group layouts (`(private-routes)/layout.tsx`, `(public-routes)/layout.tsx`) are `'use client'` — they
only wrap a layout component and need no server capabilities.
Never mix `clientFetch` and `serverFetch` in the same file; keep `clientApi.ts` and `serverApi.ts`
separate. Next enforces the environment boundary at build time.
---
## 4. The route tree, by shape
Everything lives under `src/app/[locale]/`. Route groups add no URL segment.
```
[locale]/
├── layout.tsx error.tsx not-found.tsx [...rest]/page.tsx
├── (private-routes)/ layout.tsx mounts useSessionRoleSync
│ ├── _chrome/ shared loading skeleton (private, not a route)
│ ├── select-role/ first-use role picker, own FocusedLayout
│ ├── (customer)/ the family app — no URL segment
│ ├── (customer-focused)/ chrome-free counterpart, same URL space (onboarding)
│ ├── nurse/ the nurse app
│ ├── admin/ the backoffice
│ └── partner/ the partner-centre portal — a SEPARATE authz scope
└── (public-routes)/ login · terms · privacy · welcome
```
| Convention | Meaning |
| --- | --- |
| `(parenthesised)` | A route group. Adds no URL segment; exists to attach a layout and a `RoleGuard` |
| `_`-prefixed folder | Private, **not a route**`_chrome/`, `admin/_hub/` |
| `[...rest]/page.tsx` | The catch-all. Calls `notFound()` so any unmatched path under a locale renders `not-found.tsx` — next-intl's recommended 404 pattern |
| `loading.tsx` | A route-group skeleton shaped like that group's content area. The `MobileShell` chrome is already rendered by the enclosing layout, so a skeleton shapes the content only |
Each private group's `layout.tsx` is `'use client'` and wraps `RoleGuard` → that actor's layout:
`(customer)``CustomerLayout`, `nurse``NurseLayout`, `admin``AdminLayout`, `partner`
`PartnerLayout`. `(customer-focused)` and `select-role` wrap `FocusedLayout` instead, for flows a user must
not be able to tab away from mid-setup.
The **partner portal is a separate authorization scope**: a centre admin is not a Balinyaar admin. Its
`RoleGuard` passes no `expected` role (it isn't an `AppRole`) and each page resolves the caller's *own*
centre via `useMyPartnerCenter`. See [auth.md](auth.md).
**When you add, remove, or rename a route group, a provider, or a top-level `src/` folder, update the
"Project structure" section in [client/CLAUDE.md](../../../client/CLAUDE.md) and §1 above in the same
change.**
---
## 5. The page pattern
The root layout owns a title *template*; a route supplies the `%s`. So a route that wants its own tab
title splits in two:
```tsx
// page.tsx — a thin RSC. No 'use client'.
import type { Metadata } from 'next';
import { getTranslations } from 'next-intl/server';
import HomeScreen from './HomeScreen';
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'shell' });
return { title: t('customer_app') };
}
export default function Page() {
return <HomeScreen />;
}
```
```tsx
// HomeScreen.tsx — 'use client'. All the logic and JSX.
```
Rules that fall out of it:
- The screen component is **co-located** with `page.tsx` and named `<PageName>Screen.tsx`, so its existing
relative imports keep working unchanged.
- `page.tsx` never renders `<title>` and never touches `document.title`. Assigning `document.title` in a
render body throws `ReferenceError: document is not defined` during build-time prerendering.
- A static `metadata` export is fine when the title needs no translation lookup.
- Page bodies stay **composition + content**. Reusable visuals move to `src/components/`; page-only,
never-reused composition can stay in the page.
Adoption is partial by design: the landing pages (customer home, `/login`, `/search`, `/bookings`,
`/nurse`, `/admin`, `/partner`) plus `/welcome` use it. The rest still render directly and gain it when
the page is next substantially touched.
`metadataBase` makes the pattern extend to OG: `/welcome` sets `alternates.canonical` + `openGraph` in its
`generateMetadata` and supplies `og:image` from a co-located `opengraph-image.tsx` (`next/og`'s
`ImageResponse`).
---
## 6. This is not a static export
The app relies on server components, middleware, and server-side cookies. `next.config.mjs` wires the
next-intl plugin and `reactStrictMode` and nothing else — don't add `output: 'export'`, and don't assume a
page can be prerendered without its request context.
+120
View File
@@ -0,0 +1,120 @@
# Client testing, lint and types
What is tested and how, plus the two gate tools and their traps.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. What is tested
**Every shared component has a co-located test file.** A component is "shared" if it is imported from more
than one place — a page, a layout, or another component.
There are **125 test files** across `src/` (114 `*.test.tsx`, 11 `*.test.ts`). Location:
`src/components/<Name>/<Name>.test.tsx`, next to the component.
### Coverage baseline for a shared component
1. It renders without crashing.
2. Every documented prop produces the correct HTML attribute or CSS class.
3. User interactions (click, change) call the expected callbacks.
That is a floor, not a ceiling. Where a component encodes a rule, test the rule: `BookingDetailView`'s test
proves the customer **never fires the care-instructions query** (the two-stage disclosure gate), and
`matchActivePath`'s test proves a nested route lights up its parent tab and never a sibling. Those are the
tests worth writing.
### The two rules about the test itself
- **Wrap with `<ThemeProvider>`** if the component uses MUI theming.
- **Do NOT mock MUI components.** Test against the rendered DOM. A test that mocks `Button` proves nothing
about what a user sees.
### Before removing or renaming a shared component
Check whether any `src/**/*.test.{ts,tsx}` imports it. If so, update or delete those tests in the same
change. A dangling test import fails the suite, and a test left behind for a deleted component is dead code.
### Deliberate coverage gaps
`NeshanMap` is **not** unit-tested — jsdom plus Leaflet is an integration problem, not a unit one — and it is
unreachable in CI anyway, because `NEXT_PUBLIC_NESHAN_KEY` is unset there so `AddressMapPicker` falls back to
the bounded-canvas stand-in. Both branches of that fork are tested; the map itself isn't.
---
## 2. Jest configuration
`jest.config.ts` **replaces** `next/jest`'s `transformIgnorePatterns` array outright rather than appending to
it. This is deliberate and easy to undo by accident:
`next/jest`'s default pattern already broadly matches all of `node_modules` (its negative-lookahead allowlist
carves out only a couple of Next-internal packages), and Jest's array semantics are **OR-based** — a file is
ignored if *any* pattern matches. So appending a more permissive pattern can never "un-ignore" a package an
earlier pattern already caught. The array has to be replaced, by post-processing the async config `next/jest`
returns.
What it lets through: `next-intl`, `use-intl`, `@formatjs`, `intl-messageformat` — all ESM-only builds.
That fixes real imports; it does not make the dependency free. See
[components.md](components.md) "Presentational purity" for why `ErrorBoundary`/`ErrorState` are caller-owned
and `Money` is the one sanctioned exception.
---
## 3. The gate
```
npm run check → npm run type && npm run lint && npm run lint:copy
```
Plus `npm run test:ci` when you touched a component with a co-located test.
Both gate tools are plain CLI tools. **There is no `next lint`** — it was removed in Next 16, and calling it
silently does nothing.
| Script | Runs |
| --- | --- |
| `type` | `tsc --noEmit`. `tsconfig.json`: `strict` on, `noEmit`, `@/*``src/*` |
| `lint` | `eslint .`, driven by **flat config** in `eslint.config.mjs` |
| `lint:copy` | `node scripts/check-copy.mjs` — see [i18n.md](i18n.md) §4 |
`eslint.config.mjs` spreads `eslint-config-next` (core-web-vitals + typescript + react + react-hooks +
jsx-a11y + import) and applies `eslint-config-prettier` **last**, so ESLint never fights Prettier on
formatting.
---
## 4. Lint rules for this project
- **Flat config only.** Do not add `.eslintrc*` files — put any rule change in `eslint.config.mjs`.
- **ESLint owns correctness, Prettier owns formatting.** Don't add stylistic ESLint rules.
- **No unused variables or imports.** `@typescript-eslint/no-unused-vars` is raised from
eslint-config-next's default `warn` to **`error`**, so dead code fails `npm run check`. Delete it rather
than disabling the rule; prefix a deliberately-unused binding with `_` (`_event`, `catch (_err)`) to opt
out.
- **Prefer fixing code over silencing the linter.** When a disable is genuinely correct — the real example
here is a deliberate browser-only read after mount that trips `react-hooks/set-state-in-effect` — use a
scoped `// eslint-disable-next-line <rule>` with a one-line reason. **Never a file-wide disable.**
### Two pinned constraints
- **ESLint is pinned to 9.** ESLint 10 currently crashes with this Next 16 toolchain
(`scopeManager.addGlobals is not a function`). Don't bump it as a housekeeping change.
- **`import/no-cycle` is disabled** — its TypeScript resolver has an interface mismatch here. The reason is
noted in `eslint.config.mjs`; don't re-enable it without checking that note.
---
## 5. MUI v9 API only
The type gate catches most of this, but not all of it, and the failures are confusing when it doesn't.
- Use `sx={{ mb: 4 }}`, **not** `mb={4}` as a direct prop.
- **Do not pass `flexWrap` or `useFlexGap` as direct props to `Stack`.** Neither is a valid v9 `Stack` prop;
both cause a TypeScript overload error. Use `sx={{ flexWrap: 'wrap' }}`. `useFlexGap` was a v5 opt-in and
does not exist in v9.
- No other v5/v6-era props: `storageWindow`, `InitColorSchemeScript`. See [theme.md](theme.md) for the
color-scheme ones specifically, which fail *silently* rather than at compile time.
- Avoid deprecated MUI APIs that throw at runtime.
+268
View File
@@ -0,0 +1,268 @@
# Client theme
Colors, tokens, dark mode, direction, fonts, motion. The brand's *look* is the
[frontend-designer](../../../.claude/skills/frontend-designer/SKILL.md) skill's job; this file is the
mechanism it runs on.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. Brand identity
Balinyaar is a trust-first home-nursing marketplace in Iran. The tone is calm, warm,
clinical-but-human — not a cold medical dashboard. The default audience is Persian (RTL); English is
secondary.
| Role | Light | Dark |
| --- | --- | --- |
| Primary — deep teal | `#1d4a40` | `#6fc0ac` (lifted, readable on dark) |
| Secondary — terracotta | `#d98c6a` | `#e6a98a` |
| Page surface | `#faf9f5` cream | `#0f1c19` deep teal |
| Paper / card | `#ffffff` | `#16302a` teal surface |
| Text primary | `#1b2521` ink | `#f3efe9` cream |
**Teal ground, cream glyph, terracotta accent** is the whole identity. Use terracotta sparingly as the
single accent; teal carries everything else.
The logo mark is a deep-teal rounded square, a cream lowercase "b" built from a stem plus a ring bowl, and
one terracotta dot. Two SVGs under `components/common/AppIcon/icons/`: `LogoMark.tsx` (monochrome
`currentColor` glyph only, registered as `ICONS.logo`) and `LogoLockup.tsx` (full colour, token-driven so
it tracks the scheme, used by `BrandMark`). **The wordmark beside it stays a real, translated
`<Typography>`** — never bake locale text into an SVG. The favicon and `public/img/favicon/*.png` are
rasterized from the same construction with fixed brand hex, which is the one place a literal hex is
correct; regenerate with a `sharp`-based script rather than hand-editing the binaries.
---
## 2. Two mirrored color homes
Colors exist in two places that must stay in sync. Pick the right one.
| Home | File | Reach it via | Use for |
| --- | --- | --- | --- |
| **MUI palette** | `theme/colors.ts` (`BRAND`, `LIGHT_PALETTE`, `DARK_PALETTE`) | `color="primary"`, `sx={{ color: 'text.secondary', bgcolor: 'background.paper' }}` | **The default** for styling a MUI component |
| **`--bal-*` CSS variables** | `theme/tokens.css`, under `[data-mui-color-scheme='light'\|'dark']` | `var(--bal-primary)` | Custom CSS outside MUI's palette, and every semantic feedback color |
- Styling a MUI component → palette keys.
- Need success / error / warning / info → **`--bal-*`, not MUI's defaults.** The MUI palette defines no
semantic colors, and these tokens are brand-harmonized.
- Need a custom color in raw CSS → add a `--bal-*` token **in both scheme blocks**, then `var(--…)`.
- **Never hard-code a hex or rgb** in `sx`, `styled`, or a component.
- Adding or changing a color means editing `tokens.css` **and** `colors.ts` together. Both file headers
call out the sync requirement.
### The token catalogue
Every token is defined under both `[data-mui-color-scheme]` blocks unless noted.
| Group | Tokens | Notes |
| --- | --- | --- |
| Brand | `--bal-primary`, `-light`, `-dark`, `-contrast`, `-soft`; same five for `--bal-secondary` | |
| Surfaces | `--bal-bg-default`, `--bal-bg-paper`, `--bal-frame-canvas` | `frame-canvas` is the backdrop `AppFrame` paints *outside* the phone-width column — **never a surface a component draws on** |
| Text | `--bal-text-primary`, `--bal-text-secondary`, `--bal-divider` | |
| Semantic | `--bal-{success,error,warning,info}` + each `-contrast` + each `-soft` | `-contrast` is the text color on that fill; `-soft` is the tinted background variant |
| Elevation | `--bal-shadow-1/2/3` | Teal-tinted (black-teal in dark). They back `theme.ts`'s `shadows` array, so every MUI elevation resolves through them — never MUI's grey stack |
| Radius | `--bal-radius-sm` 6px (controls), `-md` 8px (cards/paper, `= theme.shape.borderRadius`), `-lg` 12px (dialogs), `-pill` 999px | Reference the token, **never a numeric `sx={{ borderRadius: n }}`** — that multiplies the shape unit, which is how the login card once became a 30px pill. `MuiPaper` pins the `md` step so a Paper can't drift past it. `-pill` is for shapes that genuinely *are* pills (the floating nav, a segmented control's active chip), never a card |
| Motion | `--bal-motion-fast/base/slow` (120/200/300ms), `--bal-easing-standard` | `theme.ts` points `MuiDialog`/`MuiDrawer`/`MuiPopover`/`MuiMenu`'s `defaultProps.transitionDuration` at the same numbers, in one place, instead of MUI's per-variant defaults |
| Focus | `--bal-focus-ring` | The 2px ring `MuiCssBaseline`'s global `:focus-visible` override uses. **Don't hand-roll a focus style** — it is already uniform everywhere |
| Rating | `--bal-rating`, `--bal-rating-empty` | `RatingInput` uses these, **not** `--bal-warning` |
| Trust | `--bal-trust`, `--bal-trust-soft` | A distinct identity for verified marks — not primary, not success. `TrustBadge` and any future verification UI |
| Money | `--bal-money-emphasis` | AA-contrast-safe emphasized money text. `--bal-secondary` (terracotta) **fails AA at small sizes on light backgrounds** — never use it for money text |
| Avatar | `--bal-avatar-1..6` + each `-contrast` | Six warm pairs `InitialsAvatar` picks from by a deterministic name hash |
| Map | `--bal-pin-shadow` | The address-picker pin |
`--bal-chrome-top` / `--bal-chrome-bottom` are **not** in `tokens.css``AppFrame` publishes them at
runtime on its scroll container. See [components.md](components.md).
---
## 3. Dark mode, and the no-flash boot
The mechanism is **pure CSS. There is no boot script**, no inline `<script>`, and no
`Storage.prototype` patching — matching how every other color decision in this app is made.
**Returning visitor (cookie present)**
1. `getThemeMode()` (`lib/cookies/server.ts`) reads the `'color-scheme'` cookie → `{ colorScheme, defaultMode: colorScheme }`.
2. The root layout sets `data-mui-color-scheme={colorScheme}` on `<html>`, server-side.
3. `tokens.css`'s explicit `[data-mui-color-scheme='light'|'dark']` blocks match immediately — correct on
the very first paint, before any JS runs.
**First-ever visitor (no cookie)**
1. `getThemeMode()` returns `{ colorScheme: undefined, defaultMode: 'system' }`.
2. The root layout renders `<html>` **without** the attribute at all (React omits `undefined`).
3. `tokens.css` has a `@media (prefers-color-scheme: dark)` block scoped to
`:root:not([data-mui-color-scheme])` — it applies only while the attribute is absent, and paints the
OS-preferred scheme immediately with zero JS.
4. Once React hydrates, `<MuiThemeProvider defaultMode="system">` resolves the *same* media query and
stamps the attribute itself. The painted values already match, so nothing visibly flips.
5. `ColorSchemeCookieSync` (inside `ThemeProvider.tsx`) writes the cookie from
`useColorScheme().colorScheme` in an effect, so the next visit is a "returning visitor" — even before
the user ever touches the control.
### The known gap, by design
This covers the dominant visual surface — every `--bal-*` token — because that is what the media-query
fallback drives. MUI's own generated `--mui-palette-*` variables (consumed by a bare `color="primary"`
fill: a contained Button, the default `MuiTabs` indicator) do **not** get the same free fallback: MUI's
`colorSchemeSelector` supports attribute-based *or* `'media'`-based generation, not both at once. So on a
cookie-less first visit with OS dark on, a raw MUI-primary fill can very briefly show the light value
until hydration. It self-corrects in the same frame, and `disableTransitionOnChange` means it snaps rather
than animating.
**Prefer sourcing colors from `var(--bal-*)` over `theme.vars.palette.*` in new `styleOverrides`** — most
of `theme.ts`'s `components` block already does — to keep this gap as small as possible.
### MUI v9 traps in this area
**`colorSchemeSelector` must be the explicit attribute name.**
```ts
// theme.ts
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme', // CORRECT
// colorSchemeSelector: 'data', // WRONG
}
```
The shorthand `'data'` generates `[data-%s]` → boolean `data-dark=""` / `data-light=""` attributes. Our
`tokens.css` selects on `[data-mui-color-scheme="dark"]`, which never matches a boolean attribute, so the
whole token layer silently stops switching.
**Never use MUI's `InitColorSchemeScript`.** It reads localStorage, which diverges from our cookie
(especially in `system` mode), and it is a script — this app's no-flash boot is CSS-only. Don't add *any*
pre-paint color-scheme script; if a new token needs the same first-visit treatment, extend the `tokens.css`
media-query fallback instead.
**Never use `storageWindow={null}`.** In MUI v9's `localStorageManager` the check is
`if (!storageWindow && typeof window !== 'undefined')``null` is falsy, so it silently overrides to
`window`. The prop is a no-op in browsers.
**MUI v9's localStorage key defaults differ from v5/v6** — mode key `'mode'` (was `'mui-mode'`), color
scheme key `'color-scheme'` (was `'mui-color-scheme'`), HTML attribute `'data-color-scheme'` (was
`'data-mui-color-scheme'`). We override the attribute via `colorSchemeSelector`; the cookie is ours and is
named by `COOKIE_NAMES.COLOR_SCHEME`.
### `mode` vs `colorScheme`
Use **`colorScheme`** for an "is dark active" check. `mode` can be `'system'` even when dark is active.
The one exception is the control itself, which must read `mode`: that is the user's *choice*, while
`colorScheme` is only the resolved result. `mode` is `undefined` until MUI mounts, so default it
(`mode ?? 'system'`) rather than rendering an unselected control — server, first client render, and
pre-mount state then agree, so there is no hydration mismatch and no flash of "nothing selected".
### The one appearance control
`components/settings/ThemeModeSetting.tsx` is the **only** component that subscribes to `useColorScheme()`
and the app's only appearance control. It lives in each actor's settings hub (`/nurse/more`,
`/admin/system`, `/partner/more`, the customer profile hub) **and nowhere else** — the old top-bar toggle
spent a permanent slot of chrome in three shells on a preference set once.
It is a **three-way segmented control (light / dark / system), never a boolean switch.** `system` is the
app's real default on a cookie-less first visit, so an on/off control cannot represent the current state
and would silently misreport it.
The write path: `setMode('dark')``ColorSchemeCookieSync`'s effect writes the `'color-scheme'` cookie →
MUI sets `data-mui-color-scheme` on `<html>` → CSS variables resolve → the browser repaints. No React
re-render above the control.
### Pre-built themes
`APP_THEME_LTR` and `APP_THEME_RTL` are created once at module load. **Never call `createTheme()` inside a
component or hook** — pass the appropriate pre-built theme to `MuiThemeProvider`.
---
## 4. Direction
`getDirection(locale)` (`theme/direction.ts`) returns `'rtl'` for `fa`, `ar`, `he`, `ur`; `'ltr'` for
everything else. `ThemeProvider` takes a `dir` prop and selects the matching pre-built theme; the RTL
Emotion cache uses `stylis-plugin-rtl` to mirror all generated CSS.
The root layout sets `dir` on `<html>` and passes it to `ThemeProvider`. Because that layout is keyed on
the `[locale]` URL param, a locale change re-renders it with a fresh `dir` on both hard and soft
navigation, with no client state. Do **not** move the `<html dir>` render above `[locale]` — see
[structure.md](structure.md) §2.
**RTL correctness is a rule, not a nicety.** Never use directional hard-coding for layout flow —
`marginLeft`, `left:`, `textAlign: 'left'`. Use logical or MUI-flipped properties: `ml` (MUI flips it),
`marginInlineStart`, `insetInline`, `start`/`end`. Verify the layout visually at `/fa`, then `/en`.
Bidi text needs explicit isolation: a Latin-digit code, an IBAN, or a date·time range inside Persian prose
goes in a `dir="ltr"` span. `SessionCard` and `BookingRequestSummaryCard` are the references.
---
## 5. Fonts
Loaded **per locale**, so the Persian face is never shipped to English pages.
| Locale | Font | CSS variable | Source |
| --- | --- | --- | --- |
| `fa` (RTL) | **Mikhak** | `--font-mikhak` | `next/font/local` — woff2 in `src/app/fonts/` |
| `en` (LTR) | **Space Grotesk** | `--font-space-grotesk` | `next/font/google` — self-hosted at build time |
Rules:
- Both are declared with `preload: false`, and each `.variable` class is attached to `<html>` **only for
its own locale** — never both, never neither. A `next/font` loader called unconditionally would preload
on every route; `preload: false` ensures the file downloads only when its locale actually renders.
- Mikhak's woff2 files live in `src/app/fonts/`, **not** `public/``next/font/local` resolves paths
relative to the calling file at build time. Space Grotesk needs no local files.
- **Never load a font inside a component or page.** All font loading lives in
`src/app/[locale]/layout.tsx`.
- To add a local font: add the woff2 files, declare via `localFont` in the root layout, attach its
`.variable` class conditionally on the matching locale, and update the `BRAND_FONT_VARIABLE_*` constants
in `typography.ts`.
### Typography
`TYPOGRAPHY_LTR` (Space Grotesk headings, system-stack body) and `TYPOGRAPHY_RTL` (Mikhak for *all* text,
for full Persian glyph coverage) share one size/line-height scale (`SIZE_SCALE`), wrapped in
`responsiveFontSizes()` in `theme.ts` for per-breakpoint heading scaling. **There is no `TYPOGRAPHY`
alias** — import `TYPOGRAPHY_LTR`/`TYPOGRAPHY_RTL` explicitly, and not into components: use
`<Typography variant=…>` and let the theme apply the direction-aware family.
**Never write `fontWeight: 600`.** Neither face loads a 600 weight, so a requested 600 silently renders
full Bold. The system is **700** for headings (`h1``h6`), buttons and strong emphasis, **500** for lighter
in-text emphasis (subtitles, row labels, chip text), **400** body. It is enforced globally in
`typography.ts`; match it in any new `sx`.
Buttons are `textTransform: 'none'` at weight 700, set globally — never re-uppercase button text.
The Persian scale sets `letterSpacing: 0` on **every** variant (Persian is a joined script; tracking breaks
glyph connections), body line-height ≥1.7, heading line-height ~1.41.5 for ascender/descender room. Don't
hand-roll per-breakpoint `fontSize` overrides — `responsiveFontSizes()` already wraps both themes.
---
## 6. Motion and the reduced-motion gate
`RouteFadeIn` (`components/common/RouteFadeIn/`) is the one route-content fade/slide primitive. It wraps
`{children}`, keyed on the locale-stripped pathname so it remounts (and replays the CSS `bal-fade-in`
keyframe from `globals.css`) on navigation but never on an in-place re-render. It is mounted inside the
`ErrorBoundary` in **all five shells**, so a new page gets the motion for free with no per-page wiring.
**`prefers-reduced-motion: reduce` has exactly one gate**, in `src/app/globals.css`: a universal
`*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; … }`
media-query block.
This is deliberately a global CSS reset rather than token-only zeroing. `tokens.css` *also* zeroes the
duration tokens, but that alone would not reach MUI's own JS-driven Dialog / Drawer / Menu / Collapse
transitions, which don't read CSS custom properties. **Never add a second, component-local reduced-motion
branch** — extend this one rule if a new motion primitive needs the same treatment.
---
## 7. Toast colors
`NotistackProvider` maps every notistack variant to a `styled(MaterialDesignContent)` whose
`backgroundColor`/`color` come from the `--bal-{success,error,warning,info}` (+ `-contrast`) tokens. Because
those tokens are defined on `<html>`, they cascade into notistack's Portal and switch with the color scheme
automatically. **Never hard-code a toast color** — adjust the tokens.
Direction is inherited too: the Portal mounts under `<body>` and picks up `dir` from `<html dir>`. Do
**not** pass a `dir` prop to `SnackbarProvider` — it is not a valid prop (TS error) and is unnecessary.
+131
View File
@@ -0,0 +1,131 @@
# The documentation convention
How this repository keeps its own docs from lying. Read before writing or editing any `.md`.
> Last verified: 2026-08-02 against commit `51e86a1`.
---
## 1. One home per fact
Three trees, and a fact belongs to exactly one of them.
| Tree | Answers | Example |
| --- | --- | --- |
| [`product/`](../../product/index.md) | **What the business is** | escrow holds funds until check-out is confirmed |
| `docs/` | **What we built, and how we work** | the escrow ledger is implemented; here is how to test it |
| `archive/` | **How we got here** | the phase-10 prompt that built the ledger, and its report |
If you are about to write a business rule into `docs/`, it belongs in `product/`. If you are about to
obey something in `archive/`, stop — it is a record, not an instruction.
Two documents stay outside `docs/` on purpose:
- [`DEPLOY.md`](../../DEPLOY.md) — the deploy *procedure*, at the repo root where an operator will look.
- The three `CLAUDE.md` files — the hard-rule tier. See [rules/index.md](index.md).
Inside `docs/`, each section owns one question:
| Section | Owns |
| --- | --- |
| [`rules/`](index.md) | What must never be broken |
| [`integration/`](../integration/index.md) | The client↔server seam: contract, config, topology, OpenAPI |
| [`flows/`](../flows/index.md) | What is implemented and how to test it, one file per user journey |
| [`status/`](../status/index.md) | Where the project actually is: implemented, backlog, decisions |
| [`roadmap/`](../roadmap/index.md) | Where it goes next, and what gates a launch |
`product/` is a **structured docs tree** with a generated HTML view: the `.md` files are canonical, the
matching `.html` files are built by `cd product && node build-docs.mjs`. Edit the Markdown and
regenerate — never hand-edit the HTML. If you add or rename a `.md`, update the `NAV` manifest in
`product/build-docs.mjs` in the same change.
---
## 2. What to update when X changes
This is the anti-drift contract. Each row is enforced by review — there is no pre-commit tooling behind
it (a deliberate MVP-stage call; see [git-and-gates.md](shared/git-and-gates.md)).
| When you change… | Update, in the same change |
| --- | --- |
| An endpoint's route, shape, or status codes | [`docs/integration/`](../integration/index.md) + the OpenAPI snapshot |
| A project, layer, route group, provider, or major folder | The matching **architecture section** — see §3 |
| A user-facing flow, so it now works end to end | `docs/flows/<flow>.md` — what it does, and how to test it |
| A backlog item, so it is now done | **Tick it** in `docs/status/backlog.md`. Never delete a row — a ticked row is the record that it shipped |
| A decision that isn't derivable from the code | `docs/status/decisions.md` — the decision, the date, and why |
| A business rule you discovered or decided | The relevant `product/**.md`, then regenerate the HTML. Record decisions; do not invent rules |
| A rule, so it now says something different | The one file that owns it (see [rules/index.md](index.md)) — not a second copy elsewhere |
| A new reusable pattern, seam, base class, or hook family | A short note in the reference file for that area, so the next change reuses it instead of reinventing it |
| A mock or deferred external service | `docs/status/` — the seam (interface + file), what is faked, why, the config keys it reads, and step-by-step how to make it real |
**A mock is only sanctioned behind a DI-registered interface.** Mock and real implement the same
interface; selection is by configuration, never by an `if (mock)` scattered through the code. An
unrecorded mock is a defect.
---
## 3. Architecture sections are canonical, and there are exactly three
| Level | Canonical section |
| --- | --- |
| Repo | **"Repository layout"** in [root CLAUDE.md](../../CLAUDE.md) |
| Frontend | **"Project structure"** in [client/CLAUDE.md](../../client/CLAUDE.md), expanded in [client/structure.md](client/structure.md) |
| Backend | **"Project map"** in [server/CLAUDE.md](../../server/CLAUDE.md), expanded in [server/structure.md](server/structure.md) |
A map is only canonical if it stays accurate. **Stale instructions are worse than none** — an agent
that trusts a wrong map spends its budget in the wrong place and lands a change in the wrong layer.
An architecture section describes **patterns and boundaries**, not a file listing. If it wants to grow a
line per file, that is the signal it has stopped being a map: describe the shape of a route group, not
each page inside it. `git ls-files` already lists files, for free, and never goes stale.
---
## 4. `Last verified`
Every doc that makes a **claim about the current state of the code** carries, directly under its title:
```
> Last verified: <YYYY-MM-DD> against commit <short-sha>.
```
A doc without one is a claim, not a fact.
| Must carry it | May omit it |
| --- | --- |
| Everything in `docs/rules/`, `docs/status/`, `docs/flows/`, `docs/integration/` | Index/README files that only link onward |
| Any doc quoting a line count, a file count, a config key, or a default value | `product/` (business truth, not code state — it carries its own decision dates) |
Two companion rules:
- **Verify, don't copy.** A load-bearing claim is checked against the code or against a run before it is
written down. Re-stamping the date without re-checking is the failure mode this is designed to catch.
- **If it cannot be checked, mark it.** Prefix the sentence with `UNVERIFIED:` and say what would settle
it. An honest gap is useful; a confident guess is not.
---
## 5. Length budgets
So this doesn't regrow into the thing it replaced.
| Tier | Budget | If it overflows |
| --- | --- | --- |
| A `CLAUDE.md` | **250 lines** | Something in it is reference, not a hard rule. Move it. |
| A hard-rule list | **1525 numbered items** | The weakest items aren't hard rules. Cut them. |
| A `docs/rules/**` reference file | **400 lines** | Split by sub-topic, or you are listing where you should be describing. |
| A `docs/flows/<flow>.md` | **200 lines** | It is covering two journeys. |
The rule behind the numbers: **loading a rule must not cost 40k tokens.** That is what the old
1,098-line `client/CLAUDE.md` did to every frontend change, and it is why nobody read past the top.
---
## 6. Style
- **English throughout**, including in files that describe Persian UI copy. Quote the Persian, explain in
English.
- Prose over bullet soup for reasoning; tables for anything with more than three parallel cases.
- Link, don't restate. Two copies of a rule drift; one copy and a link cannot.
- Write the *why* down when it isn't obvious from the rule. A rule whose reason is recorded survives
contact with a case it didn't anticipate; a bare prohibition gets worked around.
+96
View File
@@ -0,0 +1,96 @@
# Rules
What must never be broken, and nothing else.
> Last verified: 2026-08-02 against commit `51e86a1`.
---
## The tiering rule
Three tiers, and a rule lives in exactly one of them.
| Tier | Where | What goes in it | Budget |
| --- | --- | --- | --- |
| **Hard rules** | [root CLAUDE.md](../../CLAUDE.md) · [client/CLAUDE.md](../../client/CLAUDE.md) · [server/CLAUDE.md](../../server/CLAUDE.md) | Constraints whose violation breaks the build, the gate, or a business invariant. Imperative, no explanation. | ≤250 lines each |
| **Reference** | `docs/rules/{shared,client,server}/*.md` — here | The *how* and the *why*. Read on demand when you are working in that area. | ≤400 lines per file |
| **Procedure** | `.claude/skills/` | Step-by-step playbooks for recurring tasks: [frontend-designer](../../.claude/skills/frontend-designer/SKILL.md) (design), [backend-feature](../../.claude/skills/backend-feature/SKILL.md) (adding a server feature), [flow-testing](../../.claude/skills/flow-testing/SKILL.md) (walking a flow end to end). | — |
The test: **a rule that only matters once you are already editing theme code is reference.** A rule like
"never change `Seams:FieldEncryption:Key`" is hard — it belongs inline where nobody can miss it.
So: open the `CLAUDE.md` for the side you are editing, then open **one** file below for the area you are
touching. Not both trees, not every file.
---
## Reference files
### Shared — both projects
| File | Covers |
| --- | --- |
| [shared/naming.md](shared/naming.md) | `Baya*` vs `balinyaar-client`, the `@/*` alias, file and directory conventions |
| [shared/git-and-gates.md](shared/git-and-gates.md) | Branches, commits, what "done" means per project |
| [shared/code-quality.md](shared/code-quality.md) | No dead code, comment the *why*, no starter scaffolding, the seam rule for mocks |
### Client — `client/`
| Working on… | Read |
| --- | --- |
| Routes, layouts, the RSC/client boundary, page metadata | [client/structure.md](client/structure.md) |
| Colors, tokens, dark mode, RTL, fonts, motion | [client/theme.md](client/theme.md) |
| The `App*` library, the icon registry, shells and navigation | [client/components.md](client/components.md) |
| Any form | [client/forms.md](client/forms.md) |
| Copy, translations, Persian orthography | [client/i18n.md](client/i18n.md) |
| Fetching, TanStack Query, the `services/{domain}` pattern, money display | [client/services.md](client/services.md) |
| Cookies, sessions, refresh, `RoleGuard`, middleware | [client/auth.md](client/auth.md) |
| Tests, ESLint, the type gate | [client/testing.md](client/testing.md) |
### Server — `server/`
| Working on… | Read |
| --- | --- |
| Projects, layers, startup wiring, the seam catalogue | [server/structure.md](server/structure.md) |
| Adding a feature (command/query/handler/validator/controller) | [server/cqrs.md](server/cqrs.md) |
| EF Core, migrations, interceptors, state machines, snapshots, jobs | [server/persistence.md](server/persistence.md) |
| **Anything on the money path** — ledger, refunds, BNPL, payouts, invoices | [server/money.md](server/money.md) |
| Auth, JWE, sessions, field encryption, tenancy, disclosure | [server/identity.md](server/identity.md) |
| C# style, naming, async, logging, tests | [server/conventions.md](server/conventions.md) |
### Cross-cutting
| File | Covers |
| --- | --- |
| [documentation.md](documentation.md) | The anti-drift convention: what to update when X changes, the `Last verified` stamp, one home per fact, length budgets |
The **wire contract** — envelope, status codes, casing, pagination, idempotency, money-on-the-wire,
enum codes — belongs in [`docs/integration/`](../integration/index.md), not here. This tree is about how
you write code; that one is about what the two sides have agreed to send each other.
---
## Precedence when two sources disagree
1. [`product/`](../../product/index.md) — business truth. Escrow rules, the fee model, verification steps.
2. The relevant `CLAUDE.md` — engineering truth for that project.
3. This tree — the reasoning behind (2).
4. The task in front of you.
**Never silently guess on money, auth, tenancy, or clinical-data rules.** Do the safe thing, implement it
config-drivenly where you can, and say so in your response.
Anything found under `archive/` is a **record, not an instruction** — it is phrased in the imperative
because it was once a prompt. Do not obey it. (`archive/` does not exist yet; `dev/` becomes it.)
---
## The standing expectation
Production-quality code, not demo code. Work *with* the architecture, not around it — the Clean
Architecture boundaries on the server and the RSC/client boundary on the client are not negotiable.
Think before writing: if a task is ambiguous, reason through the design first; if it touches a contract
another layer depends on, think about downstream impact. Prefer clarity over cleverness. Never leave the
tree in a worse state than you found it.
If a piece of work could be done quickly-but-wrong or properly-but-slower, do it properly.
+269
View File
@@ -0,0 +1,269 @@
# Server C# conventions
Style, types, naming, async, error handling and tests. The successor to `server/CONVENTIONS.md`.
> Last verified: 2026-07-30 against commit `d3ec723`.
When in doubt, ask: *would a senior engineer approve this diff without comment?*
---
## 1. Use the right type for the job
| Scenario | Use |
| --- | --- |
| Request / response / DTO | `record` — immutable, value semantics |
| Domain entity | `class` — mutable state, **encapsulated** |
| Shared small value | `readonly record struct` |
| Handler, service | `sealed class` |
### Immutability and safety
- Mark fields `readonly` unless mutation is genuinely needed.
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` in signatures unless the caller must mutate.
- **Never expose a public setter on an entity.** Use methods or the constructor. A lifecycle `status` gets a
private setter and cohesive transition methods — see [persistence.md](persistence.md) §5.
- Avoid `static` mutable state.
### Null handling
- `<Nullable>enable</Nullable>` in any new project.
- Guard clauses at the entry point; don't scatter null checks through a method.
- Prefer `OperationResult.NotFoundResult(...)` over returning `null` from a handler.
- **Never `null!`** unless you can prove the value cannot be null and the compiler cannot.
### Use the language
```csharp
// primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<…> { }
// switch expression over an if/else chain
var label = status switch
{
OrderStatus.Pending => "Pending",
OrderStatus.Shipped => "Shipped",
OrderStatus.Cancelled => "Cancelled",
_ => throw new ArgumentOutOfRangeException(nameof(status)),
};
// pattern matching
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
// collection expressions (C# 12)
List<string> tags = ["new", "sale"];
```
---
## 2. Naming
| Kind | Convention | Example |
| --- | --- | --- |
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
| Method | PascalCase | `GetUserOrdersAsync` |
| Parameter, local | camelCase | `orderId`, `userEmail` |
| Private field | `_camelCase` | `_unitOfWork` |
| Constant | PascalCase | `MaxRetryCount` |
| Generic type parameter | `T`, or descriptive `TEntity` | |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`,
`intCount`).
The `Baya.*` prefix is project naming, not the brand — see [shared/naming.md](../shared/naming.md).
---
## 3. Routing
All URL segments are `snake_case`. `SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is
registered globally via `RouteTokenTransformerConvention` and converts `[controller]` and `[action]` tokens
automatically.
```csharp
// ✅ the transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public sealed class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(…) { }
}
// ❌ hardcoded segments bypass the transformer and escape snake_case enforcement
[Route("api/v{version:apiVersion}/MyFeature")]
[HttpGet("GetBySlug")]
```
**If a method name doesn't read cleanly as a URL, rename the method.** Don't hardcode the route string — it
also breaks the dynamic-permission key, which is derived from the same route values.
The controller skeleton and authorization table are in [cqrs.md](cqrs.md) §4.
---
## 4. Async / await
```csharp
// ✅ async all the way — no .Result, no .Wait()
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
var entity = await _repository.GetAsync(request.Id, ct);
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
}
// ❌ blocks the thread, risks deadlock
var result = _repository.GetAsync(id).Result;
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
```
- **Every public async method accepts a `CancellationToken` and passes it downstream** — including into
`SaveChangesAsync(ct)` and `sender.Send(command, ct)`.
- Use **`ValueTask<T>`** for hot paths (handlers, repositories); `Task<T>` for rarely-called or always-async
methods.
- **Never `async void`** — it swallows exceptions. Use `async Task` even for an event-like callback.
- **Do not add `.ConfigureAwait(false)`** in this ASP.NET Core app. It is unnecessary here and adds noise.
---
## 5. Error handling and logging
```csharp
// ✅ expected failure — return, don't throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ❌ swallowing an exception into a generic failure
try { … } catch { return OperationResult<T>.FailureResult(…); }
```
The global `ExceptionHandler` middleware catches unhandled exceptions and logs them. **Do not add a try/catch
for unknown exceptions in a handler** — let them propagate. Catch only what you can actually handle.
Logging rules are in [identity.md](identity.md) §9: structured templates, no PII or secrets, correct level.
---
## 6. Validation
- Every command that accepts user input needs a FluentValidation validator. `ValidateCommandBehavior` runs it
automatically before the handler, and `RegisterValidatorsAsServices()` registers them.
- **Validate at the boundary** — the command or query — not deep in the domain or a repository.
- **Never validate a route-supplied id in the body command.** See [cqrs.md](cqrs.md) §3.
---
## 7. Mapping — Mapster
- Use the injected `IMapper` for entity↔DTO mapping **in handlers**.
- Register type-adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`; add new
assemblies containing mapping configs there.
- Never write manual mapping code where Mapster can infer it. Only write a custom `TypeAdapterConfig` when
shapes genuinely diverge.
- **Mapping happens in the handler after the DB query**, never in the repository — the repository projects.
---
## 8. Testing
### Arrange — Act — Assert, always
```csharp
[Fact]
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
{
// Arrange
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Result.Should().NotBeNull();
}
```
- **Test the handler directly**, not the controller — controllers are thin wrappers.
- **`NSubstitute`** for mocking: `Substitute.For<IUnitOfWork>()`.
- **Persistence tests use the in-memory SQLite context** from `Baya.Tests.Setup` rather than mocking the DB.
- Name tests `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
- One assertion *concept* per test. Multiple `.Should()` calls are fine if they verify the same outcome.
- **Don't test EF internals** (tracking, migrations) — test behaviour through the handler.
### Integration tests — the HTTP pipeline
Handler tests leave the whole HTTP stack untested: routing, the auth pipeline, middleware, and the
`OperationResult → IActionResult` translation. **Each feature area needs at least one
`WebApplicationFactory<Program>` test** in `Baya.Test.Api` (environment `Testing`, in-memory SQLite) covering:
1. **Happy path** — an authenticated request returns 200 with the right body shape.
2. **Unauthenticated** — returns 401.
3. **Validation failure** — returns 400 with field-level error detail.
```csharp
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetSomething_Authenticated_Returns200()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
var response = await client.GetAsync("/api/v1/my_feature/get_something");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
```
The recurring-job scheduler is **dormant under `Testing`**, so a background tick can't make an integration
test flaky.
---
## 9. Service registration
- Every new infrastructure service gets an extension method in that project's `ServiceConfiguration/` folder,
called from `Program.cs`. **No inline DI registration in `Program.cs`.**
- Lifetimes: **Singleton** for stateless, thread-safe services (`IHttpContextAccessor`, `IFieldEncryptor`
which *must* be a singleton, see [identity.md](identity.md) §3); **Scoped** for per-request services
(repositories, `DbContext`, handlers); **Transient** for lightweight stateless ones (validators,
transformers).
- **All NuGet versions live only in `Directory.Packages.props`.** Never add `Version=` to a
`<PackageReference>` in a `.csproj`.
---
## 10. Code organisation
- **One type per file**, file name matching the type name exactly.
- Handlers and validators live in the **same feature folder** — not in a root `Handlers/` or `Validators/`.
- A file over **~150 lines** usually means mixed concerns. Consider splitting it.
- **Partial classes are only for generated code** (source generators, EF scaffolding) — and the one deliberate
exception, `DemoLifecycleSeeder`'s `.Money.cs`/`.Social.cs` partials, which split a Development-only seeder
by domain.
- **`Program.cs` stays an orchestrator** — extension-method calls only, no logic.
---
## 11. No unused code, and comment the *why*
Both are shared rules with real teeth on this side: the gate is **zero new warnings**, and `CS0168` / `CS0219`
/ `CS0169` / `IDE0005` all surface dead code. **Delete it — don't `#pragma warning disable` it.**
The one exception: a parameter that must exist to satisfy an interface or delegate signature but is genuinely
unused. Keep it, name it conventionally, and add a one-line `// why` only if the reason isn't obvious.
Full rules, with examples of a comment that earns its place: [shared/code-quality.md](../shared/code-quality.md).
Known pre-existing warnings that must **not** be fixed unless a task says so:
[shared/git-and-gates.md](../shared/git-and-gates.md) §5.
+149
View File
@@ -0,0 +1,149 @@
# How a server feature is shaped
Adding a command, a query, a validator, and the controller action that reaches them.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. The dispatcher is not MediatR
CQRS runs on **`martinothamar/Mediator`** — a source-generator-based dispatcher. Use `ISender` / `ICommand` /
`IQuery` from that package. Any prose anywhere that says "MediatR" is wrong; do not add MediatR types or
`IMediator`.
---
## 2. The folder shape
```
Baya.Application/Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <VerbNoun>Command.cs record : IRequest<OperationResult<T>>
│ ├── <VerbNoun>Command.Handler.cs internal sealed class : IRequestHandler<…>
│ └── <VerbNoun>Command.Validator.cs AbstractValidator<Command> (omit when there is nothing to validate)
└── Queries/<VerbNoun>Query/
├── <VerbNoun>Query.cs
├── <VerbNoun>Query.Handler.cs
└── <VerbNoun>Query.Result.cs record Result(…) ← the DTO returned
```
`Features/System/Queries/Ping/` is the minimal live example — query, handler, result — surfaced by
`Controllers/V1/PingController`.
One type per file, and the file name matches the type name.
---
## 3. The rules
- **Requests are `record`s** — immutable, value semantics.
- **Handlers are `internal sealed`** — they are never used outside the Application layer.
- **Exactly one handler per request type.** No conditional dispatch.
- **Never throw for an expected failure.** Return an `OperationResult`:
| Factory | Maps to |
| --- | --- |
| `OperationResult<T>.SuccessResult(value)` | 200 |
| `OperationResult<T>.FailureResult(errors)` | 400 — validation or business-rule failure, with field-level detail |
| `OperationResult<T>.NotFoundResult(message)` | 404 |
| `OperationResult.ConflictResult(message)` | 409 — idempotency, duplicate, or an illegal state transition |
Let a genuinely *unexpected* exception propagate to the global `ExceptionHandler` middleware. Don't
try/catch unknown exceptions in a handler, and never swallow one into a `FailureResult`.
- **Contracts the handler depends on are interfaces in `Application/Contracts/`**, implemented in
Infrastructure. A handler never references a concrete infrastructure type.
- **Validators are FluentValidation** `AbstractValidator<TRequest>`, auto-registered from the Application
assembly by `AddApplicationServices` and run by the `ValidateCommandBehavior` pipeline behavior before the
handler. Validate **at the boundary** — the command or query — not deep in the domain or a repository.
```csharp
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
```
**A route-supplied id must NOT be validated in the body command.** Route values (e.g.
`patients/update/{id}`) aren't bound into the body, so a `GreaterThan(0)` on them fails every request.
- **Pipeline order is Logging → Metrics → Validate.** A new behavior slots into that chain in
`AddApplicationServices`, not into a handler.
---
## 4. The controller
Every controller follows this skeleton:
```csharp
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
- **`sealed`.** Controllers are not designed for inheritance beyond `BaseController`.
- **Inject `ISender` via the primary constructor**, not `IMediator`.
- **Never call `Ok()`, `BadRequest()`, or `NotFound()` directly.** Always `base.OperationResult(result)`
that is what maps `OperationResult` (including 401/403/409) onto the envelope every client already parses.
- **Keep the method thin: one `Send`, one `OperationResult`.** No business logic in a controller.
- **Use `[Display(Description = "…")]`** so NSwag generates meaningful Swagger tags.
- **Pass the `CancellationToken`** from the action into `sender.Send(...)`.
- **Route segments come from `[controller]`/`[action]` tokens**, which `SnakeCaseParameterTransformer`
converts. Never hardcode a route string — that bypasses the transformer. If a method name doesn't read
cleanly as a URL, **rename the method**.
### Authorization — the narrowest that fits
| Attribute | When |
| --- | --- |
| *(none)* | Truly public — health check, metrics, a webhook (which is signature-verified instead) |
| `[Authorize]` | Any authenticated user |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | A role/claim-gated admin action |
| `[RequireTokenWithoutAuthorization]` | A token must be present but may be expired — the refresh endpoint |
Apply at the **controller** level for a uniform policy; override at the action level only for a genuine
exception. Least privilege: an admin action gets `DynamicPermission`, not a bare `[Authorize]`.
Rate-limit the sensitive ones — see [identity.md](identity.md) §5.
---
## 5. To add a feature
1. Create the folder under `Features/<Area>/{Commands|Queries}/<VerbNoun>/`.
2. Implement the request, the handler, and a validator if it takes input.
3. Add any new dependency as an interface in `Application/Contracts/`, and implement it in Infrastructure —
mock and real both, if it is an external rail. See [structure.md](structure.md) §3.
4. Wire a controller action to `sender.Send(...)`.
5. Add handler unit tests (NSubstitute) **and** at least one `WebApplicationFactory` integration test for the
area: happy path 200, unauthenticated 401, validation 400. See [conventions.md](conventions.md) §5.
6. Publish the endpoint's contract to [`docs/integration/`](../../integration/index.md).
If the feature adds a table, read [persistence.md](persistence.md) first — the money, snapshot, state-machine
and soft-delete rules there are invariants, not suggestions.
+218
View File
@@ -0,0 +1,218 @@
# Server identity, encryption and disclosure
Auth, JWE, sessions, field encryption, tenancy, and the two-stage clinical disclosure rule.
> Last verified: 2026-08-02 against commit `51e86a1`.
---
## 1. Phone-OTP is the public login
There is no username/password path for a normal user. `Controllers/V1/AuthController`
(`request_otp` / `verify_otp` / `refresh` / `logout`) plus `MeController` (`/me`, `select_role`) drive the
`Features/Identity/` slices.
OTP delivery goes through the **`ISmsSender`** seam. The mock (`LoggingSmsSender`) logs the code; the real
rails are config-selected — see [structure.md](structure.md) §3.
### The OTP-capture bridge
`AddDevelopmentOtpCapture()` decorates the registered `ISmsSender` to capture each OTP in memory for
`GET /api/v1/dev/last_otp/{phone}`. It is:
- **never wired outside Development**, and
- **only** wired for a capture-safe provider — `mock`/unset, or the Development-only `telegram` relay.
**A real gateway (`kavenegar`) disables it**, so a production OTP only ever leaves the process over the SMS
wire. `TelegramSmsSender` is the one non-mock provider that keeps the bridge enabled, because it is a
**broadcast, not a gateway**: it pushes every code to a fixed list of chat ids so a human tester can read them
without grepping logs. Its API key is not committed.
`DevController` returns 404 outside Development.
---
## 2. Tokens and sessions
- **JWE** — a signed *and* AES-128-encrypted JWT — issued by `IJwtService`
(`Baya.Infrastructure.Identity/Jwt/JwtService.cs`). `GenerateAccessTokenAsync` mints an access token only
(the REST flow); the legacy `GenerateAsync` additionally writes a `UserRefreshTokens` row and still feeds the
gRPC path.
- **Every login creates a revocable `usr.UserSessions` row** storing **only the refresh token's
`IFieldEncryptor.Hash`** — never the token itself.
- **Refresh rotates**: the old session is revoked and a new pair issued.
- **A replayed or revoked token revokes ALL of the user's sessions** and returns 401. This is reuse detection,
and it is the reason the client's silent refresh is single-flight.
- **Logout revokes the session AND rotates the security stamp**, so outstanding access tokens fail the JWE
`OnTokenValidated` stamp check. Revoking the session alone would leave a valid access token live for up to
its full lifetime.
Settings bind from `appsettings.json``IdentitySettings`. `RequireHttpsMetadata` is **on outside
Dev/Testing** (passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`,
and `Issuer`/`Audience` are real (`Balinyaar` / `BalinyaarClient`).
**`SecretKey` and `Encryptkey` belong in the environment-specific file**, never in the base
`appsettings.json`, which stays at its `StartupSecretsGuard`-rejected placeholder. **Never hardcode a secret
in C#** — keys, connection strings and tokens come from configuration bound to typed settings, never a literal
in a handler or service.
---
## 3. Encrypted PII
`users.PhoneNumber` / `Email` / `NationalId` are encrypted at rest through an EF value converter over
**`IFieldEncryptor`**, wired in `ApplicationDbContext.OnModelCreating`.
Two consequences that are easy to get wrong:
- **The encryptor must stay a process-wide singleton**, because EF caches the model. A scoped encryptor gives
you a model whose converters point at a disposed instance.
- **Equality lookups go through the deterministic `PhoneHash` column** (UNIQUE, synced on `SaveChanges`
which also resets `ShahkarVerifiedAt` when the phone actually changes). **Never query `PhoneNumber == x`**:
the ciphertext is not deterministic, so the comparison silently matches nothing.
### What else is encrypted
| Column | Notes |
| --- | --- |
| `customer_profiles` emergency contact | |
| `patients.initial_medical_notes` | |
| `customer_addresses` — address line, postal code, recipient name/phone | Decrypted **only in the owner's own read** |
| `nurse_bank_accounts.iban` | Plus `UNIQUE(iban_hash)` as a deterministic-hash duplicate guard |
| `nurse_payouts.iban_snapshot` | `[AuditRedacted]`, frozen from the verified primary account |
| `partner_centers.settlement_iban` | `[AuditRedacted]`, **masked to last 4 in every read** |
| `payment_gateways.config_json` | |
| `booking_care_instructions` — every field | See §6 |
| `patient_care_records.body_encrypted` | Ciphertext with **no EF value converter** — the handler encrypts on write and decrypts only *after* the access check passes |
| `messaging.TicketMessages.Body` | Ticket bodies are the refund/dispute paper trail — phone numbers, addresses, clinical detail. Column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule |
Annotate any encrypted or PII property with **`[AuditRedacted]`** so the audit diff records a marker rather
than plaintext.
> `Seams:FieldEncryption:Key` and `:HashKey` are **load-bearing and must never change.** They decrypt all
> existing PII and derive the phone-lookup hash. Rotating them without a re-encryption migration makes every
> PII read throw and every phone lookup miss.
---
## 4. Roles and permissions
The full vocabulary is in `Domain/Entities/User/RoleNames`.
- **`SeedDataBase` always seeds the roles**, and seeds a **bootstrap admin only when
`Seed:AdminUsername`/`Seed:AdminPassword` are configured** — break-glass only. There is no committed
`admin`/`qw123321` any more. Day-to-day admins come from the phone-OTP demo seeds or are provisioned
out-of-band.
- **`customer` and `nurse` are self-selectable** via `POST me/select_role` — audited (`granted_by`,
`granted_at`), idempotent, and **both can be held** by one user (a dual session moves freely between the
family and nurse apps).
- **Admin sub-roles are internal-only** and `select_role` returns **403** for them. Never build a flow that
implies a user can grant themselves an admin role.
- **`user_roles.revoked_at` has a global query filter**, so a revoked grant disappears from every role read
automatically.
- The **dynamic permission system** (`DynamicPermissionHandler`) reads the `[controller]` + `[action]` route
values and checks role claims. **Always use the tokens** so the permission keys stay consistent — a
hardcoded route string produces a key nothing grants.
Auth knobs — `auth_otp_resend_seconds`, `auth_otp_max_attempts`, `auth_session_ttl_days` — are
`platform_configs` rows read via `IPlatformConfig`, not constants.
**`nurse_profiles.is_verified` has no public setter.** It is flipped only by the verification pipeline's
guarded cross-aggregate transition — see [persistence.md](persistence.md) §5.
---
## 5. Rate limiting
Auth and OTP endpoints **must** be rate-limited, using ASP.NET Core's built-in limiter (no extra package).
| Endpoint | Policy |
| --- | --- |
| `request_otp`, `verify_otp` | `otp`, plus a per-phone resend window via `ICacheService` |
| `refresh` | `auth` |
| The PSP and BNPL webhooks | the single deliberate `webhook` policy — bursty-tolerant, partitioned **per provider** |
| Admin money/trust actions | `sensitive` |
| Everything else | the per-resolved-IP global policy |
Behind a reverse proxy the limiter partitions on the **forwarded** client IP, which is why
`UseForwardedHeaders()` runs first and `UseRateLimiter()` runs before `UseAuthentication()`. See
[structure.md](structure.md) §4.
---
## 6. Two-stage clinical disclosure
This is the platform's central privacy invariant. A nurse learns progressively more about a patient as the
engagement becomes real, and each stage is enforced **at the query layer**.
| Stage | When | What the nurse can see |
| --- | --- | --- |
| **1** — a booking request | Before payment | **Only** the unencrypted, limited `customer_notes` — never routed through `IFieldEncryptor`. The full address is **masked** to a coarse city/district: no line, no postal code, no recipient |
| **2** — a confirmed booking | After capture | `booking_care_instructions` (every field encrypted), readable **only post-confirmation** and **only** by the **assigned nurse + admin**. `GetCareInstructionsQuery` enforces it |
Stage-2 fields are **never projected into a list and never logged.**
`patient_care_records` are **patient-scoped, not booking-scoped**, encrypted, and behind a strict access check:
the owning customer, a nurse with a confirmed booking for that patient, or an admin. Anyone else gets **403**.
The handler decrypts only *after* the check passes.
---
## 7. Tenancy
**Child rows must belong to the caller.** A patient and an address must be in the caller's `customer_id`; a
variant must belong to the requested `nurse_id`.
Two rules:
- **Resolve the owner from `ICurrentUser`, never from the request body.** A body-supplied `customer_id` is an
authorization bypass waiting to happen.
- **A mismatch is a clean 404, never a 403 and never a leak.** A 403 confirms the row exists.
The same applies to a cross-tenant booking on a review submit, and to the partner portal: a centre resolves
from the caller, never from a raw id in the URL.
**`INotificationService` and the notification endpoints are always tenant-scoped to `ICurrentUser`.**
`support_alerts` are **admin-only and must never appear on a user-facing route.**
---
## 8. `is_internal` is a hard visibility boundary
Ticket messages can be internal staff notes. **The boundary is enforced at the QUERY layer, never in the UI.**
`GetTicketThreadQuery` takes an `AsAdmin` flag:
- `false` (the user view) — the repository projection **strips every `is_internal` message**
(`GetMessagesAsync(includeInternal: false)`).
- `true` (staff only) — returns them.
A non-staff caller can never *set* `is_internal` on `PostMessage`, and can never *read* one. The client mirrors
this by not modelling `is_internal` in its user-app types at all — see
[client/services.md](../client/services.md) §5 — but **that is a second layer, not the boundary.**
Related messaging invariants:
- **There is no direct nurse↔customer channel.** All post-booking communication is ticket-mediated and
admin-readable. Participation (`TicketParticipant`, `UNIQUE(ticket_id, user_id)`, soft-remove via
`removed_at`) plus staff status *is* the authorization boundary.
- `reference_code` is minted once, collision-checked, UNIQUE, and stable.
- `booking_id` and `refund_id` links are both nullable — handle a ticket with neither.
- A coordination ticket is auto-created (idempotent, one per booking) on confirmation, dispatched from the card
confirm and the BNPL settle handlers. A refund ticket is auto-opened by `CreateRefundCommand` when the caller
supplies none, so `refunds.ticket_id` is always non-null.
- `LogEmergencyTicket` records the aftermath of an out-of-platform emergency call and **exposes no phone
number**. There is no telephony seam by design; the call is a `tel:` link.
---
## 9. Logging
- **Structured logging with message templates**, never string interpolation of values:
`_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId)`.
- **Never log passwords, tokens, secrets, or full PII.** Email is borderline — use `userId` in logs instead.
- The mock SMS sender **never logs the OTP code**; clinical text and IBANs are encrypted or masked before they
could reach a log.
- Levels: `Debug` for trace detail, `Information` for meaningful events, `Warning` for recoverable issues,
`Error` for unexpected failures. Deployed environments write Information+ to `Baya_Logs`, with framework
categories held at Warning.
+244
View File
@@ -0,0 +1,244 @@
# Server money path
IRR integers, the append-only ledger, idempotency, and the invariants of refunds, BNPL, payouts and invoices.
> Last verified: 2026-07-30 against commit `d3ec723`.
Read this before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}` or the
`payments` / `payouts` schemas. Every rule here is enforced in code **and** by a database constraint, and the
constraint is the authority.
---
## 1. Money is IRR `BIGINT`, integer-only
**Every monetary value is IRR Rials stored as `long` / `BIGINT`.** There is **no float or decimal path on
money** — not in entities, not in DTOs, not in the API, not in arithmetic. If a money value object is ever
introduced it must be integer-only.
- **Toman is display-only**, and converts to/from Rials **only inside a provider adapter at its boundary**
never in domain or shared code.
- On the wire, money is a **digit string** (IRR aggregates exceed JS's safe integer range).
- Currency is normalized to IRR **at the provider boundary only**, via `ICurrencyNormalizer`.
### The three booking amounts always reconcile
```
gross_price_irr = balinyaar_commission_irr + nurse_payout_amount (all ≥ 0)
```
This is a **DB CHECK** *and* a handler invariant. Commission is `integer-round(gross × platform_fee_rate)`
with the rate **snapshotted onto the booking**; the payout is *derived*, never free-entered.
And per session: **`Σ(visit_payout_amount) = nurse_payout_amount` exactly** — an integer split with the
remainder on the last session (`BookingAmounts`).
### A rate change is never retroactive
Money-critical constants — commission percentage, VAT rate, deadlines, cancellation tiers — live in
`ops.PlatformConfigs` and are read via `IPlatformConfig.GetConfig<T>`. **Never hardcode one.**
> **Changing a rate must never retroactively alter an already-computed amount.** The rate is snapshotted at
> compute time. Do not live-re-read a rate for an already-priced row.
---
## 2. The ledger is the source of truth
`payments.LedgerEntries` is **append-only**: it implements `IEntity` only, with **no `ITimeModification`** (so
the audit interceptor never stamps it) and **no soft delete**. There is no update or delete path.
Every posting group is **balanced** — Σdebit = Σcredit per `transaction_group_id` — and built through
**`LedgerPosting`**, which throws if the frozen amounts don't reconcile. Never hand-write a leg.
| Posting group | Legs |
| --- | --- |
| `CardCapture` | DEBIT `escrow_held` gross = CREDIT `platform_revenue` commission + `nurse_payable` payout |
| `BnplSettle` | The card-capture legs **plus** DEBIT `bnpl_fee_expense` / CREDIT `escrow_held` for the provider commission |
| `RefundReversalPrePayout` | DEBIT `nurse_payable` — a clean reversal |
| `ClawbackReversalPostPayout` | DEBIT `nurse_clawback_receivable` — the nurse was already paid |
| `RefundPayableClearing` | Posted only once the customer cash-back confirms |
| `ClawbackWriteOff` | An admin write-off |
| `NursePayout` | DEBIT `nurse_payable` / CREDIT `escrow_held` for the paid net |
| `ClawbackRecovery` | DEBIT `nurse_payable` / CREDIT `nurse_clawback_receivable` |
**Escrow IS the ledger.** `GetNursePayableBalance` is the **signed sum** over `nurse_payable` legs — never a
stored column. There is no `payout_released` boolean anywhere: paid-ness is *derived* from a
`nurse_payout_booking_links` row plus the ledger.
The lawful split is **تسهیم via `ISettlementSplitProvider`** to registered IBANs. **The platform never moves
money itself.**
---
## 3. Idempotency
Three patterns, all mandatory on this path.
**Upsert the webhook event first.** `HandlePaymentWebhook` upserts on `(provider_code, external_event_id)` and
**no-ops on a duplicate** before doing anything else. On a *new* success event it **re-verifies server-side**
(`IPaymentProvider.VerifyAsync`) — never trusting the payload — then dispatches
`ConfirmPaymentAndPostLedger`, all under `IDistributedLock("booking-request:{id}:payment")`.
**A unique-violation on confirm is an idempotent no-op success, not an error.**
**Claim first, execute second.** Persist the state claim *before* the external call. The refund row is
persisted (approved) before the channel call for exactly this reason — it is the crash-window fix, and it
matches the webhook handler's shape. A crash between claim and execute leaves a recoverable record; a crash
between execute and claim leaves money moved with nothing recording it.
**The DB constraint is the authoritative backstop** behind every friendly pre-check. The two filtered uniques
on `payment_transactions``UNIQUE(gateway_reference_code) WHERE NOT NULL` and
`UNIQUE(booking_id) WHERE status='succeeded'` — are the anti-double-capture guard, not the handler's `if`.
A **forward-only status machine** is the idempotency spine of each money entity: a replayed transition that
would re-drive a completed edge is an idempotent no-op. See [persistence.md](persistence.md) §5.
---
## 4. Capture and conversion
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
initiated against the `accepted_awaiting_payment` **request**, and `payment_transactions.booking_id` is
**nullable**, bound only when the confirm creates or loads the booking.
- A booking request carries **no money and no `bookings` row**. Accept only opens the payment window.
- Conversion goes through the shared **`BookingFactory` / `Features/Bookings/BookingConversion`** helper. The
card confirm and the BNPL settle both call it rather than re-implementing the split.
- `IPaymentCaptureSimulator` is **out of the production registration** — production gets the fail-closed
`DisabledPaymentCaptureSimulator`, and the `bookings/convert` path is a Dev/Testing affordance. Production
converts through the webhook confirm.
---
## 5. Refunds and clawbacks
A refund **decomposes across both fee legs and reverses the ledger.** `CreateRefundCommand` runs the whole
money path under `lock(booking:{id}:refund)`: it reads the booking's frozen split, the cancellation snapshot
and the captured transaction, splits `amount = platform_fee_refunded_irr + nurse_payout_refunded_irr`
**pro-rata at the resolved percentage**, enforces **`Σ refunded ≤ captured`** as a handler backstop, executes
the channel behind its seam, and posts the balanced reversal through `LedgerPosting`.
The channel-execution and ledger steps are cohesive **private** steps inside the handler, so they stay atomic.
### The pre-payout / post-payout fork
`INursePayoutStatus` answers *"was the nurse already paid?"*
| Answer | What the reversal debits | Plus |
| --- | --- | --- |
| Not yet paid | `nurse_payable` — a clean reversal | — |
| Already paid | `nurse_clawback_receivable` | Opens a `pending` `nurse_clawbacks` row **and** raises a `nurse_clawback` support alert |
The fork exists because **an Iranian IBAN transfer is irreversible.** Once money has left, the platform holds a
receivable, not a reversal.
The authoritative implementation is `NursePayoutLinkStatusService` — a booking is paid iff it is linked to a
`paid` payout.
### Channel parity
`psp_card` and `bnpl_revert` post the **same** reversal legs. Only three things differ:
| | `psp_card` | `bnpl_revert` |
| --- | --- | --- |
| Initial status | immediate `succeeded` | `processing` |
| Clearing | posts now | deferred to reconciliation |
| Customer ETA | immediate | `expected_customer_refund_eta` ≈ now + config **business** days (~710) |
The `refund_payable ↔ escrow_held` clearing posts **only once the customer cash-back confirms** — reached by
`ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, or the BNPL cash-back
callback branch), which transitions `processing → succeeded`, stamps the settled instant, and posts
`RefundPayableClearing` in the same commit, idempotently under the refund lock.
`MarkRefundSettlementFailedCommand` is the counterpart.
The canonical wire code for the manual channel is **`manual`** (the data model calls it `manual_bank`).
**Clawback recovery is the payout engine's job** (§7), not the refund's. A refund only opens the receivable and
supports an admin `write_off`.
`refunds.ticket_id` is always non-null — `CreateRefundCommand` auto-opens a `category=refund` ticket when the
caller supplies none.
---
## 6. BNPL — provider-financed installments
**In our books, a BNPL order is a card payment that lands net-of-fee.** There is no customer-installment
tracking on our side: the provider owns the schedule and **100% of the default risk**.
- `BnplTransactions` is **1:1 with its `payment_transaction`** (`UNIQUE(payment_transaction_id)`).
- The forward-only machine is `eligible → token_issued → verified → settled → reverted/cancelled/failed`
(`BnplTransitions`), mutated only through the entity's `mark-*` methods.
- **Settle** posts the net-of-fee group (§2) so escrow reflects the **net** cash
(`settled_amount_irr = order commission`), and confirms the parent `payment_transaction` — which triggers
the booking conversion — exactly like a card capture.
- **The nurse's payout is invariant to payment method.** `nurse_payable` comes from the booking split
(`gross commission`), **never** from `settled_amount_irr`. **The BNPL commission is a platform expense.**
- **`settled_at` is per-transaction and nullable** — never assume it is instant. The commission is read from
the **actual settlement**, never hardcoded.
- **Revert reuses the refund path** with `refund_channel='bnpl_revert'`. Money flows
customer ↔ provider ↔ Balinyaar only.
- `IBnplProvider` is selected per `provider_code` by `IBnplProviderResolver`. **`balinyaar` is the in-house
provider** and resolves to the net-of-fee model with no external API.
- `bnpl_settlement_entries` (tranched settlement) is **deferred — modelled but not built.** Do not create it.
---
## 7. Weekly payouts
- **Eligibility ≠ completed.** A booking enters a batch only when `status='completed'` **AND**
`dispute_window_ends_at < now` **AND** it has no active refund **AND** it isn't already in a link row.
`SetDisputeWindow` is the only eligibility trigger:
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)`.
- **One payout per booking, forever.** `nurse_payout_booking_links.booking_id` is an **unconditional** UNIQUE —
*not* filtered on soft-delete. The "not already linked" filter is the fast first line; the UNIQUE is the
backstop.
- **The payout drains `nurse_payable`.** A netted clawback posts `ClawbackRecovery` and marks the
`nurse_clawbacks` row `recovered` (`recovered_in_payout_id` + `resolved_at`). **Netting recovers WHOLE
pending clawbacks up to earnings** — never a negative net, never a partial single-clawback recovery.
- **`net = gross clawback`** is a DB CHECK on `NursePayouts`. `iban_snapshot` is **encrypted** and
`[AuditRedacted]`, frozen from the verified primary account.
- **Holiday-aware.** `period_end` and `processing_date` shift off `is_bank_closed` days via
`IHolidayCalendar`; a retry **refuses on a bank-closed day**.
- **First-payout gate.** Only an account with `is_primary=1 AND is_verified=1 AND matched_national_id=1` is
paid. A nurse without one is **skipped with a recorded reason**, never silently.
- **A retried process never double-sends an irreversible transfer**: the forward-only `PayoutStatus` machine,
the ledger-exists guard, and a batch idempotency key together.
- `IBankTransferProvider` is the PAYA/SATNA rail; PAYA vs SATNA is chosen by the `payout_satna_threshold_irr`
config. The real Jibit adapter is **async**: it accepts as `submitted`, and the HMAC-verified callback
`POST webhooks/payouts/{provider}``ReconcilePayoutBatchCommand` flips `submitted → paid/failed`.
- The BNPL `settled_at` guard is the default-off `require_bnpl_settlement_for_payout` flag.
### Money movement stays human-approved
The `weekly_payout_generation` job schedules **generation only** — a `draft` batch, recorded system-initiated
(`NursePayoutBatch.InitiatedByAdminId` nullable = "no human initiator"). **The irreversible `process` step
remains an explicit admin action**, and `AdminPayoutsController` **neutralizes any request-supplied
`SystemInitiated` value** — that flag is scheduler-only.
---
## 8. Invoices
- **VAT is on the commission line only**: `vat_irr = round(platform_commission_irr × vat_rate)` (config
`vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0). **Never on the nurse payout.**
- **The invoice number is gap-free and sequential**, drawn from the single-row `InvoiceNumberSequences` counter,
locked and committed with the invoice — **portable across SQL Server and SQLite, so no DB sequence.**
- **Idempotent per booking** (`UNIQUE(booking_id)`).
- The issuing entity follows the **merchant-of-record resolver**: booking → nurse → `partner_center_id`, and the
target is the partner centre **only** when it `is_merchant_of_record`, else `platform`. Never a hardcoded
platform.
- `IMoadianClient` submits to سامانه مودیان; the mock leaves `moadian_status = pending` with no reference. A
`MoadianReconciliationJob` walks `pending/submitted → registered` every 6 hours.
---
## 9. Cancellation
The applicable `cancellation_policies` tier is resolved by **`(actor, lead-time bucket)`**, and its `code` +
`refund_percentage` + the computed refundable amount are **frozen onto the booking**.
**Only still-`scheduled` sessions are refundable.** A session already started or completed is not, and the
per-session split is what makes a partial refund on a multi-session package correct.
Cancellation itself **posts no refund ledger** — it snapshots the policy and computes the refundable amount.
The reversal is the refund path's job (§5).
+382
View File
@@ -0,0 +1,382 @@
# Server persistence
EF Core rules, money, state machines, snapshots, the scheduler, and the domain invariants that live in the
database.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. EF Core basics
```csharp
// ✅ project to a DTO in the query
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads the entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);
```
- **Always `AsNoTracking()`** on a read-only query.
- **Always project with `.Select()`** in a query — never hydrate full entities just to map them, and **never
return an entity from a handler**.
- **Pagination is mandatory** on any unbounded list (`Skip`/`Take`). No unbounded `ToListAsync()`.
- Use `Include` **only** in a command handler that needs navigation properties loaded to mutate the aggregate.
- **Access the DB through `IUnitOfWork`** in Application handlers. `ApplicationDbContext` is referenced
directly only inside Infrastructure.
- **Commit once per command**, at the end: `await unitOfWork.CommitAsync(ct)`.
- **One `IEntityTypeConfiguration<T>` per entity**, in `Persistence/Configuration/<Area>Config/`.
- **Mapster maps in the handler after the query**, never in the repository. Only write a custom
`TypeAdapterConfig` when shapes genuinely diverge; register scans in `Program.cs`.
- **Never concatenate raw SQL.** EF parameterizes automatically. If you must drop to SQL, use
`FromSqlInterpolated`, never `FromSqlRaw` with user data.
**Migrations:**
```bash
dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api
```
### Migrations are split from boot
`dotnet run -- migrate` (the deploy-time one-shot, or a CI `dotnet ef database update`) applies migrations
plus the idempotent seeders, then exits — so multi-instance boots never race on DDL and the runtime login
needs no permanent DDL rights.
| Environment | What boot does |
| --- | --- |
| Development | Migrates + seeds (roles always; a bootstrap admin **only if** `Seed:AdminUsername`/`Seed:AdminPassword` are configured), plus the Development-only gateway, demo-world and demo-lifecycle seeders |
| Deployed | Only **checks** the schema is current (`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles / the break-glass admin, idempotently |
A reachable SQL Server is required to start.
### Soft delete
Every soft-deletable entity **must** declare a global query filter in its configuration:
```csharp
builder.HasQueryFilter(o => !o.IsDeleted);
```
Without it, soft-deleted rows appear in every query that doesn't explicitly exclude them — a silent data
leak. **Never add `Where(x => !x.IsDeleted)` per query**; the filter makes it automatic and auditable.
**Deactivate, never hard-delete.** `user_roles.revoked_at` has the same treatment, so a revoked grant
disappears from every role read automatically.
---
## 2. Audit
| Field | Type | Set by |
| --- | --- | --- |
| `CreatedAt` / `ModifiedAt` | `DateTimeOffset` | `AuditFieldInterceptor` |
| `CreatedById` / `ModifiedById` | `int?` | `AuditFieldInterceptor`, via `ICurrentUser` |
The base type is `BaseEntity` / `IAuditableEntity` (`Baya.Domain/Common/`). Stamping happens in
`AuditFieldInterceptor` (a `SaveChangesInterceptor` in `Persistence/Interceptors/`) which reads time from
`IDateTimeProvider` and the user from `ICurrentUser`**not** in the `DbContext`, and **not** in a handler.
Audit fields cannot be backfilled retroactively, so design them in from the start.
### The append-only audit trail
Mark a compliance-sensitive entity with **`IAuditable`** and the interceptor writes an old/new diff row into
`ops.AuditLogs` **in the same transaction as the change**. Annotate any encrypted or PII property with
**`[AuditRedacted]`** so the diff records a marker, never plaintext.
`audit_logs` is **immutable — there is no update or delete path in app code.** Current `IAuditable` entities:
`PlatformConfig`, `PartnerCenter`, `Review`, and the admin-decided money and trust entities `Refund`,
`NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`.
Retention is a two-tier sweep via `IAuditLogger.PurgeExpiredAsync`: financial and verification entity types
keep `audit_retention_financial_days` (default 2555 ≈ 7 years), everyday rows `audit_retention_general_days`
(default 730 ≈ 2 years). Oldest-first, capped, id-keyed delete, idempotent.
---
## 3. Config is rows, read at compute time
Money-critical constants — commission percentage, VAT, deadlines, EVV tolerance, cancellation tiers, job
cadences — live in `ops.PlatformConfigs` and are read via **`IPlatformConfig.GetConfig<T>`** (cached, parsed by
the row's `data_type`). **Never hardcode one.**
And the corollary, which is the part that actually matters:
> **Changing a rate must never retroactively alter an already-computed amount.** A rate is **snapshotted onto
> the booking or invoice at compute time**. Do not live-re-read a rate for an already-priced row.
The DB-backed platform facades — `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`,
`INotificationService`, `ISupportAlertService` — live in `Persistence/Services/` and are the contracts other
domains reuse. **Don't re-query those tables directly.** `IAnalyticsSink` is fire-and-forget and never fails
the caller; `INotificationService` is always tenant-scoped to `ICurrentUser`; `support_alerts` are admin-only
and must never appear on a user-facing route.
### Self-committing facades come *after* the atomic commit
`ISupportAlertService.RaiseAsync`, `INotificationDispatcher.DispatchAsync`, `IAuditLogger.WriteAsync` and
`IPlatformConfig.SetConfig` each call `SaveChanges` on the **shared scoped** `DbContext`. Calling one
mid-build flushes your partial tracked changes. **Invoke them only after `unitOfWork.CommitAsync()`.**
In a batch loop that commits per item: load and guard **every** dependency *before* mutating tracked state, or
an early `continue` leaks a dirty entity that a later iteration's commit will flush.
---
## 4. Money
**Money has its own file: [money.md](money.md).** IRR `BIGINT` integers, the append-only balanced ledger, the
three-amount reconciliation, webhook idempotency, and the refund / BNPL / payout / invoice invariants all live
there. Read it before touching anything under `Features/{Payments,Refunds,Invoices,Bnpl,Payouts}`.
The one line to carry in your head meanwhile: **money is an integer number of IRR Rials, and there is no float
path on it anywhere.**
---
## 5. Forward-only status machines
When an entity has a lifecycle `status` with a fixed set of allowed transitions, model the machine as a
**static allowed-edges table** and route **every** write through it. Never assign `status` ad hoc.
- **Statuses are `const string` codes**, persisted as the stable snake_case string — no C# enum, no value
converter needed.
- **Edges live in a static `CanTransition(from, to)`** built from a
`Dictionary<string, IReadOnlyCollection<string>>`; a terminal state maps to an empty set.
- **The entity owns the transition.** `status` has a **private setter**, and the only mutators are cohesive
domain methods (`Accept`/`Reject`/`Cancel…`) calling a private `Transition(target)` that asserts the edge is
legal — throwing on an illegal edge, because that is a programming error, since the handler pre-checks.
Side-effect fields are set in the same method.
- **The handler pre-checks and returns a clean 409**:
`if (!entity.CanTransitionTo(target)) return OperationResult.ConflictResult(...)`. Never throw for the
expected "already moved / terminal" case.
- **A replayed transition that is already complete is an idempotent no-op**, not a failure.
Machines in the codebase: `BookingRequestTransitions`, the `bookings` machine, `BnplTransitions`,
`PayoutBatchStatus`/`PayoutStatus` transitions, `VerificationStatus`, `ReviewModerationStatus`.
### When the enum is a C# enum
Persist it as its **stable snake_case code** via `HasConversion(e => e.ToCode(), s => Parse(s))` (see
`VerificationCodes`) so the DB and the wire carry `in_review`, not `InReview`. Enum→code mapping in a
projected read happens **in memory after materialization**`.ToCode()` is not LINQ-translatable. DTOs expose
the code string.
### Guarded cross-aggregate flips
When one write must atomically change a header row's state **and** a derived boolean on a *different*
aggregate (`nurse_verifications.status``nurse_profiles.is_verified`): load **both** as tracked entities,
mutate them through a single pure domain helper (`VerificationAggregator.Finalize`), then `CommitAsync`
**once**. Never flip the derived flag from a controller, a partial write, or an out-of-band update, and never
leave an in-between state.
`NurseProfile.is_verified` has **no public setter** for this reason.
### Two SQL Server / SQLite portability rules
- **A deadline column that is compared or sorted uses `DateTime` (UTC `datetime2`), not `DateTimeOffset`**
the SQLite test provider cannot translate `DateTimeOffset` comparison or `ORDER BY`. Order lists and sweeps
by `Id` for the same reason.
- **Sequential numbers come from a counter row, not a DB sequence** (`InvoiceNumberSequences`), locked and
committed with the row it numbers, so it is portable and gap-free.
---
## 6. Uniqueness patterns
| Need | Pattern |
| --- | --- |
| A nullable column must participate in uniqueness | **The filtered-index pair.** SQL Server treats NULLs as distinct, so `district_id = NULL` needs `UNIQUE(nurse_id, city_id) WHERE district_id IS NULL` **plus** `UNIQUE(nurse_id, city_id, district_id) WHERE district_id IS NOT NULL`, both `AND deleted_at IS NULL` |
| "No two rows may share the same *set* of child rows" | **A deterministic set-hash.** `Baya.Application.Common.OptionSetHash.Compute(pairs)` sorts the `(long, long)` pairs and SHA-256s them into a stable, **order-independent** 64-char hex hash. Persist `NVARCHAR(64)` and back it with a filtered unique index as the race-safe backstop, plus a handler pre-check for a friendly 409. **Do not reuse `IFieldEncryptor.Hash`** — that is for PII equality lookups |
| One-per-parent, forever | An **unconditional** UNIQUE, not filtered on soft-delete — `nurse_payout_booking_links.booking_id` |
| One flagged row per parent | A filtered UNIQUE plus clear-then-set in one transaction — `UNIQUE(customer_id) WHERE is_primary=1 AND deleted_at IS NULL` |
| PII equality lookup | A deterministic hash column, UNIQUE, synced on `SaveChanges``users.PhoneHash`. See [identity.md](identity.md) |
A duplicate returns **409** through `OperationResult.ConflictResult``BaseController`'s 409 mapping.
---
## 7. Snapshots freeze history
A row that represents a past agreement must not change when its sources are edited later. Frozen at their
moment, and never mutated afterwards:
- `variant_snapshot_json` (via `IVariantSnapshotSerializer`) and the **encrypted** `address_snapshot_json`
- `platform_fee_rate` on the booking
- The resolved cancellation policy `code` + `refund_percentage`
- `iban_snapshot` on a payout (**encrypted**, `[AuditRedacted]`), frozen from the verified primary account
- Deadlines: `nurse_response_deadline_at` = `now + config`, `payment_deadline_at` = `now + config` — both
stored as **absolute UTC**, so a later config change cannot move them
A later edit to the source variant, address, or policy **never** mutates an existing booking.
---
## 8. The search projection
`search.NurseSearchIndices` is **one flat row per (bookable variant × covered service area)** — a fan-out
denormalization carrying the variant's category/price/unit, the covered `city_id`/`district_id`, the nurse's
gender and rating aggregates, and one visibility gate. It is a **read-only projection**, written only by
`ISearchIndexMaintainer`.
Three invariants:
- **`is_searchable = 1` only when** the nurse `is_verified = 1` **AND** `nurse_verifications.status !=
'suspended'` **AND** `is_accepting_bookings = 1` **AND** the variant `is_active = 1` — recomputed on **every**
relevant source write. An unverified, paused, suspended, or deactivated nurse or variant must **never**
surface.
- **`district_id = NULL` means whole city, both directions.** A city search matches every row in the city; a
district search matches that district's rows **plus** the NULL-district rows.
- **Incremental maintenance and a full rebuild must converge.** The index is fully re-derivable from source;
`RebuildAsync` is idempotent.
The maintainer keeps the index consistent **inline, inside the source write's own unit of work** — it shares
the request-scoped `DbContext`, so it only *stages* changes and the handler's single `CommitAsync` flushes
source and projection atomically. It reads the facts a trigger does not change from the DB, and takes the
facts it *does* change as **tracked arguments**, so it never reads a stale pre-commit value. It resurrects a
soft-deleted row on re-upsert, so each (variant × area) has exactly one live row.
`INurseSearch` (read) reads **only `is_searchable = 1`** rows. Callers depend on the interface, so a later
Elasticsearch backend is a config-selected drop-in.
**Coverage is named districts, not GPS radii.** Address lat/lng exists only for the EVV distance check; it is
never used for coverage matching.
---
## 9. Reference-data caching
Public and reference reads are cached through `ICacheService` behind a **generation-token key scheme**
`GeoCache`, `CatalogCache`, `ReviewCache`. Any admin write to that area **bumps the token**, which invalidates
the whole namespace at once rather than enumerating keys.
The catalog is **EAV/data, not code**: an admin adds a category or a pricing dimension as *rows*, never a
migration. The only closed code enum in the area is `PriceUnits`. A `ServiceOptionGroups.ServiceCategoryId =
NULL` marks a **cross-category** dimension that applies to every category — and "applicable groups" means the
category's own groups **plus** every NULL group, everywhere: public browse, required-group validation, and the
duplicate guard. All required groups must be answered; one value per dimension.
**The bookable unit is the variant, not the nurse.** Keep it a clean projectable source. The engagement total
is `price` + `price_unit` + `session_count` — never `price` alone.
---
## 10. Domain invariants that live here
The rules a change in these areas must not break. Each is enforced in code *and* by a constraint.
**Bookings and EVV**
- A `bookings` row exists **only** when the nurse accepted **and** payment was captured. So a payment is
initiated against the `accepted_awaiting_payment` *request*, and `payment_transactions.booking_id` is
**nullable**, bound only when the confirm creates or loads the booking.
- Conversion goes through the shared `BookingFactory` / `BookingConversion` helper — the card confirm and the
BNPL settle both call it rather than re-implementing the split.
- A booking request carries **no money and no `bookings` row**; accept only opens the payment window.
- **EVV is per session, and a mismatch is advisory.** Check-in computes the distance to the *frozen* booking
address against `evv_location_tolerance_meters`; a mismatch raises a `location_mismatch` support alert and
notifies **without blocking**. GPS-denied still checks in, flagged null.
- **`SetDisputeWindow` is the only payout-eligibility trigger.** Completion sets
`dispute_window_ends_at = completed_at + config(dispute_window_hours, 72)` and each completed session's
`payout_eligible_at`.
- **Cancellation refunds only un-started sessions**; the applicable policy tier is resolved by
`(actor, lead-time bucket)` and frozen onto the booking.
**Refunds, clawbacks, invoices, BNPL, payouts** → all in [money.md](money.md).
**Reviews**
- Reviews are for **completed/closed bookings only, owned by the caller, 1:1** (`UNIQUE(booking_id)` is the
backstop; the handler pre-checks for a clean 409). A cross-tenant booking is a **404**, never a leak.
- **Recompute the nurse aggregate from source on EVERY transition — not a delta.** Read
`COUNT`/`SUM(rating)` over the nurse's currently-`published` reviews *excluding* the transitioning review,
fold that review's *new* status in memory, set the guarded aggregates, and stage the reindex — all in the
**same transaction** as the status change. The exclude-and-fold avoids a stale pre-commit re-query. This is
the fix for inflated-rating-after-hide drift.
- **`pending_moderation` is never public** — list and aggregate filter to `published` at the query layer.
- `rating <= min_rating_for_support_alert` (config, default 2) raises a support alert **reliably** — after the
main commit, never silently swallowed.
**Partner centres**
- Merchant-of-record resolution follows `partner_centers` through the single resolver, **not a hardcoded
platform**: booking → nurse → `partner_center_id`, and the issuer/settlement target is the centre **only**
when it `is_merchant_of_record`, else `platform`.
- `partner_centers` (the licensing *sponsor*) **≠** `organizations` (the future *employer*, deferred).
`settlement_iban` is encrypted, `[AuditRedacted]`, and **masked to the last 4 in every read**. The centre's
`commission_rate` is separate from `platform_fee_rate`.
**Deferred by design — do not create these tables:** `bnpl_settlement_entries`, `organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`.
---
## 11. The recurring-job scheduler
A single in-process scheduler, `Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives
every registered `IRecurringJob` on its own cadence — using **no new infrastructure**, so SQL Server stays the
only external dependency.
| Job | Cadence |
| --- | --- |
| `booking_request_expiry` | 1 min (const) |
| `notification_retention` | 24 h (const) — the predicate is exactly `is_read = 1 AND age > 90d`; **unread is never auto-deleted** |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` |
| `no_show_sweep` | `no_show_scan_cadence_hours` |
| `weekly_payout_generation` | `nurse_payout_interval_days` |
| `MoadianReconciliationJob` | 6 h |
| `audit_log_retention` | `audit_retention_scan_cadence_hours` |
- **Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in
`AddPersistenceServices`. The scheduler owns the per-tick DI scope, error isolation (a throwing tick never
kills the loop), and the lock. A job says only *how often* and *what one idempotent run does*.
- **Jobs must be idempotent.** A retry — or a second instance, once the lock is Redis-backed — must never
double-pay or double-post. The DB uniques and state machines are the backstop. Each tick runs under
`IDistributedLock("scheduler:{name}")`, which is in-process today and is **the >1-instance scale-out gate**:
swap the seam to Redis to serialize ticks across nodes. A single-instance MVP needs neither Redis nor
Hangfire/Quartz.
- **Money movement stays human-approved.** The payout job schedules *generation* only — a `draft` batch,
recorded system-initiated (`InitiatedByAdminId` nullable = "no human initiator"). The irreversible `process`
step remains an explicit admin action, and `AdminPayoutsController` **neutralizes any request-supplied
`SystemInitiated` value**.
- **Admin manual triggers are overrides**, running the same idempotent commands.
- **The scheduler is dormant under the `Testing` environment**, so integration tests stay deterministic. Each
job and command is unit-tested directly.
- A time-sensitive command **self-guards** against a passed deadline via `IDateTimeProvider` rather than
trusting that a sweep has run; a sweep's re-queried `WHERE status = …` predicate **is** the concurrency guard
— a row a racing action moved is simply not reloaded.
---
## 12. Development seeders
Both are **Development-only** and idempotent.
- **`DemoWorldSeeder`** — a coherent demo marketplace on top of the reference `HasData` seeds: 3 nurses (2
verified with variants, Tehran coverage, `approved` verification, credentials and a `matched_national_id`
bank account; 1 unverified), 2 customers with patients and addresses, **2 phone-OTP admins** (a `super_admin`
plus a scoped `finance` operator, so the console is reachable through the normal login and capability gating
is demonstrable), and one cross-category required option group.
- **`DemoLifecycleSeeder`** (+ `.Money.cs` / `.Social.cs` partials) — a full lifecycle world layered on those
personas so every flow is manually testable: booking requests in every status, 8 bookings across every
reachable state, the balanced payment ledger behind each, refunds on all three forks, a paid and a draft
payout batch, moderated reviews with recomputed aggregates, tickets (including an `is_internal` note),
notifications, patient care records, a merchant-of-record partner centre, and a mid-pipeline verification
case.
Three rules they establish:
1. **Write through the real entities and commands** — the guarded transition methods, `BookingFactory`,
`GeneratePayoutBatch`/`ExecutePayoutBatch`, `LedgerPosting`, `OpenTicketCommand`. Business timestamps are
backdated explicitly. (Application grants `InternalsVisibleTo` to Persistence for this.)
2. **Drive the search projection through `ISearchIndexMaintainer.RebuildAsync`** — never hand-insert index
rows.
3. **Never guard idempotency on a Persian string.** The `ApplicationDbContext` save hook normalizes Persian
digits and ZWNJ in every stored string, so a Persian literal **never round-trips equal**. Guard on a phone
number, a code, or another natural key.
+209
View File
@@ -0,0 +1,209 @@
# Server structure
The layers, the projects, startup wiring, and the seam catalogue.
> Last verified: 2026-07-30 against commit `d3ec723` — 14 `.csproj` projects, 55 V1 controllers.
---
## 1. Clean Architecture, and the one hard boundary
**Dependencies point inward.**
```
Domain ← Application ← Infrastructure
← API
```
- **Domain** references nothing.
- **Application** references only Domain.
- **Infrastructure** and **API** implement and consume Application contracts.
- **Never** make Domain or Application reference Infrastructure or the API. This is not a preference; it is
the thing that keeps handlers unit-testable and lets a mock become a real vendor without touching a caller.
## 2. The projects
```
src/
├── Core/
│ ├── Baya.Domain Entities, value objects, status-code sets, transition tables
│ └── Baya.Application Features/ (CQRS slices) · Contracts/ (the seams) · Models/ · pipeline behaviors
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext · ValueConversion/ · Repositories/ · Configuration/<Area>Config/ · Migrations/ · Interceptors/ · Services/ (DB-backed facades, Scheduling/, Search/, Seeding/)
│ ├── Baya.Infrastructure.Identity Jwt/ · Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring · Seams/ (mocks) · Seams/Real/ (vendor adapters) · AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready) · OpenTelemetry
├── API/
│ ├── Baya.Web.Api Program.cs · Controllers/V1/ · appsettings*.json
│ ├── Baya.WebFramework BaseController · Filters/ · Middlewares/ · Swagger/ · Routing/ · ServiceConfiguration/
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup, TestFieldEncryptor)
├── Baya.Test.Infrastructure.Identity xUnit identity tests
├── Baya.Test.Foundation Cross-cutting plumbing + identity handler unit tests
└── Baya.Test.Api WebApplicationFactory integration tests (in-memory SQLite, env "Testing")
```
**Domain entity folders**, one per bounded area: `User/`, `Identity/`, `Geography/`, `Catalog/`,
`Verification/`, `Search/`, `Booking/`, `Payments/`, `Refunds/`, `Invoices/`, `Bnpl/`, `Payouts/`, `Reviews/`,
`Messaging/`, `PartnerCenters/`, plus `Configuration/`, `Audit/`, `Analytics/`, `Holidays/`,
`Notifications/`, `SupportAlerts/`. `Common/` holds `BaseEntity`, `IEntity`, `ITimeModification`,
`IAuditableEntity`, `IAuditable`, `[AuditRedacted]`.
**Application feature areas** mirror them: `Identity`, `Geography`, `ServiceAreas`, `Addresses`, `Catalog`,
`Variants`, `Verification`, `Search`, `Booking` (singular — pre-payment requests), `Bookings` (plural — the
post-payment engine), `Payments`, `Refunds`, `Invoices`, `Bnpl`, `Payouts`, `Reviews`, `PatientCareRecords`,
`Messaging`, `PartnerCenters`, `Configuration`, `Audit`, `Analytics`, `Holidays`, `Notifications`,
`SupportAlerts`, `System`.
> `Booking` (singular) and `Bookings` (plural) are **different areas, not a rename.** A booking request is
> the money-free pre-payment intent; a booking exists only after capture. The entity type `Booking` is
> aliased where the two namespaces collide. The same split is load-bearing in the client's
> `bookingRequests`/`bookings` domains and in Persian copy («درخواست رزرو» vs «رزرو»).
**Database schemas**, one per area, mirroring how Identity uses `usr`: `usr`, `ops`, `geo`, `catalog`,
`verif`, `search`, `booking`, `payments`, `payouts`, `reviews`, `messaging`, `partner`.
**Keeping this current is mandatory.** When a change adds, removes, or renames a project, a layer, or a major
folder, or changes a cross-layer dependency, update the **Project map** in
[server/CLAUDE.md](../../../server/CLAUDE.md) and this section in the **same** change. A map is only canonical
if it stays accurate.
---
## 3. The seams
The Application layer defines every mock-able external dependency as an interface. Implementations live in
Infrastructure and are chosen by **registration**, never by a branch in a handler.
| Contracts folder | Seams |
| --- | --- |
| `Contracts/Common/` | `IDateTimeProvider`, `IFieldEncryptor`, `ICacheService`, `IObjectStorage`, `INotificationDispatcher`, `IGeocoder`, `IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`, `ILicenseVerificationService`, `IBankAccountOwnershipVerifier`, `IVariantSnapshotSerializer`, `IPaymentCaptureSimulator`, `ISmsSender`, `ICurrentUser` |
| `Contracts/Payments/` | `IPaymentProvider`, `ISettlementSplitProvider`, `IWebhookVerifier`, `IDistributedLock`, `IBnplProvider`, `IBnplProviderResolver`, `ICurrencyNormalizer`, `IBankTransferProvider`, `IMoadianClient`, `INursePayoutStatus` |
| `Contracts/Search/` | `INurseSearch` (read), `ISearchIndexMaintainer` (write) |
| `Contracts/Reviews/` | `IReviewModerationService` (the AI pre-screen) |
| `Contracts/Persistence/` | The per-domain repositories, all exposed on `IUnitOfWork` |
| Platform facades | `IPlatformConfig`, `IHolidayCalendar`, `IAnalyticsSink`, `IAuditLogger`, `INotificationService`, `ISupportAlertService` |
### Where each implementation lives
| Kind | Location | Registered by |
| --- | --- | --- |
| Mocks | `CrossCutting/Seams/` | `AddCrossCuttingSeams(configuration)` — config section `Seams` |
| Real vendor adapters | `CrossCutting/Seams/Real/` | the same, selected per rail |
| Platform facades (DB-backed) | `Persistence/Services/` | `AddPersistenceServices`**not** CrossCutting, because they are DB-backed |
| `ICurrentUser` | `Infrastructure.Identity` | `RegisterIdentityServices` |
Audit fields are stamped by `AuditFieldInterceptor` (Persistence), never in a handler.
### Real rails are config-selected, and the default falls closed
Every vendor rail has a real HTTP adapter, selected by a per-rail **`Seams:*:Provider`** key in
`AddCrossCuttingSeams`. **The default is the mock, and a typo falls closed to the mock** — so an unconfigured
environment behaves exactly as before, and a misconfigured one does not silently reach a live vendor.
Real adapters use `HttpClient` (typed via `IHttpClientFactory`), `System.Text.Json`, and BCL crypto —
**no new NuGet packages**. Credentials come from `Seams:*`.
| Rail | Selector | Adapter |
| --- | --- | --- |
| SMS/OTP | `Sms:Provider=kavenegar` | `KavenegarSmsSender`**launch-critical** |
| SMS/OTP (demo) | `Sms:Provider=telegram` | `TelegramSmsSender` — a **broadcast, not a gateway** |
| Shahkar / KYC / IBAN ownership | `{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech` | `Finnotech*`, shared `Seams:Finnotech` creds |
| Geocoding | `Geocoding:Provider=neshan` | `NeshanGeocoder` |
| Object storage | `ObjectStorage:Provider=s3` | `S3ObjectStorage` — MinIO/S3/ArvanCloud via manual AWS SigV4; presigned GET is the real signed-URL contract |
| PSP | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` + `HmacWebhookVerifier` + `ProviderSettlementSplitProvider` |
| BNPL | `Bnpl:Provider=real` | `SnappPayBnplProvider` / `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`. **`balinyaar` = in-house, resolving to the net-of-fee model with no external API** |
| Bank transfer | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` — an **async rail**: it accepts as `submitted`, and the HMAC-verified reconciliation callback `POST webhooks/payouts/{provider}` flips `submitted → paid/failed` |
| e-invoicing | `Moadian:Provider=moadian` | `MoadianClient` + a 6-hour `MoadianReconciliationJob` walking `pending/submitted → registered` |
Three deliberate exceptions:
- **`IPaymentCaptureSimulator` is out of the production registration.** Production gets the fail-closed
`DisabledPaymentCaptureSimulator`; Dev and Testing re-register the succeeding mock. The `bookings/convert`
path is a Dev/Testing affordance — production converts through the b10 webhook confirm.
- **`ICredentialVerifier` / `ILicenseVerificationService` stay mock**, because **manual MoH / INO / eNamad
review is the intended MVP** — there is no public B2B API. Don't "finish" them.
- **There is no telephony/VoIP seam.** The emergency call is an out-of-platform `tel:` link by design.
`ICurrencyNormalizer` is already config-driven with a real implementation.
---
## 4. Startup wiring
Service registration is composed from per-layer extension methods, each in that project's
`ServiceConfiguration/` folder. **`Program.cs` is an orchestrator: extension-method calls only, no logic and
no inline registration.**
```
builder.ValidateRequiredSecrets() // fail fast on a missing/placeholder DB or crypto secret
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(…, requireHttpsMetadata)
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories,
// the IRecurringJob crons + RecurringJobSchedulerHostedService
AddCrossCuttingSeams(config)
AddWebFrameworkServices() // API versioning + snake_case routing
AddCorsPolicies(config) // from Cors:AllowedOrigins
AddForwardedHeadersConfiguration(config) // trust ForwardedHeaders:KnownProxies/KnownNetworks
AddRateLimitingPolicies() // per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices(builder.Environment) // gRPC reflection: Development only
// Development-only: AddDevelopmentOtpCapture() decorates ISmsSender to capture each OTP in memory for
// GET /api/v1/dev/last_otp/{phone}. Never wired outside Development, and only for a capture-safe
// Seams:Sms:Provider (mock/unset, or the Development-only telegram relay). Kavenegar disables it.
```
**When you add infrastructure, expose it as an extension method and call it from `Program.cs`.**
### Middleware order, and why each position matters
```
forwarded headers → exception handler → Swagger → routing → CORS → rate limiter
→ authentication → authorization → controllers → metrics → health checks → gRPC
```
- **`UseForwardedHeaders()` is first**, so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is
in place before the rate limiter partitions on it. Behind a proxy without it, the limiter sees one IP and
throttles everyone together.
- **`UseCors(...)` sits after `UseRouting()` and before `UseRateLimiter()`**, so a pre-flight `OPTIONS` is
answered before the limiter and auth run.
- **`UseRateLimiter()` is before `UseAuthentication()`**, so over-limit auth and OTP attempts are rejected
with 429 before hitting the auth stack.
### Fail-fast on secrets
`StartupSecretsGuard` (via `ValidateRequiredSecrets()`) refuses to start if a load-bearing secret is missing
or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder: the DB connection strings always, plus the
JWE and field-encryption keys in deployed environments.
> The placeholder's *name* is stale — `dotnet user-secrets` is **not used** and the `<UserSecretsId>` was
> removed, so that store is never read. The behaviour is correct; the string is a legacy name. See
> [code-quality.md](../shared/code-quality.md) §6 for where config actually lives.
---
## 5. Observability and health
One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`, `SetupOpenTelemetry`):
- **Metrics** — runtime + ASP.NET Core + the `mediator_meter` histogram, scraped at `/metrics` via the OTel
Prometheus exporter. (The duplicate prometheus-net stack was removed.)
- **Tracing** — ASP.NET Core + EF Core, sharing `service.name = Baya.Web.Api`.
- **OTLP export (traces + metrics) is opt-in** — wired only when `OpenTelemetry:Otlp:Endpoint` is set, so an
MVP running Prometheus alone is unchanged.
- **`ApiResult.RequestId` IS the W3C trace id** (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`),
so a support ticket maps 1:1 to a trace. Don't replace it with a random correlation id.
**Health checks are split**: `/healthz/live` (process only, dependency-free — for a liveness probe),
`/healthz/ready` (app DB + `logDb` in deployed environments + an `IObjectStorage` write probe), and
`/HealthCheck` (the aggregate, kept for compatibility).
**Logs**: deployed environments write Information+ to `Baya_Logs`, with framework categories held at Warning.
**No PII, no secrets** — the mock SMS sender never logs the OTP code, and clinical text and IBANs are
encrypted or masked. Set the OTLP collector to ship logs off-box; the SQL sink is the deployed default.
**gRPC reflection is Development-only** (`GrpcPluginStartup` gates it on `IsDevelopment`); the plugin shares
the mixed-protocol Kestrel listener.
+159
View File
@@ -0,0 +1,159 @@
# Code quality
The four rules that apply identically to both projects: no dead code, comment the *why*, no starter
scaffolding, and a mock is only a mock behind a seam.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## 1. No dead code
Unused variables, imports/usings, parameters, and private members are removed — not left behind, not
commented out, and **not suppressed**.
| Project | How it surfaces | Gate |
| --- | --- | --- |
| Client | `@typescript-eslint/no-unused-vars`, raised from eslint-config-next's default `warn` to **`error`** in `eslint.config.mjs` | Dead code **fails `npm run check`** |
| Server | `CS0168` (declared, never used), `CS0219` (assigned, never read), `CS0169` (private field never used), `IDE0005` (unnecessary `using`) | The gate is **zero new warnings**, so dead code is a gate failure |
**Delete it — don't silence it.** No `#pragma warning disable`, no throwaway discards, no `_ =`
assignments to quiet an analyzer, no file-wide ESLint disable.
Two sanctioned opt-outs, both narrow:
- **Client:** a deliberately-unused binding is prefixed with `_``_event`, `catch (_err)`.
- **Server:** a parameter that must exist to satisfy an interface or delegate signature but is genuinely
unused stays, named conventionally, with a one-line `// why` only if the reason isn't obvious.
When a lint disable is genuinely correct — a deliberate browser-only read after mount that trips
`react-hooks/set-state-in-effect` is the real example in this codebase — use a scoped
`// eslint-disable-next-line <rule>` with a one-line reason on the line above. Never a file-wide disable,
and never in preference to fixing the code.
---
## 2. Comment the *why*, never the *what*
Code that needs a comment to be understood usually needs a **better name** instead. Reach for the name
first, then a small helper, then a comment.
**Don't** write a comment that restates what the code already says:
```csharp
// ❌ restates the obvious
// increment the retry counter
retryCount++;
```
```tsx
// ❌ restates the obvious
// set the access token
setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, token);
```
No XML-doc or JSDoc that merely echoes a function's name either.
**Do** add a tight comment where a non-obvious decision, constraint, business rule, workaround, ordering
or security requirement, or deliberate deviation is *not* evident from the code. Explain the reasoning,
not the mechanics:
```csharp
// ✅ captures a constraint the code can't express on its own
// Payment gateway rejects amounts above 50M IRR per call; split larger settlements upstream.
if (amount > MaxPerCallRial) …
```
The models to follow in this codebase:
| File | What its comment earns |
| --- | --- |
| `client/src/app/[locale]/layout.tsx` | Why `<html>` lives in the `[locale]` layout and not above it |
| `client/src/lib/auth/token.ts` | Why the JWT `exp` check is UX-only and never a security boundary |
| `client/src/layout/config.ts` | Why the two chrome-bar heights are measured rather than guessed, and must stay in sync with the bars |
| `client/middleware.ts` | Why the matcher lists bare `'/'` explicitly alongside the catch-all regex |
Delete comments that no longer match the code. A wrong comment costs more than no comment.
---
## 3. Don't reintroduce starter scaffolding
Both projects were derived from open-source starters. Their branding, demo/showcase pages, and
`_TITLE_`/`_DESCRIPTION_` placeholders were **intentionally removed**. Don't add them back — not as a
convenience, not while editing docs, not as an example.
Specifically:
- No placeholder page, showcase route, or "example component" gallery.
- No `_TITLE_` / `_DESCRIPTION_` / lorem-ipsum copy anywhere, including in message files.
- No starter README boilerplate reinstated into a project README.
- `PlaceholderScreen` exists for a genuinely not-yet-built screen and must not be reachable from a shell's
navigation. `/admin/notifications` is the current example: it is a placeholder, and it is deliberately
absent from `AdminLayout`'s nav for that reason.
---
## 4. A mock is only a mock behind a seam
Some integrations are intentionally out of scope and must be **mocked, not invented**: real PSP and BNPL
connections, the Shahkar / MoH / INO / criminal-record vendors, MinIO/S3 credentials, the سامانه مودیان
enrollment. Reaching one is not a blocker.
The only sanctioned form of "not real yet" is:
1. **An interface.** Server: an interface in `Application/Contracts/`, implemented twice, selected by
configuration in `AddCrossCuttingSeams` — and **the default is the mock, with a typo falling closed to
the mock**. Client: the domain's `Api` interface in `services/{domain}/types.ts`, implemented by
`clientApi.ts` and `mockApi.ts`, selected in `apis/index.ts` by `USE_{DOMAIN}_MOCK`.
2. **Selection by registration, never by branching.** No `if (mock)` inside a handler, hook, or component.
Swapping a mock for the real thing is a one-line registration change and touches no caller.
3. **A record**, in `docs/status/`: 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 settings, which methods,
what to test.
An unrecorded mock is a defect, because the next agent cannot tell a deliberate stand-in from a bug.
Two mocks in this repo are **deliberate MVP endpoints, not stand-ins waiting for a vendor**:
`ICredentialVerifier` / `ILicenseVerificationService` stay mock because manual MoH / INO / eNamad review
*is* the intended MVP — there is no public B2B API. Don't "finish" them.
---
## 5. Scale and cost are part of correctness
Every decision should consider what it costs at scale, not only whether it works once:
**Server** — indexing, pagination on every unbounded list, caching read-heavy and reference data behind
the cache seam, idempotency and locks on the money path, the DB constraint as the authoritative backstop
behind every friendly pre-check.
**Client** — query caching with a deliberate `staleTime` so you never refetch what you already hold,
invalidation on mutation, re-render cost (stable references, `select` to subscribe to a slice, state
colocated low), and bundle size.
And in both: the seam that lets a mock become real without touching a caller.
---
## 6. Configuration lives in files, not a secret store
`dotnet user-secrets` is **not used** in this repo, and the `<UserSecretsId>` was removed from
`Baya.Web.Api.csproj`, so that store **is not read at all**. Any instruction telling you to set a value
with `dotnet user-secrets` is stale.
| Where config lives | What |
| --- | --- |
| `server/src/API/Baya.Web.Api/appsettings.*.json` | Server config, including dev crypto keys |
| `client/.env.development` / `.env.production` | Client config |
| root `docker-compose.yml` | The deployment's container-specific overrides |
This is a deliberate pre-launch trade for a demo deployment, which means **the repo contains live
credentials**. Before onboarding real users they must be rotated and the secret half moved out of git —
see [`DEPLOY.md`](../../../DEPLOY.md) "Going to Production". Never hardcode a secret in code either way:
keys, connection strings, and tokens come from configuration bound to typed settings, never a literal in
a handler, service, or component.
One value is load-bearing and must never change: `Seams:FieldEncryption:Key` / `:HashKey` decrypt all
existing PII and derive the phone-lookup hash. Changing them makes every PII read throw and every phone
lookup miss.
+106
View File
@@ -0,0 +1,106 @@
# Git and the quality gates
What must pass before work is done, and what the repo refuses to let you commit.
> Last verified: 2026-08-02 against commit `51e86a1`.
---
## 1. The gates
Each project is built, linted, and tested **on its own**. There is no root-level build, package, or
solution, so there is no single command that gates the repo. Run the gate for the side you edited.
### Client — `cd client`
| Command | What it runs |
| --- | --- |
| `npm run check` | **The gate.** `type``lint``lint:copy`, in that order |
| `npm run type` | `tsc --noEmit` (`strict` on) |
| `npm run lint` | `eslint .` (flat config) |
| `npm run lint:copy` | `node scripts/check-copy.mjs` — greps `fa.json` for banned Persian orthography variants |
| `npm run test:ci` | `jest --ci`**also required** when you touched a component with a co-located `*.test.tsx` |
`npm run check` must be green. `en.json` and `fa.json` must be in sync.
> `lint:copy` is part of `check`, not a separate step you can forget. It is what stops a copy regression
> — a hamza-less «تایید», a space in the brand name — from needing to be re-discovered by a human. The
> rules it enforces are in [client/i18n.md](../client/i18n.md).
### Server — `cd server`
| Command | What it runs |
| --- | --- |
| `dotnet build Baya.sln` | **Zero new warnings.** Unused usings, locals, parameters, private fields or members count as failures — delete them, don't suppress them |
| `dotnet test Baya.sln` | All tests pass, including the ones your change adds |
A reachable SQL Server is required to run the API (not to build or unit-test it).
### Both
Read your own diff as if you were reviewing the PR: **would a senior engineer approve it without
comment?** A change that passes the mechanical gate and fails that question is not done.
---
## 2. What "done" means
A change is done when all of these hold:
- [ ] The full scope is implemented. No `// TODO: implement later`, no stub that returns fake data.
Anything not real is behind a **DI-registered seam** and recorded (see [code-quality.md](code-quality.md)).
- [ ] It follows the rules for that project — the relevant `CLAUDE.md` plus the one reference file for the
area you touched.
- [ ] No dead code. Comments explain *why*, not *what*.
- [ ] The project's own gate above is green.
- [ ] If the structure changed, the matching **architecture section** is updated in the same change
(see [documentation.md](../documentation.md) §3).
- [ ] If a business rule was discovered or decided, `product/` reflects it — recorded, not invented.
- [ ] If a new reusable pattern or seam landed, the reference file for that area names it, so the next
change reuses it instead of reinventing it.
A change that doesn't pass its own gate is **not done**, regardless of how complete the code looks.
---
## 3. No pre-commit secret scan (for now)
There is no git hook enforcing anything in this repo — `.githooks/` was removed in phase 7 as an
MVP-stage call: this is a pre-launch demo project and the mechanical backstop wasn't worth the overhead
yet. The underlying rule is unchanged — **never commit a real secret** — it's just unenforced by tooling.
Root [CLAUDE.md](../../../CLAUDE.md) §6 already documents the repo's actual trade: config lives in
committed files, including live credentials, until real users exist (see
[DEPLOY.md](../../../DEPLOY.md) "Going to Production" for the rotation step that unblocks that). Revisit
adding a hook — or a CI scanner (gitleaks, trufflehog) — if that trade changes before this one does.
---
## 4. Branches and commits
`main` is the default branch and the base for PRs.
- **Commit or push only when asked.** If you are on `main` and about to commit, branch first.
- One coherent change per commit. The repo's history reads as a sequence of completed units of work
(`ui phase 11`, `remove user-secrets approach & prepare a pilot deploy`) — keep that.
- Never skip hooks (`--no-verify`) or bypass signing unless explicitly asked. If a hook fails,
fix the underlying issue.
- Prefer a new commit over amending an existing one.
- Before a destructive git operation (`reset --hard`, `push --force`, `checkout --`), consider whether a
safer route reaches the same place.
---
## 5. Known pre-existing warnings
These are expected and **must not be "fixed"** unless a task says so — a change that touches them is
scope creep, and one that silences them is worse.
| Warning | Project | Note |
| --- | --- | --- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all server projects | The .NET 10 SDK is preview on this machine |
On the client, `import/no-cycle` is disabled in `eslint.config.mjs` (its TypeScript resolver has an
interface mismatch with this toolchain), and **ESLint is pinned to 9** — ESLint 10 crashes against this
Next 16 toolchain with `scopeManager.addGlobals is not a function`. See
[client/testing.md](../client/testing.md).
+94
View File
@@ -0,0 +1,94 @@
# Naming
The names that are load-bearing across both projects, and the ones that are only conventions.
> Last verified: 2026-07-30 against commit `d3ec723`.
---
## The two names, and why there are two
The product and brand are **Balinyaar** (Persian: «بالین‌یار»). The server's code namespace is **`Baya*`**
— a legacy prefix from before the name settled.
| Layer | Name | Rule |
| --- | --- | --- |
| Server namespaces, projects, solution | `Baya.*` / `Baya.sln` | Keep it. **Do not rename without explicit instruction** — it touches 14 `.csproj` files, every namespace, and the solution. |
| Client package | `balinyaar-client` | — |
| Client import alias | `@/*``client/src/*` | Defined in `client/tsconfig.json`. Use it; don't write deep relative paths across folders. |
| User-facing copy | «بالین‌یار» / "Balinyaar" | Never `Baya`. See [client/i18n.md](../client/i18n.md) for the ZWNJ rule — it is linted. |
So `Baya.Application` is correct in C# and wrong in a UI string, and «بالین‌یار» is correct in a UI string
and would be wrong as a namespace. That is the whole split.
---
## Agent-facing docs
`CLAUDE.md` is the single source of truth at every level of the repo. `AGENTS.md` files exist only so the
convention is discoverable under that name too — they are **thin pointers**, never content. If you find
yourself writing a rule into an `AGENTS.md`, it belongs in the `CLAUDE.md` beside it.
Three `AGENTS.md` files exist: repo root, `client/`, `server/`.
---
## Server naming
Full C# conventions in [server/conventions.md](../server/conventions.md). The names that matter beyond
style:
| Kind | Convention | Example |
| --- | --- | --- |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
| Feature folder | `Features/<Area>/{Commands\|Queries}/<VerbNoun>/` | `Features/Payments/Commands/InitiatePayment/` |
| EF config folder | `Persistence/Configuration/<Area>Config/` | `PaymentsConfig/` |
| Seam interface | `I{Capability}` in `Application/Contracts/` | `IBankTransferProvider` |
| Real adapter | `{Vendor}{Capability}` in `Seams/Real/` | `JibitBankTransferProvider` |
| Mock adapter | `Mock{Capability}` in `Seams/` | `MockBankTransferProvider` |
**Controller and action names become URLs.** All URL segments are `snake_case`, produced automatically
from `[controller]`/`[action]` tokens by `SnakeCaseParameterTransformer`. So `GetBySlug` becomes
`get_by_slug`. If a method name doesn't read cleanly as a URL, **rename the method** — never hardcode the
route string, which bypasses the transformer.
One type per file, and the file name matches the type name exactly.
---
## Client naming
| Kind | Convention | Example |
| --- | --- | --- |
| Shared component | `src/components/<Name>/<Name>.tsx` + `index.tsx` barrel | `components/TrustBadge/TrustBadge.tsx` |
| Its test | co-located `<Name>.test.tsx` | `components/TrustBadge/TrustBadge.test.tsx` |
| Page body | `<PageName>Screen.tsx`, co-located with `page.tsx` | `HomeScreen.tsx`, `SearchScreen.tsx` |
| Private (non-route) folder under `app/` | `_`-prefixed | `_chrome/`, `_hub/` |
| Route group (adds no URL segment) | parenthesised | `(customer)`, `(public-routes)` |
| Service domain | `src/services/{domain}/` | `services/bookingRequests/` |
| Query hook | one per file, `hooks/use{Action}.ts` | `hooks/useBookingDetail.ts` |
| Icon registry key | **lowercase**, semantic | `icon="verification"`, not `icon="ShieldCheck"` |
| i18n namespace | a top-level key in both message files | `booking`, `payouts` |
| Constant | `SCREAMING_SNAKE` in a `constants.ts` | `APP_FRAME_MAX_WIDTH` |
`bookings` and `bookingRequests` are **siblings, not a rename** — a booking request is the money-free
pre-payment intent, a booking exists only after capture. The same distinction is load-bearing in Persian
copy («درخواست رزرو» vs «رزرو») and in the server's singular `Booking` vs plural `Bookings` feature areas.
---
## Directory conventions that carry meaning
| Path | Meaning |
| --- | --- |
| `client/src/components/common/` | Foundational primitives, imported via `@/components` |
| `client/src/components/<domain>/` | Domain composites (`booking/`, `messaging/`, `admin/`, `geography/`, `notifications/`, `settings/`, `auth/`) |
| `client/src/services/{domain}/apis/` | The seam: `clientApi.ts` (real), `mockApi.ts`, `serverApi.ts`, `index.ts` (selects) |
| `server/src/Core/` | Domain + Application — no outward dependencies |
| `server/src/Infrastructure/` | Implementations of Application contracts |
| `server/src/API/` | Controllers, framework, plugins |
| `dev/` | The finished build-plan chain. History, not a project — nothing to build in it |
| `product/` | Business truth. Markdown canonical, HTML generated |
+135
View File
@@ -0,0 +1,135 @@
# Backlog — closed items
> Last verified: 2026-08-02 against commit `b876490`.
Items confirmed done during phase 4's reconciliation, with what closed them. This is what keeps
[backlog.md](backlog.md)'s open count honest — an item only leaves this file if later evidence proves it
regressed.
---
## Hardening ledger (3 of 18 items — see [backlog.md](backlog.md) for the 11 still open + 4 partially-fixed)
| Origin | Item | Closed by |
|--------|------|-----------|
| H-02 | `isTokenAlive` could never read the real token (JWE) — treated every session as dead. | `baa3cc6` ("manual improvement 1") — a well-formed 5-part JWE is now treated as alive without decoding. |
| H-03 | Anonymous visitor to a private shell hit an infinite splash screen. | `baa3cc6``useRoleHydration` now has an explicit `unauthenticated` status; `RoleGuard` redirects to `/login` on it. |
| H-12 | Raw stack-trace error boundary; no route-level error pages. | `370c1be` (ui-phase-1) + `d33568b` (ui-phase-12) — branded `ErrorBoundary` with retry, plus `error.tsx`/`global-error.tsx`. |
## REQ ledger — delivered (28 of 67; see [backlog.md](backlog.md) for the remaining 39 open/partial/deferred)
| REQ | Item | Evidence |
|-----|------|----------|
| REQ-001 | `ApiResult` envelope, camelCase casing, `Paginated` shape | `ApiResult.cs`, `PagedResult.cs` |
| REQ-002 | `codeLength`/`expiresInSeconds` on OTP request result | `RequestOtpResult.cs:8` |
| REQ-003 | Machine-readable OTP failure codes + `retryAfterSeconds` | `VerifyOtpCommand.Handler.cs:43,58` |
| REQ-005 | `relation`/`conditions` on `PatientDto` | `Patient.cs:28,33` |
| REQ-006 | Multipart avatar-upload endpoint (nurse + customer) | `UploadCustomerAvatarCommand.cs`, `UploadNurseAvatarCommand.cs` |
| REQ-007 | Customer name + preferred-language update | `UpsertCustomerProfileCommand.cs` |
| REQ-008 | Client map-pin lat/long on address create/update | `CreateAddressCommand.cs:21`, `UpdateAddressCommand.Handler.cs:62-71` |
| REQ-009 | `provinceId` on `CustomerAddressDto` | `CustomerAddressDto.cs:12` |
| REQ-010 | Pagination param name confirmed (`pageSize`) | `PagedResult.cs:4` |
| REQ-011 | Nurse-facing structured credential-details endpoint | `NurseVerificationController.cs:47-51` |
| REQ-012 | Search enrichment + public nurse-profile aggregate | `NurseSearchResultDto.cs:21-23`, `NursesController.cs:33-35` |
| REQ-013 | Variant price on `BookingRequestDto` | `BookingRequestDto.cs:20-21,45` |
| REQ-014 | `variantLabel`+`patientAge` on nurse-inbox list DTO | `BookingRequestListItemDto.cs:23` |
| REQ-015 | Booking/session/EVV enum wire codes confirmed | `BookingStatus.cs:11-30` |
| REQ-016 | Checkout summary read | `BookingRequestsController.cs:64-66` |
| REQ-017 | `bookingId` on converted request | `BookingRequestDto.cs:49` |
| REQ-018 | Invoice reachable right after capture (auto-issue) | `ConfirmPaymentAndPostLedgerCommand.Handler.cs:98` |
| REQ-019 | Customer-initiated cancellation + refund | `BookingsController.cs:67-71` |
| REQ-020 | Pre-cancel policy preview | `BookingsController.cs:74-77` |
| REQ-021 | Refund lookup-by-booking + fee-leg decomposition | `RefundsController.cs:29` |
| REQ-023 | BNPL eligibility accepts D3 KYC inputs | `CheckBnplEligibilityQuery.cs:21-23` |
| REQ-025 | Nurse earnings balance + list + detail + `failureReason` | `NursePayoutsController.cs:27-47` |
| REQ-026 | Review eligibility + my-review-for-booking reads | `BookingReviewsController.cs:33-41` |
| REQ-027 | Family care record + record-access reads | `PatientCareRecordsController.cs:44-59` |
| REQ-028 | Ticket inbox enrichment + message idempotency | `MessagingProjections.cs:13,16-17,62` |
| REQ-029 | `updatedAt`/`updatedBy` on `PlatformConfigDto` | `PlatformConfigDto.cs:11-12` |
| REQ-030 | Audit-trail actor/action/date-range filters | `GetAuditTrailQuery.cs:8-15` |
| REQ-037 | `tagCodes` on the moderation-queue DTO | `ReviewProjections.cs:37-48` |
**Note:** REQ-029/030's server work is closed, but `client/src/services/admin/constants.ts` still cites both
as reasons the admin mock stays primary — a small residual client cleanup, not tracked separately (fold into
whichever admin-mock item is next touched).
## REQ ledger — obsolete (1)
| REQ | Item | Reason |
|-----|------|--------|
| REQ-004 | Confirm multi-role disambiguation (`activeRole`?) on `/me` | Resolved by product decision in refinement-phase-2: the client owns active-role choice; no `MeResult` change was ever needed. |
## Manual-testing iterations 1 & 2 (19 of 20 bullets — 1 partially-fixed, see [backlog.md](backlog.md) BL-217)
| Origin | Item | Closed by |
|--------|------|-----------|
| iteration-1 #1 | Login card / all Papers had too large a border radius | `theme.ts:76-84`, `tokens.css:46-51` |
| iteration-1 #2 | Login-page options should move inside the card | `AuthCard.tsx:8-33`, `PhoneStep.tsx:100-117` |
| iteration-1 #3 | No top bar needed on the login page | `PublicLayout.tsx:8-14` |
| iteration-1 #4 | Language/theme switches should live only in Settings | `PublicLayout.tsx:11-13`, `SettingsPanel.tsx:10-16` |
| iteration-1 #5 | Icon set should be modernized with semantic mapping | `AppIcon/config.ts:1-19` — MUI Icons replaced with Lucide |
| iteration-1 #6 | Default route should resolve by role, not spin forever | `RoleGuard.tsx:34-38`, `RoleRouter.tsx:31-46` |
| iteration-1 #7 | React-child console error on phone submit | `OtpStep.tsx:104-115` + regression test `richText.test.tsx` |
| iteration-1 #8 | OTP input spacing broken in `fa` locale | `OtpInput.tsx:119-127``gap` not RTL-mirrored `spacing` |
| iteration-1 #9 | Nurse dashboard needed a full visual refactor | `NurseDashboardScreen.tsx:28-45` |
| iteration-1 #10 | Replace drawer nav with a bottom navbar | `NurseLayout.tsx:11-26`, `BottomBar.tsx:18-40` |
| iteration-1 #11 | Each nav group needs a summary root page | `NursePracticeScreen.tsx:13-18`, `NurseFinanceScreen.tsx:10-15` |
| iteration-1 #12 | Horizontal scroll present on nurse dashboard | `AppFrame.tsx:31-33,96-103` — structurally disabled |
| iteration-1 #13 | App should be phone-width at every viewport | `layout/config.ts:5-14``APP_FRAME_MAX_WIDTH=480` |
| iteration-2 #1 | Nurse greeting section useless; card borders looked bad | `NurseDashboardScreen.tsx:38-41`, `AccentCard.tsx:15-21` |
| iteration-2 #2 | Bottom nav needed a better, floating form | `BottomBar.tsx:22-34,93-101`, `layout/config.ts:37-44` |
| iteration-2 #3 | No easy way to reach the nurse requests page | `NurseLayout.tsx:16-21,39-44` — now a tab with a badge |
| iteration-2 #4 | Theme-mode toggle buttons had no gap | `ThemeModeSetting.tsx:60-75` |
| iteration-2 #5 | `/nurse/verification` and `/nurse/profile` forms were an undifferentiated list | `nurse/profile/page.tsx:68-80`, `FormSection.tsx:26-40` |
| iteration-2 #6 | `/nurse/services` had the same forms problem | `VariantBuilder.tsx:1-70` — react-hook-form + `StepperHeader` wizard |
## Report follow-ups confirmed completed by a later phase (curated — the notable ones)
Verification method: cross-referenced against the later phase's own "What was built" section, not re-traced
against current code (these predate the flow-gap sweep and were folded in only where a later report's own
claim is unambiguous). Treat as **strong evidence**, not the same rigor as the code-verified items above.
| Origin | Item | Closed by |
|--------|------|-----------|
| backend-phase-0 follow-up | Extend audit interceptor to write `audit_logs`; evolve schema; add `IHolidayCalendar` seed | backend-phase-1 |
| backend-phase-0 follow-up | `ISmsSender` seam + auth/OTP REST surface + rate-limit policies | backend-phase-2 |
| backend-phase-0 follow-up | `WebApplicationFactory` integration-test project | backend-phase-2 (`Baya.Test.Api`) |
| backend-phase-2 follow-up | Profiles/patients/addresses/nurse bank accounts; `/me` completion flags read real tables | backend-phase-3 |
| backend-phase-2 follow-up | Shahkar/KYC populate `NationalId`; `/me` surfaces real `nurseVerificationStatus` | backend-phase-6 |
| backend-phase-3 follow-up | `customer_addresses`+`nurse_service_areas` geography + geocoder | backend-phase-4 |
| backend-phase-3 follow-up | `is_verified` flip transaction; Shahkar/KYC; bank-ownership step coupling | backend-phase-6 |
| backend-phase-3 follow-up | `average_rating`/`total_reviews` aggregate recompute | backend-phase-14 (`total_completed_bookings` recompute unconfirmed — see BL-245-adjacent residue) |
| backend-phase-4 follow-up | `nurse_search_index` fan-out wired to service-area add/remove | backend-phase-7 |
| backend-phase-4 follow-up | EVV distance check consuming address lat/long | backend-phase-9 |
| backend-phase-5 follow-up | `nurse_search_index`, `INurseSearch`, search query, index fan-out | backend-phase-7 |
| backend-phase-5 follow-up | `booking_requests.variant_snapshot_json` persistence | backend-phase-9 (not b8 as originally planned) |
| backend-phase-6 follow-up | Search reads `nurse_profiles.is_verified` for `is_searchable` | backend-phase-7 |
| backend-phase-7 follow-up | Search results feed the booking flow; `required_caregiver_gender` capture | backend-phase-8 |
| backend-phase-8 follow-up | `accepted_awaiting_payment` → real `bookings` row on capture | backend-phase-9/10 |
| backend-phase-8 follow-up | Stage-2 encrypted `booking_care_instructions` | backend-phase-9 |
| backend-phase-8 follow-up | Three-amount split, snapshots, sessions, EVV, dispute window | backend-phase-9 |
| backend-phase-9 follow-up | Real card capture replacing `IPaymentCaptureSimulator` | backend-phase-10 |
| backend-phase-9 follow-up | Reviews-on-completed-booking; `partner_center_id` wiring | backend-phase-14, backend-phase-15 |
| backend-phase-11 follow-up | Real `IBnplProvider` (beyond the b11 revert-only stub) | backend-phase-12 |
| backend-phase-11 follow-up | Clawback netting/recovery + real `INursePayoutStatus` | backend-phase-13 |
| backend-phase-12 follow-up | `settled_at`-gates-payout coupling flag (`require_bnpl_settlement_for_payout`) | backend-phase-13 (shipped, default off) |
| backend-phase-14 follow-up | Ticket system + partner centers | backend-phase-15 |
| frontend-phase-5 follow-up 3 | Admin verification review queue (pass/reject, doc viewer) | frontend-phase-15 |
| frontend-phase-10 follow-up 1 | Admin refund console (`RefundPanel`) | frontend-phase-15 |
| frontend-phase-12 follow-up 2 | Admin payout console (`/admin/payouts`) | frontend-phase-15 |
| frontend-phase-13 follow-up 2 | Review moderation console (`/admin/reviews`) | frontend-phase-15 |
| frontend-phase-14 follow-up 1 | Admin global ticket queue + internal-note composer | frontend-phase-15 |
| frontend-phase-14 follow-up 2 | Support-alert worklist, partner-center console, audit viewer | frontend-phase-15 |
| ui-phase-0/1 follow-up | Core primitives (EmptyState/ErrorState/PageHeader/`<Money>`/Jalali picker) | ui-phase-1 |
| ui-phase-0/1 follow-up | App-wide motion pass consuming `--bal-motion-*` tokens | ui-phase-12 |
| ui-phase-3 follow-up | Skip-onboarding («بعداً تکمیل می‌کنم») | ui-phase-4 |
| ui-phase-4 follow-up | Public/guest storefront + landing page | ui-phase-13 |
| ui-phase-4/5 follow-up | Zero-case copy sweep; `continue_payment` CTA string sweep | ui-phase-12 |
| ui-phase-7 follow-up 1 | `DashboardActivationSlot`'s full "go live" checklist content | ui-phase-8 |
| ui-phase-8 follow-up | Coverage map visualization | ui-phase-9 |
| handoff-after-refinement-phase-0 | No `USE_*_MOCK` flag had been flipped yet | refinement-phase-4 (14 of 22 domains flipped to real) |
**Not included above** (checked and found still genuinely open, or not confirmed by a later report):
`support_alerts` FK constraints (BL-not-filed — low-priority schema hygiene, see decisions.md), region
bulk-import feed, `SuspendNurse`/`ResolveSupportAlert`/`FlagConcern` admin actions (BL-245), and everything
else already carried into [backlog.md](backlog.md)'s Deferred section.
+306
View File
@@ -0,0 +1,306 @@
# Backlog — every open item, triaged
> Last verified: 2026-08-02 against commit `51e86a1`.
Reconciled from five ledgers — [hardening/issues.md](../../archive/post-phase/hardening/issues.md) (18 items),
[for-backend.md](../../archive/build-chain/working-context/frontend/requests/for-backend.md) (67 REQs), 53
report "Follow-ups" sections, 22 backend handoff files, two manual-testing iterations — plus
[product/notes/open-questions.md](../../product/notes/open-questions.md), root [CLAUDE.md](../../CLAUDE.md)
§6, phase 2's drift list, and phase 3's **283 flow gaps** (the primary, freshest, code-verified input; see
[docs/flows/index.md](../flows/index.md)). ~700 raw candidate rows were harvested; this file is the deduped,
triaged result. Old ids stay greppable in their source files — nothing was rewritten.
**Area**: `client` · `server` · `contract` · `ops` · `product` · `docs`. **Sev**: `blocker` (product is wrong
or unusable) · `major` · `minor` · `deferred`. **Status**: `open` · `in-progress` · `blocked (on what)` ·
`deferred (trigger)`. **Blocks**: the [docs/flows/](../flows/index.md) journey(s) it degrades.
Closed items (with what closed them) are in [backlog-closed.md](backlog-closed.md). The distilled decision
log is in [decisions.md](decisions.md). The business-area overlay is in [implemented.md](implemented.md).
---
## Blockers (18)
| ID | Area | Item | Origin | Status | Blocks |
|----|------|------|--------|--------|--------|
| BL-001 | server | Admin RBAC is structurally dead: `DynamicPermissionService.CanAccess` grants only the literal role `admin`; every seeded admin holds `super_admin`/`finance` and 403s on all `DynamicPermission`-gated controllers. No code path ever writes a `DynamicPermission` claim. | H-04, H-05, admin-backoffice gap-1/3/12, bnpl gap-7, cancellation-and-refunds gap-9, messaging-tickets gap-1/3, nurse-verification gap-3, nurse-earnings-and-payouts gap-7, partner-center gap-8, reviews gap-1/2, booking-request gap-3, booking-lifecycle-evv gap-3/5, nurse-catalog-and-pricing gap-2 | open | admin-backoffice, bnpl-installments, cancellation-and-refunds, messaging-tickets, nurse-verification, nurse-earnings-and-payouts, partner-center, reviews, booking-request, booking-lifecycle-evv, nurse-catalog-and-pricing |
| BL-002 | ops | No seeded account holds the literal `admin` role (`Seed:AdminUsername`/`Password` unset, `SeedDataBase.cs` returns early) — even a fixed BL-001 is untestable out of the box. | admin-backoffice gap-2 | open | admin-backoffice |
| BL-003 | ops | Committed live credentials (DB `sa`, JWE/field-encryption key halves, Kavenegar, Neshan, Finnotech, Telegram bot token) must be rotated and moved out of git before onboarding real users. | CLAUDE.md §6, refinement-phase-0 follow-up 3 | open | — (pre-launch) |
| BL-004 | ops | `GET /api/v1/dev/last_otp/{phone}` is anonymous and Development-only by design, but the deployment runs as Development — live on `api.balinyaar.ir`, lets anyone who knows a phone read its login code. | auth-login-otp gap-9, public-front-door gap-10 | open | auth-login-otp, public-front-door |
| BL-005 | server | Card payment is a dead end everywhere: `MockPaymentProvider` redirects to a non-existent host, and nothing fires the PSP webhook locally, so no new card payment can ever reach `ConfirmPaymentAndPostLedger`. | checkout-and-payment gap-1/2 | open | checkout-and-payment |
| BL-006 | contract | Booking deadline timestamps ship without a timezone (`DateTime` not `DateTimeOffset`); the client reads the offset-less value as local time — in Tehran the 30-minute payment window renders ~4h and expires while the timer still shows time left. | booking-request gap-1 | open | booking-request, checkout-and-payment |
| BL-007 | ops | No `bnpl` payment-gateway row is ever seeded — every BNPL initiate/eligibility call 400s with "No active BNPL gateway is configured" on the live server. | bnpl gap-1 | open | bnpl-installments |
| BL-008 | client | The BNPL wizard is a dead end for every real booking-request id: its mock cross-imports the bookings-mock store (seeded ids 1-2 only), bypassing the real-data flip entirely. | H-07, bnpl gap-2/3 | open | bnpl-installments |
| BL-009 | client | Refunds mock reads the retired bookings-mock store (ids 5001-5005 only), so any real booking id 404s inside the cancel-policy preview; flipping the flag today would also render a 10000% refund (percent-scale bug on an already-0-100 field). | H-06, cancellation-and-refunds gap-1/2/4 | open | cancellation-and-refunds |
| BL-010 | client | Verification is 100% client-mocked under one flag covering nurse + public + admin; the mock resets to empty on every reload, so even a server-verified nurse renders unverified everywhere the app reads it, hiding the real search-visibility gate and "go live" CTA. `submitCredentialDetails` is also a no-op though the real endpoint matches field-for-field. | H-08, nurse-verification gap-1/2/4, onboarding-nurse gap-1/2/3 | open | nurse-verification, onboarding-nurse, search-and-discovery, public-front-door |
| BL-011 | client | Patient/care records are 100% mocked on both the family record and nurse visit-note panel, and the real DTO shapes would break a naive flip: medication/routine fields mismatch server field-for-field, and care-plan ids are `string` client-side vs `long` server-side. | care-circle-patients gap-1..4, patient-care-records gap-1..3, H-17 | open | care-circle-patients, patient-care-records, booking-lifecycle-evv |
| BL-012 | server | Nurse earnings/payouts: mock hides four working endpoints; no `process` operation exists anywhere in the client (the irreversible payout step has no UI); `DeriveEarningsState` marks a booking "paid" whenever it merely has a payout *link* regardless of that payout's actual status; the four earnings buckets don't reconcile against the ledger. | H-09, nurse-earnings-and-payouts gap-1/4/5/9/10/11 | open | nurse-earnings-and-payouts |
| BL-013 | client | Partner center is 100% mocked with zero tenancy (any signed-in caller resolves to center id 1), and 5 core routes (`centers/me`, `/me/nurses`, `/me/bookings`, `/me/bookings/{id}`, `/me/settlement`) don't exist server-side; a center owner has no legal read of its own issued invoices. | partner-center gap-1/2/3/9/10, REQ-032, REQ-033, REQ-064 | open | partner-center |
| BL-014 | server | Search results are index rows, not nurses — one nurse with 3 variants × 3 areas shows as "9 پرستار" with no de-dup; the trust dossier on results is mocked, and the by-id profile page asserts "verified" unconditionally, even for an `in_review` nurse. | search-and-discovery gap-1/3/4 | open | search-and-discovery, public-front-door |
| BL-015 | client | Editing an address through the UI silently destroys data: the form never collects `postalCode`/`recipientName`/`recipientPhone`, and the update handler nulls them unconditionally on every save. | addresses-and-map gap-4 | open | addresses-and-map |
| BL-016 | server | A booking whose remaining sessions get swept to `missed` never reaches a payable state — the `allSettled → Completed` re-check only lives inside the checkout path, so already-checked-out sessions can never enter a payout batch. The "today" session feed also applies no date filter and lists a nurse's whole history. | booking-lifecycle-evv gap-1/2 | open | booking-lifecycle-evv, nurse-earnings-and-payouts |
| BL-017 | server | Reviews can never leave moderation on the live stack: the queue and status-PATCH sit behind BL-001, and `AutoApproveClean` is absent from config — a real review never reaches a nurse's profile except via the seeder. | reviews gap-1/2/3 | open | reviews |
| BL-018 | server | A production database has zero option groups outside Development — the one seeded «نوع شیفت» group only exists because the demo seeder is Dev-only, so every builder collapses to two steps and every variant in a category duplicates every other, deployed. | nurse-catalog-and-pricing gap-3 | open | nurse-catalog-and-pricing |
## Major (86)
| ID | Area | Item | Origin | Status | Blocks |
|----|------|------|--------|--------|--------|
| BL-019 | client | Customer without an emergency contact cannot save name or language — the profile validator requires it non-empty but the UI has no field-level pre-check, and the resulting 400 is completely silent (no `onError`). | account-and-settings gap-1/2 | open | account-and-settings |
| BL-020 | client | Partner settings hub shows fabricated identity — `USE_PARTNER_MOCK` resolves the same center for any caller, zero real tenancy on the screen. | account-and-settings gap-6 | open | account-and-settings, partner-center |
| BL-021 | client | Sign-out confirmation is inconsistent — customer hub gates it behind a confirm dialog; nurse/admin/partner fire on the first tap and end every session on the account. | account-and-settings gap-7 | open | account-and-settings |
| BL-022 | client | A customer cannot set an avatar — the real endpoint exists but the client's `uploadAvatar` only targets the nurse route. | account-and-settings gap-8 | open | account-and-settings |
| BL-023 | product | `preferredLanguage` is stored and never consumed anywhere — the UI locale comes only from the URL prefix, so the زبان sheet has two controls that do different things. | account-and-settings gap-3 | open | account-and-settings |
| BL-024 | server | Nurse avatars never load anywhere in the app — `LocalDiskObjectStorage.GetUrl` returns a `file://` URI a browser cannot fetch. | account-and-settings gap-4, onboarding-nurse gap-4, search-and-discovery gap-6, public-front-door gap-2 | open | account-and-settings, onboarding-nurse, search-and-discovery, public-front-door, booking-request |
| BL-025 | ops | `NEXT_PUBLIC_NESHAN_KEY` is unset in every environment, so the real map (tiles, address search, locate-me) never renders anywhere — every environment gets the keyless grid stand-in, and the response shapes are unverified against the live Neshan API. | addresses-and-map gap-1/2 | open | addresses-and-map |
| BL-026 | client | `CITY_CENTROIDS` is keyed on mock-seed city ids; only Tehran matches the real seeded cities, so the map picker opens ~400km away for every other city. | addresses-and-map gap-3 | open | addresses-and-map |
| BL-027 | client | Recipient name/phone/postal-code are never displayed anywhere on an address card either, even where the (broken, BL-015) form would collect them. | addresses-and-map gap-5 | open | addresses-and-map |
| BL-028 | contract | Admin list pagination silently breaks on flip: `admin/apis/clientApi.ts` sends `page_size`; controllers declare `PageSize`. Binding is case- but not separator-insensitive, so every admin list falls back to the server default page size. | admin-backoffice gap-6, search-and-discovery gap-13 | open | admin-backoffice, search-and-discovery |
| BL-029 | server | `/fa/admin/roles` and `/fa/admin/users` have no server — `admin_roles/*` and `admin_users/*` are phantom; REQ-061 (admin user directory) was never given a ledger header despite ten live client files citing it. | admin-backoffice gap-7/8, REQ-031, REQ-061 | open | admin-backoffice |
| BL-030 | client | `admin_cancellation_policies/list|upsert` are unwired — no screen edits the cancellation tiers; they are effectively read-only in production. | admin-backoffice gap-11 | open | admin-backoffice, cancellation-and-refunds |
| BL-031 | client | Reuse-detection cannot tell theft from two tabs: the single-flight refresh guard is per-tab module state, so two tabs 401ing at once race and the loser's classified-as-reuse kills every session with no explanation. `useSelectRole`'s own rotation bypasses the guard the same way. | auth-login-otp gap-2/3 | open | auth-login-otp |
| BL-032 | server | Session revocation does not kill access tokens — `RefreshTokenCommandHandler` revokes sessions but never rotates the security stamp, so "logged out everywhere" is up to 60 minutes late. | auth-login-otp gap-4 | open | auth-login-otp |
| BL-033 | client | The access cookie expires 45 minutes before the token does (900s vs 60min), so 15 idle minutes bounces any full page load to `/login` even though the refresh cookie could have recovered the session. | auth-login-otp gap-5 | open | auth-login-otp |
| BL-034 | client | A `?next=` pointing at `/partner/...` never survives login — `appRoleForPath` has no partner branch, and `/me` carries no signal that the caller administers a partner center. | auth-login-otp gap-11, partner-center gap-4, REQ-038 | open | auth-login-otp, partner-center |
| BL-035 | client | `useLogout` always signs out every device — an absent `refreshToken` means "everywhere"; no UI offers a single-device choice. | auth-login-otp gap-12 | open | auth-login-otp |
| BL-036 | client | `resolveRoleDestination` checks admin before customer/nurse, so a user holding both an admin sub-role and `customer` can never reach the family app from login. | auth-login-otp gap-13 | open | auth-login-otp |
| BL-037 | ops | `POST /auth/request_otp` 500s on a fresh clone — the committed SMS provider is `telegram` with nothing listening; login is unreachable until booted with the mock provider. | auth-login-otp gap-1 | open | auth-login-otp |
| BL-038 | client | Query cache is never cleared on logout/login — only `authKeys` are invalidated, so a device that signs out of one account and into another can render stale, cross-account data from the previous session's cache. | H-11 | open | auth-login-otp, account-and-settings |
| BL-039 | server | `BookingRoles.Admin` over-grants: Support/Moderation roles get clinical care-instruction reads and financial nurse-payable-balance reads, wider than the "admin/finance" comment claims. | H-05 | open | booking-lifecycle-evv, nurse-earnings-and-payouts |
| BL-040 | client | The «پاسخ‌داده» nurse-inbox tab is unpaged — the API filters one status at a time with no group filter, so `nurse/requests` fires three page-1 queries and concatenates; a nurse with >20 answered requests silently loses rows. | booking-request gap-4, REQ-050 (residue) | open | booking-request |
| BL-041 | ops | Nothing seeded is actionable in the booking-request flow — no `pending`/`accepted` request survives the demo seeder's epoch-anchored aging, so the nurse inbox, accept/reject and payment-window handoff are all unwalkable without creating a fresh request. | booking-request gap-2 | open | booking-request |
| BL-042 | server | `nurseAvatarUrl` on a booking request is served as a raw local-disk path — same root cause as BL-024, called out because checkout renders it directly. | booking-request gap-11 | open | checkout-and-payment |
| BL-043 | client | `NEXT_PUBLIC_EVV_MOCK_GPS` now defaults to off (derived from the now-real bookings flag), so a tester away from the seeded Tehran address fires a real GPS mismatch — and a support alert — on every check-in; the doc-comment still describes the old default. | booking-lifecycle-evv gap-6 | open | booking-lifecycle-evv |
| BL-044 | server | The nurse never sees the address on a confirmed booking — `BookingMapper.ToDetailDto` nulls the address snapshot for the nurse role even though the server itself reads it to compute the EVV match. | booking-lifecycle-evv gap-7, REQ-051 | open | booking-lifecycle-evv |
| BL-045 | client | `POST bookings/submit_care_instructions/{id}` is unwired — no customer form exists anywhere; on a booking a tester creates, the nurse's care-instructions card is empty. | booking-lifecycle-evv gap-10 | open | booking-lifecycle-evv |
| BL-046 | client | The nurse booking detail is half real: the bookings/EVV half is server truth, but the mounted visit-notes panel (task checklist, continuity history, note-save) is entirely `patientRecords`-mocked — same root cause as BL-011. | booking-lifecycle-evv gap-12 | open | booking-lifecycle-evv |
| BL-047 | client | H-06's exact mocked-cross-import defect also shows up as: `CHANNEL_BY_BOOKING` pins the BNPL-revert refund channel to one fixture id, so every real booking silently demos as a card refund. | cancellation-and-refunds gap-3 (folded severity note) | open | cancellation-and-refunds |
| BL-048 | contract | The client discards six fields the server now serves on refund status (`platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt`) — the fee-split transparency section can never render even once BL-009 is fixed. | cancellation-and-refunds gap-5 | open | cancellation-and-refunds |
| BL-049 | contract | Cancellation enum drift on three fields (`CancellationPolicyCode`, `CancellationLeadTime`, `appliesTo`) between client and the seeded/wire values — every one drives an i18n lookup that will miss on flip. | cancellation-and-refunds gap-6 | open | cancellation-and-refunds |
| BL-050 | server | `POST admin_refunds` creates and executes in one call — no preview/approve/reject route exists, so the admin `RefundPanel`'s three-step console has no real backend. | cancellation-and-refunds gap-10, REQ-035 | deferred (trigger: admin refund console prioritized) | cancellation-and-refunds |
| BL-051 | client | No admin console exists for refund/clawback settlement — `confirm_settlement`, `mark_failed`, `write_off` all have server handlers but zero client screens. | cancellation-and-refunds gap-12 | open | cancellation-and-refunds |
| BL-052 | server | `expectedCustomerRefundEta` is hardcoded null in the cancellation preview — the BNPL 10-business-day window is invisible before the customer confirms, exactly where it matters most. | cancellation-and-refunds gap-14 | open | cancellation-and-refunds |
| BL-053 | ops | `customer_no_show` has no seeded cancellation-policy row — the product doc's "up to 100% charge" tier does not exist; a customer no-show falls through to the 50%-back tier. | cancellation-and-refunds gap-15 | open | cancellation-and-refunds |
| BL-054 | server | No credit note / invoice reversal on refund — a refunded booking's VAT-bearing commission invoice stays as originally issued. | cancellation-and-refunds gap-16 | open | cancellation-and-refunds |
| BL-055 | server | Seeded visit-note `taskResults` labels are lost — the handler deserializes with case-sensitive options against camelCase seeded JSON; any externally-written JSON in that column silently degrades to blank labels. | care-circle-patients gap-5, patient-care-records gap-7 | open | care-circle-patients, patient-care-records |
| BL-056 | client | Editing a patient destroys their birth date — the form always submits `ageToBirthDate(age)` = `YYYY-01-01`, overwriting the real recorded date on every save even with no change. | care-circle-patients gap-10 | open | care-circle-patients |
| BL-057 | server | Tenancy leak on patient records: a foreign patient returns 403, not 404, across all three handlers — confirms the patient exists to an unauthorized caller, violating the repo's 404-not-403 invariant. | patient-care-records gap-8 | open | care-circle-patients, patient-care-records |
| BL-058 | contract | A verified nurse is shown as unverified on the checkout screen — the checkout summary DTO declares `nurseVerified` but the server never constructs it. | checkout-and-payment gap-3, REQ-046 | open | checkout-and-payment |
| BL-059 | client | Payment confirmation cannot deep-link to the booking — the server does serve `bookingId` but the client types it away and hardcodes null; the receipt also always hides the tracking code and paid-at. | checkout-and-payment gap-5, H-10, REQ-046 | open | checkout-and-payment |
| BL-060 | client | The invoice screen derives a money row client-side (`gross commission vat`) that is wrong by exactly the VAT amount, and never shows the server's real `totalIrr` — violates the "client never computes money" rule. | checkout-and-payment gap-7 | open | checkout-and-payment |
| BL-061 | server | Customer payment history is a live 404 — `bookings/payment_history` doesn't exist; the wallet «پرداخت‌ها» tab is permanently empty for a card-paying customer. | checkout-and-payment gap-8, REQ-047 | open | checkout-and-payment |
| BL-062 | server | The escrow ledger has no read surface at all — a customer, admin or tester has no way to see the balanced capture group the flow's core money invariant depends on. | checkout-and-payment gap-10 | open | checkout-and-payment |
| BL-063 | client | `getUnreadTotal` is a hardcoded `null` on the real path — the chrome support badge can never light up. | messaging-tickets gap-4, account-and-settings gap-10, REQ-059 | open | messaging-tickets, account-and-settings |
| BL-064 | client | `mapSummary` hardcodes `lastMessagePreview`/`lastAuthorRole` to null on the real path — the ticket inbox card shows no message preview. | messaging-tickets gap-5, REQ-059 | open | messaging-tickets |
| BL-065 | client | `POST /tickets/emergency` has zero client callers — the nurse's emergency banner only dials `tel:`; the "then opens a ticket" half of the business playbook is manual. | messaging-tickets gap-6 | open | messaging-tickets |
| BL-066 | contract | 11 of 14 server notification types are unknown to the client's parser — only 3 deep-link; the nurse's most important notification (`booking_confirmed_nurse`) and the entire `booking_request_*` lifecycle are non-navigable. | notifications gap-1/2/3 | open | notifications, booking-request |
| BL-067 | client | The client handles 10 notification types the server never emits, while the server's real payout-paid/payout-failed events have no client branch at all — a nurse is never told a payout paid or failed. | notifications gap-6 | open | notifications, nurse-earnings-and-payouts |
| BL-068 | server | Every notification title/body is an English literal rendered verbatim into the Persian RTL feed — no i18n path for notification content, only for chrome. | notifications gap-7 | open | notifications |
| BL-069 | client | No catalogue-authoring UI exists at all — all seven `admin_catalog/*` routes have zero client callers; an admin cannot add a pricing dimension without SQL, compounding BL-018. | nurse-catalog-and-pricing gap-1 | open | nurse-catalog-and-pricing |
| BL-070 | server | ZWNJ is stripped from every stored Persian string via a global `FixPersianChars` normalizer, contradicting the repo's "with a ZWNJ, always" naming rule. | nurse-catalog-and-pricing gap-4 | open | nurse-catalog-and-pricing |
| BL-071 | server | A nurse's price edit is not shielded from an in-flight booking request — the conversion reads the live variant price, not a request-time snapshot, so editing price inside the 30-minute payment window changes what the customer pays. | nurse-catalog-and-pricing gap-5 | open | nurse-catalog-and-pricing, booking-request |
| BL-072 | client | The «همراهی و مراقبت روزمره» category is data-only per its own seed comment, but the builder offers it like any real pricing path. | nurse-catalog-and-pricing gap-8 | open | nurse-catalog-and-pricing |
| BL-073 | client | `toHistoryItem` hardcodes `failureReason: null` though the live wire carries it — a failed payout loses its reason in the nurse's history on flip. | nurse-earnings-and-payouts gap-3 | open | nurse-earnings-and-payouts |
| BL-074 | client | `previewPayoutBatch` sums net amounts and fabricates a processing date client-side — the client computing money and a payout date, violating the "client never computes money" rule. | nurse-earnings-and-payouts gap-6 | open | nurse-earnings-and-payouts |
| BL-075 | contract | `recordTransferReference` targets a route that doesn't exist — the batch-detail reconcile field 404s on flip. | nurse-earnings-and-payouts gap-12, REQ-036 | deferred (trigger: payout reconciliation console prioritized) | nurse-earnings-and-payouts |
| BL-076 | client | `POST admin_payouts/{id}/mark_failed` exists server-side but has no client op — a reconciled bank rejection cannot be recorded from the console. | nurse-earnings-and-payouts gap-14 | open | nurse-earnings-and-payouts |
| BL-077 | server | Whole-city + a specific district in the same city are both accepted for one nurse, and search then returns her twice with no de-dup on `(nurseId, variantId)`. | nurse-service-areas gap-1 | open | nurse-service-areas, search-and-discovery |
| BL-078 | client | The coverage screen has no error state — `isError` is dropped, so a failed list load renders the "no area registered" empty state, telling a nurse with real coverage she is invisible in search. | nurse-service-areas gap-3 | open | nurse-service-areas |
| BL-079 | contract | No edit and no deactivate for a service area — changing a district means remove-then-add, which silently drops the nurse from search between the two calls. | nurse-service-areas gap-5 | open | nurse-service-areas |
| BL-080 | client | `nurse/verification/page.tsx` unconditionally imports the verification mock module — the one production seam breach in the client; the mock ships in every build regardless of the render gate. | nurse-verification gap-4 | open | nurse-verification |
| BL-081 | client | `foldQueueRows` folds the per-step admin verification queue to per-nurse items but leaves the step-progress counts at zero and the pager count wrong — a nurse's steps can straddle a page boundary. | nurse-verification gap-6, REQ-062 | open | nurse-verification |
| BL-082 | contract | `approveVerification`/`rejectVerification`/`getDocumentSignedUrl` target routes that don't exist — the admin case page's visible approve/reject CTAs would 404 the moment BL-001 is fixed. | nurse-verification gap-7, REQ-034 | deferred (trigger: verification-admin console prioritized) | nurse-verification |
| BL-083 | client | No admin UI exists for `suspend`/`scan_expiring` or the verification step-type catalog — the "data-driven catalog" design rule has no admin surface. | nurse-verification gap-8 | open | nurse-verification |
| BL-084 | client | Admin verification case URLs are mistyped — the route folder is `[nurseId]` but the value passed is a `nurseVerificationId`; typing a nurse id into the URL opens the wrong case. | nurse-verification gap-11 | open | nurse-verification |
| BL-085 | product | Onboarding never creates a `customer_profile` — it creates a patient and stops; nothing blocks a customer from booking without ever setting an emergency contact. | onboarding-customer gap-1 | open | onboarding-customer |
| BL-086 | contract | A customer's own `gender` is unsettable through the app — no client call and no command field carries it; a fresh account's `/me.gender` stays null forever. | onboarding-customer gap-3 | open | onboarding-customer |
| BL-087 | client | The customer profile upsert has no PATCH semantics — every sheet save rewrites the whole profile including name, so a stale form on one device can silently overwrite a name changed on another. | onboarding-customer gap-5 | open | onboarding-customer, account-and-settings |
| BL-088 | client | `/fa/select-role` is reachable by any authenticated user with no guard, letting a customer permanently self-grant the `nurse` role by typing a URL, with no confirmation step for an irreversible grant. | onboarding-customer gap-7 | open | onboarding-customer |
| BL-089 | client | `useSelectRole` swallows a failed post-select token rotation — the role persists server-side but the client keeps a stale token until the next silent refresh happens to fire, so a role-gated call in between 403s. | onboarding-customer gap-9 | open | onboarding-customer |
| BL-090 | client | The duplicate-IBAN error message never reaches the nurse — the bank page discards the server's field error and shows a generic "registration failed" toast. | onboarding-nurse gap-7 | open | onboarding-nurse |
| BL-091 | server | Bank-ownership inquiry ignores the national id entirely — a nurse who never did identity KYC (`national_id = NULL`) still gets `matchedNationalId = true`, so the payout gate opens on a claim nothing actually checked. | onboarding-nurse gap-8 | open | onboarding-nurse, nurse-earnings-and-payouts |
| BL-092 | client | `verifyOwnership` is a dead seam op — no hook calls it, so a `mismatch` bank account has no re-inquiry affordance; the UI only offers "re-enter the IBAN". | onboarding-nurse gap-9 | open | onboarding-nurse |
| BL-093 | contract | Shape mismatch blocks the partner portal even once mocked data is off: the server serves one capped/inline dashboard aggregate; the portal needs paginated `/me`-scoped splits it cannot get from that shape. | partner-center gap-5 | open | partner-center |
| BL-094 | client | `GET /centers/{id}/dashboard` — the one portal endpoint that *is* real — needs a center id the portal has no way to discover, so it goes unused. | partner-center gap-6 | open | partner-center |
| BL-095 | server | Structured `taskResults` are discarded in both directions on the real patient-records path — accepted on write and returned on read by the server, but the client hardcodes them away; the "wire has no structured field" client comments are simply wrong. | patient-care-records gap-6 | open | patient-care-records |
| BL-096 | product | Tier (c) of the public front door (guest search + public nurse profiles) is unbuilt — REQ-066/067 need a backend phase plus an explicit privacy sign-off before any guest-facing search/profile screen is built. | public-front-door gap-6, ui-phase-13 follow-up | deferred (trigger: privacy sign-off + REQ-066/067 delivered) | public-front-door, search-and-discovery |
| BL-097 | product | `/terms` and `/privacy` ship placeholder legal copy behind a draft banner, flagged for human/legal review since ui-phase-3 and still unreviewed — a real pre-launch item. | public-front-door gap-5, ui-phase-3 follow-up 1 | open | public-front-door |
| BL-098 | server | `GET /api/v1/nurses/{id}/profile` returns 200 anonymously for an unverified (`in_review`) nurse — the persona that "must never appear in search" is reachable and enumerable by id, even though search itself correctly hides her. | public-front-door gap-3 | open | public-front-door, search-and-discovery |
| BL-099 | client | The nurse cannot see their own reviews anywhere in the app — the only signal is a notification the (BL-001-blocked) moderation handler dispatches. | reviews gap-8 | open | reviews |
| BL-100 | client | The client hardcodes the five review tag codes instead of reading the server's tag master — a newly seeded tag is invisible to the UI and an unseeded code fails submit outright. | reviews gap-7 | open | reviews |
| BL-101 | server | `ReviewModerationStatus.Rejected` is unreachable end to end — the submit-time banned-word path maps to `Hidden`, not `Rejected`, and the only other producer is the BL-001-blocked admin PATCH; the client still renders a `rejected` chip nobody can ever trigger. | reviews gap-4 | open | reviews |
| BL-102 | server | Review eligibility distinguishes "booking not found" from "not your booking" with a 200, letting any authenticated caller probe whether an arbitrary booking id exists — violates the repo's 404-not-403 tenancy invariant. | reviews gap-9 | open | reviews |
| BL-103 | product | No free-text search exists — a customer who knows a nurse's name by name cannot find her; category/city/gender/price filters only. | search-and-discovery gap-10, REQ-041 | open | search-and-discovery |
| BL-104 | client | Guests cannot reach search at all — every search route sits behind a customer-only route guard and `/search` is not in the public-path list, even though the underlying endpoints are already anonymous. | search-and-discovery gap-11 | open | search-and-discovery, public-front-door |
## Minor (115)
| ID | Area | Item | Origin | Status | Blocks |
|----|------|------|--------|--------|--------|
| BL-105 | client | Admin hub renders a raw i18n key as the role label — the translation keys were never added. | account-and-settings gap-5 | open | account-and-settings |
| BL-106 | client | Customer hub navigates with a hand-built locale prefix instead of the shared navigation helper — works today, breaks silently if `localePrefix` ever changes. | account-and-settings gap-11 | open | account-and-settings |
| BL-107 | client | UNVERIFIED: a system-theme user may see one light/dark flip after hydration (SSR cookie vs. post-hydration MUI resolution can disagree). | account-and-settings gap-12 | open | account-and-settings |
| BL-108 | product | No notification-preference surface exists anywhere — the settings row only deep-links to the notification centre. | account-and-settings gap-9 | open | account-and-settings, notifications |
| BL-109 | client | The pin is a required address-form field, so the API's "no pin" state and its UI badge are unreachable except via direct API calls or seed data. | addresses-and-map gap-6 | open | addresses-and-map |
| BL-110 | ops | Geography is seeded one city per province; districts exist only for Tehran, so the region cascade is a two-step formality everywhere else. | addresses-and-map gap-7 | open | addresses-and-map |
| BL-111 | client | `GET geo/tree` is live but unused — the region cascade makes three round trips instead of one. | addresses-and-map gap-8 | open | addresses-and-map |
| BL-112 | client | Map-pin RTL geometry is dodged via inline style, untested for RTL drift beyond the keyless fallback path. | addresses-and-map gap-9 | open | addresses-and-map |
| BL-113 | docs | No `product/` doc describes address entry, the map-pin picker, or the Neshan integration. | addresses-and-map gap-10 | open | addresses-and-map |
| BL-114 | client | `/fa/admin/notifications` is a placeholder stub and a true orphan — no link reaches it and no bell is mounted in the admin shell. | admin-backoffice gap-9, notifications gap-8 | open | admin-backoffice, notifications |
| BL-115 | client | `POST holidays/delete_holiday` is unwired — the admin console offers no delete affordance for a holiday row. | admin-backoffice gap-10 | open | admin-backoffice |
| BL-116 | ops | `admin_search/rebuild_index` and `admin_booking_requests/expire` are reachable only by curl, and both 403 for a seeded admin anyway. | admin-backoffice gap-12 | open | admin-backoffice, booking-request |
| BL-117 | client | `useAdminCapabilities` hides tabs but never blocks the route itself — every admin console stays URL-reachable regardless of capability; server enforcement is currently just the blanket BL-001 403. | admin-backoffice gap-13, H-14 | open | admin-backoffice |
| BL-118 | client | A 429 on OTP verify renders as "wrong code" — the empty error body leaves nothing to branch on, unlike the phone-step's correct 429 handling. | auth-login-otp gap-6 | open | auth-login-otp |
| BL-119 | contract | The client ignores the server's OTP metadata (`codeLength`, `expiresInSeconds`) and hardcodes its own — the 60-second code expiry is never shown, only the 120-second resend cooldown. | auth-login-otp gap-7 | open | auth-login-otp |
| BL-120 | docs | Stale client comments claim the server exposes no OTP code length or machine-readable failure code — it exposes both. | auth-login-otp gap-8 | open | auth-login-otp |
| BL-121 | client | A signed-in user visiting `/login` sees the phone form again instead of being redirected away, unlike `/welcome`. | auth-login-otp gap-10 | open | auth-login-otp |
| BL-122 | product | The OTP SMS template is not WebOTP-conformant, so the client's WebOTP autofill ships but never fires; landing this is a Kavenegar-dashboard template edit (ops action), not a code change. | auth-login-otp gap-14, REQ-039 | open | auth-login-otp |
| BL-123 | ops | No seeded account is role-less, so `/fa/select-role` has no natural path in the demo world. | auth-login-otp gap-15, onboarding-nurse gap-10 | open | onboarding-customer, onboarding-nurse |
| BL-124 | client | Client hardcodes English toasts for 401/403/5xx/network errors — no i18n dictionary. | H-13 | open | (all authenticated flows) |
| BL-125 | client | Admin read-only consoles are inconsistently client-guarded — some pages check capabilities only to gate mutation buttons, `admin/audit` has zero check, and no shared `CapabilityGuard` component exists. | H-14 | open | admin-backoffice |
| BL-126 | docs | `bnpl/apis/clientApi.ts` and its header comment both claim the by-request BNPL read 404s — it exists and returns 200, a stale comment blocking a partial de-mock. | bnpl gap-5 | open | bnpl-installments |
| BL-127 | client | `acceptBnplSchedule` derives request status client-side from the order status, mislabelling a failed/reverted/cancelled order as awaiting-payment — the expired-window branch can never fire on the real path. | bnpl gap-6 | open | bnpl-installments |
| BL-128 | product | `providerCommissionReversedAmount` is left null on a reverted BNPL order — nothing in the UI or admin surfaces the resulting commission shortfall. | bnpl gap-8 | open | bnpl-installments |
| BL-129 | ops | Seeded BNPL eligibility status (`"approved"`) is not in the closed status vocabulary on either side of the wire. | bnpl gap-9 | open | bnpl-installments |
| BL-130 | server | D3 KYC inputs are half-wired — the client sends `nationalId`/`mobile`/`consent`, but eligibility only reads the mobile; legal consent is collected and silently discarded. | bnpl gap-10, REQ-023 (residue note) | open | bnpl-installments |
| BL-131 | ops | Nothing fires the BNPL provider webhook in dev, so even a successfully initiated real order would never reach `settled` — settlement is webhook-driven by design. | bnpl gap-11 | open | bnpl-installments |
| BL-132 | client | `/fa/bookings/checkout/bnpl/gateway` is an orphan route on the real path, reachable only from the mock and gated outside development. | bnpl gap-12 | open | bnpl-installments |
| BL-133 | client | No client path produces a `disputed` booking status — it renders in the status map but nothing ever raises one. | booking-lifecycle-evv gap-4 | open | booking-lifecycle-evv |
| BL-134 | contract | The nurse's today-feed session row carries no service/variant label — every row says patient name + visit index only. | booking-lifecycle-evv gap-8, REQ-052 | open | booking-lifecycle-evv |
| BL-135 | contract | A booking list row has no `patientId` — a booking card can never deep-link to the care record. | booking-lifecycle-evv gap-9, care-circle-patients gap-13, REQ-057 | open | booking-lifecycle-evv, care-circle-patients |
| BL-136 | client | `POST booking_sessions/cancel/{id}` is unwired — a nurse cannot cancel a single visit from the UI. | booking-lifecycle-evv gap-11 | open | booking-lifecycle-evv |
| BL-137 | docs | `services/bookings/apis/serverApi.ts` is dead code with no importer, and its own doc-block still claims the domain is mock-primary. | booking-lifecycle-evv gap-13 | open | booking-lifecycle-evv |
| BL-138 | contract | No structured rejection-reason code exists — the client runs a seven-keyword heuristic on free text to decide whether to offer "retry with the same nurse," and it silently misses any other wording. | booking-request gap-5, REQ-044 | open | booking-request |
| BL-139 | contract | `variantLabel` is typed as client-augmented/optional though the server always serves it, and the list row still lacks `variantPrice`/`variantPriceUnit`, so the nurse inbox card can't show the money without opening the detail. | booking-request gap-6/7, REQ-050 (residue) | open | booking-request |
| BL-140 | contract | `patientAge` is served on every nurse-inbox row but never modelled client-side — a pure client-side type-widening fix. | booking-request gap-8 | open | booking-request |
| BL-141 | client | The customer's pending-request list doesn't poll while the detail view does, so countdowns on `/bookings` go stale until a manual refresh. | booking-request gap-9 | open | booking-request |
| BL-142 | client | `useBookingRequest` has no auth gate (unlike both list hooks) — a hard reload before hydration can flash an error card. | booking-request gap-10 | open | booking-request |
| BL-143 | product | The nurse sees the patient's display name pre-accept, wider than the integration doc's "notes + coarse address only" — confirm this is the intended stage-1 boundary. | booking-request gap-12 | open | booking-request |
| BL-144 | client | The client's 22 in-memory mocks are module-scoped and reset on reload/HMR — a mocked cancel demo doesn't survive navigating to the refund-status screen. | cancellation-and-refunds gap-17 | open | cancellation-and-refunds |
| BL-145 | contract | `preview.refundableSessionIds` doesn't exist on the wire (only `sessions[]`) — harmless today, but the client type lies. | cancellation-and-refunds gap-7 | open | cancellation-and-refunds |
| BL-146 | docs | The refunds `clientApi.ts` doc-block is stale — claims REQ-019/020/021 are contract gaps; all three routes exist and return 200. | cancellation-and-refunds gap-8 | open | cancellation-and-refunds |
| BL-147 | server | No `GET refunds/my` — the wallet «استردادها» tab renders empty on the real path. | cancellation-and-refunds gap-11, REQ-048 | open | cancellation-and-refunds |
| BL-148 | client | `/fa/admin/finance` is a hub with a single payouts tile — no refunds, clawbacks, or invoice-issue surface. | cancellation-and-refunds gap-13 | open | cancellation-and-refunds |
| BL-149 | client | Client discards a field the server does serve on the visit-note read — the "N of M tasks" summary chip can never render on the real path. | care-circle-patients gap-6 | open | care-circle-patients |
| BL-150 | contract | `deniedReason` enum mismatch on patient-record access denial — server sends `not_authorized`, client/docs declare `no_access`/`not_found`. | care-circle-patients gap-7, patient-care-records gap-5 | open | care-circle-patients, patient-care-records |
| BL-151 | docs | Stale client comments assert patient-record endpoints "have no backend" — all exist, are authorized, and were probed 200. | care-circle-patients gap-8/9, patient-care-records gap-12 | open | care-circle-patients, patient-care-records |
| BL-152 | ops | Seeded care plans are empty and seeded patients have no relation/conditions — three of four record tabs would be blank the moment the mock flag flips. | care-circle-patients gap-11/12, patient-care-records gap-9 | open | care-circle-patients, patient-care-records |
| BL-153 | docs | No `product/business/` file covers patient care records at all — no documented rule for append-only, encryption, or the clinical-access gate. | care-circle-patients gap-14, patient-care-records gap-10 | open | care-circle-patients, patient-care-records |
| BL-154 | client | Archiving a patient is optimistic with no undo — a stale/cross-tenant id 404s and the card silently reappears with a generic toast. | care-circle-patients gap-15 | open | care-circle-patients |
| BL-155 | client | The only way to demo patient-record access-denial uses a hardcoded sentinel patient id — untested against how the real server denies. | patient-care-records gap-11 | open | patient-care-records |
| BL-156 | contract | `sessionCount` is served nullable but typed non-null client-side; UNVERIFIED how the i18n label renders a null count. | checkout-and-payment gap-4 | open | checkout-and-payment |
| BL-157 | docs | Payment client constants/comments are stale — claim a live endpoint 404s, claim the mock is still primary, and hardcode a platform-fee rate that no longer matches the server's seeded value. | checkout-and-payment gap-6 | open | checkout-and-payment |
| BL-158 | server | Invoice carries no payment method, transaction reference, or seller fiscal identity — those rows never render on the real path. | checkout-and-payment gap-9, REQ-049 | open | checkout-and-payment |
| BL-159 | server | `GET invoices/{bookingId}` tenancy is enforced only inside the query handler; UNVERIFIED whether a foreign customer actually gets 404. | checkout-and-payment gap-11 | open | checkout-and-payment |
| BL-160 | server | Staff authorization is inconsistent by layer for tickets — the handler-level role set includes Support/Finance, but the controller attribute blocks them; two different definitions of "staff" in one request path. | messaging-tickets gap-3 | open | messaging-tickets |
| BL-161 | client | `POST`/`DELETE tickets/{id}/participants` are unwired — no UI can attach a third party to a thread. | messaging-tickets gap-7 | open | messaging-tickets |
| BL-162 | client | `close`/`reopen` are live and working server-side but unreachable client-side — one shared flag gates them alongside the genuinely-missing `assign`, so two already-shippable features stay dark. | messaging-tickets gap-8, REQ-063 (residue) | open | messaging-tickets |
| BL-163 | server | `POST tickets/{id}/assign` is a phantom endpoint the client already targets. | messaging-tickets gap-9, REQ-063 | deferred (trigger: ticket assignment prioritized) | messaging-tickets |
| BL-164 | docs | Stale client comment claims the server has no field for `clientMessageId` echo — it is sent and echoed today. | messaging-tickets gap-10 | open | messaging-tickets |
| BL-165 | contract | Client `TicketAuthorRole` declares `system`; the server's role vocabulary doesn't — one side must move. | messaging-tickets gap-11 | open | messaging-tickets |
| BL-166 | product | Auto-created coordination tickets open with no first message — both actors see an empty thread with no explanation of what it's for. | messaging-tickets gap-12 | open | messaging-tickets |
| BL-167 | contract | Ticket message history is unpaginated by contract — a long thread has no incremental read. | messaging-tickets gap-13 | open | messaging-tickets |
| BL-168 | client | Ticket attachments are fully designed but gated off behind a flag. | messaging-tickets gap-14, REQ-060 | deferred (trigger: attachment upload prioritized) | messaging-tickets |
| BL-169 | contract | `refund_issued`/`refund_processed` and `review_moderated`/`review_published` are near-miss names on either side of the same contract. | notifications gap-4 | open | notifications |
| BL-170 | server | `verification_expiry_prompt` is dispatched with a null data payload — even a matching client branch couldn't route it. | notifications gap-5 | open | notifications, nurse-verification |
| BL-171 | client | `NotificationBellPopover` (full mark-read + deep-link logic) is exported but mounted nowhere — dead UI. | notifications gap-9 | open | notifications |
| BL-172 | client | Notification "load more" isn't real pagination — it grows the page size and refetches the whole feed every time. | notifications gap-10 | open | notifications |
| BL-173 | server | Notification retention (90d/24h) is a hardcoded constant rather than a `platform_configs` row, against the repo's config-is-rows convention. | notifications gap-11 | open | notifications |
| BL-174 | client | Both notification pages are client components with no `generateMetadata`, departing from the thin-RSC-page convention. | notifications gap-12 | open | notifications |
| BL-175 | product | No unread-notification cap or archive — the feed grows until the 90-day sweep; unread rows are never separately swept. | notifications gap-13 | open | notifications |
| BL-176 | docs | `IVariantSnapshotSerializer`'s doc-comment names the wrong table for the frozen JSON snapshot. | nurse-catalog-and-pricing gap-6 | open | nurse-catalog-and-pricing |
| BL-177 | server | Category `iconKey` and description fields are null for all seeded categories — the catalog grid falls back to a generic icon with no explainer copy. | nurse-catalog-and-pricing gap-7 | open | nurse-catalog-and-pricing |
| BL-178 | server | `sessionCount` is unvalidated against `priceUnit` — nothing stops an invalid combination from being saved. | nurse-catalog-and-pricing gap-9 | open | nurse-catalog-and-pricing |
| BL-179 | client | The mock payout store's fixture booking ids deep-link into the (now real) bookings screens and 404. | nurse-earnings-and-payouts gap-2 | open | nurse-earnings-and-payouts |
| BL-180 | contract | The client sends `Idempotency-Key` on payout generate/retry, which the controller never reads — decorative and misleading to a reader. | nurse-earnings-and-payouts gap-13 | open | nurse-earnings-and-payouts |
| BL-181 | server | `holidayShifted` is always false — `PayoutBatchDto` carries no such flag, so a bank-holiday date shift can never be shown. | nurse-earnings-and-payouts gap-15 | open | nurse-earnings-and-payouts |
| BL-182 | product | No payout forecast — the nurse dashboard's "next batch" line renders nothing on the real path. | nurse-earnings-and-payouts gap-16, REQ-053 | open | nurse-earnings-and-payouts |
| BL-183 | client | Only one bank-rail failure reason is mapped to a label; every other reason falls back to a generic message. | nurse-earnings-and-payouts gap-17 | open | nurse-earnings-and-payouts |
| BL-184 | docs | Stale payouts doc-blocks assert three live endpoints don't exist server-side — all three return 200. | nurse-earnings-and-payouts gap-18 | open | nurse-earnings-and-payouts |
| BL-185 | client | Duplicate React keys on the nurse-service-areas search results whenever the BL-077 duplicate-listing bug fires. | nurse-service-areas gap-2, search-and-discovery gap-2 | open | nurse-service-areas, search-and-discovery |
| BL-186 | docs | The service-areas integration doc states a conflict rule that the live server doesn't enforce (probed 200 where it claims a conflict). | nurse-service-areas gap-4 | open | nurse-service-areas |
| BL-187 | client | No warning that removing a nurse's last service area de-lists her from search — the confirm-dialog copy doesn't distinguish this case. | nurse-service-areas gap-6 | open | nurse-service-areas |
| BL-188 | ops | Remove-service-area was left end-to-end unverified this pass (token expired mid-probe); code-traced only. | nurse-service-areas gap-7 | open | nurse-service-areas |
| BL-189 | ops | The shared demo DB has drifted from the seeder's own definitions (nurse 1 now has 5 service areas, not the seeded 3) and the idempotent seeder will never correct it. | nurse-service-areas gap-8 | open | nurse-service-areas |
| BL-190 | client | Verification step model omits `isRequired` though the wire serves it, so the "X of Y" progress meter would be wrong the moment an optional step type is added. | nurse-verification gap-9 | open | nurse-verification |
| BL-191 | contract | No `submittedAt` on the nurse verification status, and no per-step detail on the public trust-badge read. | nurse-verification gap-10, REQ-043, REQ-055 | open | nurse-verification, search-and-discovery |
| BL-192 | ops | Seeded verification data leaves two of six catalog step types unexercisable in the demo world. | nurse-verification gap-12 | open | nurse-verification |
| BL-193 | client | `POST customer_profiles/avatar` is live and implemented but no client code calls it — a customer can never set a photo. | onboarding-customer gap-2 | open | onboarding-customer, account-and-settings |
| BL-194 | client | `preferredLanguage` write silently commits a default `'fa'` the customer never chose, the first time they open and save the language sheet. | onboarding-customer gap-4/6 | open | onboarding-customer |
| BL-195 | client | `avatarUrl` on the nurse-profile upsert input is dead on the real path — populated by the form but never sent (harmless today, since the multipart route already persisted it separately). | onboarding-nurse gap-5 | open | onboarding-nurse |
| BL-196 | docs | The onboarding integration doc states duplicate-IBAN returns 409; the handler actually returns 400 with a field error. | onboarding-nurse gap-6 | open | onboarding-nurse |
| BL-197 | client | The bank-account page polls a stuck ownership inquiry forever, with no ceiling and no timeout copy. | onboarding-nurse gap-11 | open | onboarding-nurse |
| BL-198 | server | The HTTP merchant-of-record resolver (`internal/bookings/{id}/center`) is `DynamicPermission`-gated and 403s for both seeded admins — reachable only in-process today. | partner-center gap-7 | open | partner-center |
| BL-199 | server | Two merchant-of-record resolvers can disagree — BNPL reads a platform config value while invoicing resolves the actual center, so a MoR center's BNPL order and invoice could name different sellers. | partner-center gap-10 (folded severity, see BL-013) | open | partner-center, bnpl-installments |
| BL-200 | ops | Seeded partner-center invoices carry null Moadian reference/PDF fields — the settlement view's document columns have nothing real to render. | partner-center gap-11 | open | partner-center |
| BL-201 | product | Center self-onboarding (write-then-masked IBAN) is deferred and has never been exercised on a real route. | partner-center gap-12 | deferred (trigger: center self-service onboarding prioritized) | partner-center |
| BL-202 | client | Welcome page's Open-Graph image is Latin-only — no Persian glyph rendering for the `fa` share card. | public-front-door gap-7 | open | public-front-door |
| BL-203 | client | `/fa/welcome` has no in-app link at all — reachable only as the `/` rewrite body or by typing the URL. | public-front-door gap-8 | open | public-front-door |
| BL-204 | ops | `NEXT_PUBLIC_SITE_URL` is unset in dev, so `robots.txt`/`sitemap.xml`/OG tags fall back to localhost (dev-only caveat, `.env.production` is correct). | public-front-door gap-9 | open | public-front-door |
| BL-205 | ops | `next dev` misrepresents this flow entirely — stale prerenders serve ahead of the middleware; only a production build reproduces real behavior. | public-front-door gap-1 | open | public-front-door |
| BL-206 | server | No anonymous rate limit on the public search/profile reads — falls back to the shared global per-IP limiter. | public-front-door gap-4, search-and-discovery gap-12, REQ-066 (residue) | open | public-front-door, search-and-discovery |
| BL-207 | client | `GET nurses/{id}/review_tags` (the "% of reviewers said X" rollup) and `POST reviews/{id}/tags` are both unwired — no chip-aggregate view and no post-submit tag amendment. | reviews gap-5/6 | open | reviews |
| BL-208 | server | The low-rating support alert is raised on submit but has no reachable triage surface — reads sit behind BL-001. | reviews gap-10 | open | reviews |
| BL-209 | docs | Stale review doc comments claim the mock is still primary; the real client is live and the 14.6KB mock module is dead behind the flag. | reviews gap-11 | open | reviews |
| BL-210 | server | `distanceKm` is always null on search results — the covering index carries no coordinate, so the distance chip can never render. | search-and-discovery gap-7 | open | search-and-discovery |
| BL-211 | contract | `topReviewTag` and `variantDisplayName` are never served on search results — dead code on the client's tag chip and no multi-variant collapse. | search-and-discovery gap-8, REQ-040 | open | search-and-discovery |
| BL-212 | client | `nurseGender` is hardcoded `'female'` in the public-profile mapping because the DTO carries no gender field — unused today, but a live lie in the typed model. | search-and-discovery gap-9, REQ-042 | open | search-and-discovery |
| BL-213 | server | `attributeChips` is empty for every seeded nurse, and a masked reviewer name maps to an empty string — the profile snippet renders a bare separator with nothing around it. | search-and-discovery gap-14 | open | search-and-discovery |
| BL-214 | contract | No nurse-facing read-back of submitted credential details (INO-on-file, specialties) — the returning-nurse form can't hydrate from the server. | REQ-056 | open | nurse-verification |
| BL-215 | contract | Policy numbers (dispute-window hours, cancellation lead-time, refund ETA) have no public/authenticated read — the client keeps them single-sourced in a local constants file. | REQ-065, ui-phase-12 follow-up 3 | open | cancellation-and-refunds |
| BL-216 | contract | No typed address/variant snapshot objects — `BookingDetailDto` still carries opaque JSON strings, forcing the client's defensive multi-key parse in three separate places. | REQ-045, ui-phase-6 follow-up 2, ui-phase-7 follow-up 2 | open | booking-lifecycle-evv, checkout-and-payment |
| BL-217 | client | Two admin-only multi-field dialogs (`GrantRoleDialog` in `admin/roles`, `PreviewBatchDialog` in `admin/payouts`) still hold raw `useState` instead of react-hook-form — the only genuine residue of the app-wide form migration. | iteration-2 #7 | open | admin-backoffice |
| BL-218 | client | `H-01` residue: the auth-gate root-cause fix landed, but `client/middleware.ts` was never migrated to Next 16's `src/proxy.ts` as the original fix prescribed, and `outputFileTracingRoot` was never added alongside `turbopack.root` — a production build may still warn from the stray root-level lockfile. | H-01 | open | auth-login-otp |
| BL-219 | docs | Rename the `SET_VIA_USER_SECRETS_OR_ENV` placeholder sentinel now that `user-secrets` is confirmedly removed — filed by phase 2 with a worklist (`appsettings.*.json`, `StartupSecretsGuard.PlaceholderMarkers`, `StartupSecretsGuardTests.cs`, `docs/rules/server/structure.md`); the two `.githooks/*` files in the original worklist no longer exist (phase 7 dropped the pre-commit hook — see [decisions.md](decisions.md)). Optional cleanup, not urgent — the name is a load-bearing sentinel and the mechanism is already documented correctly elsewhere. | C-2 (open-contradictions.md) | deferred (trigger: none — cleanup-of-convenience) | — |
## Deferred (43)
These are recorded decisions with pull-triggers, not bugs — carried here (per this phase's Status vocabulary)
for phase 5's `roadmap/deferred.md` to pick up. Grouped by theme; each origin id is greppable in the source
handoff/report files for the full context.
| ID | Area | Item | Origin | Trigger | Blocks |
|----|------|------|--------|---------|--------|
| BL-220 | ops | Redis (shared cache, cross-instance scheduler/money lock) is not deployed — every current seam is correct only for a single instance. | handoff-after-refinement-phase-7/8, refinement-phase-7/8/9 follow-ups | running more than one instance | — |
| BL-221 | server | Elasticsearch `INurseSearch` backend + outbox feeder not built — SQL search is the real MVP implementation. | handoff-after-backend-phase-7, handoff-after-refinement-phase-7/8/9, backend-phase-7 follow-up | SQL search shows real strain | search-and-discovery |
| BL-222 | product | `IAnalyticsSink` / an analytics warehouse is not built — events aren't routed anywhere durable yet. | handoff-after-backend-phase-1, handoff-after-refinement-phase-9 | product pulls it | — |
| BL-223 | server | A real holiday-calendar feed (vs. the seeded/admin-CRUD calendar) is not built. | handoff-after-backend-phase-1, handoff-after-refinement-phase-9 | product pulls it | — |
| BL-224 | server | `organizations`/`organization_nurses` (employer model) tables are not built. | handoff-after-refinement-phase-9, frontend-phase-15 follow-up 2 | product pulls it | — |
| BL-225 | product | `fraud_flags` / ML fraud scoring is not built — manual suspension + support alerts cover this for now. | handoff-after-backend-phase-6/14, handoff-after-refinement-phase-9 | product pulls it | — |
| BL-226 | server | `recurring_booking_schedules` is not built. | handoff-after-refinement-phase-9, frontend-phase-15 follow-up 2 | product pulls it | — |
| BL-227 | server | `bnpl_settlement_entries` (tranched settlement) is modelled but not built. | handoff-after-backend-phase-12, handoff-after-refinement-phase-9 | product pulls it | bnpl-installments |
| BL-228 | product | Nurse availability slots/exceptions (soft scheduling guidance) are not built. | handoff-after-backend-phase-5/7, handoff-after-refinement-phase-9 | product pulls it | booking-request |
| BL-229 | product | Customer national-ID KYC collection is deliberately not built — the product decision is to never gate browsing/booking on it. | handoff-after-backend-phase-3, handoff-after-refinement-phase-9 | never, by design (informational) | — |
| BL-230 | server | A geography bulk-import feed (`IGeoDataImporter`) is deferred — the idempotent seed + admin CRUD is sufficient for MVP. | handoff-after-backend-phase-4, backend-phase-4 follow-up | product pulls it | addresses-and-map |
| BL-231 | product | Holiday/surge pricing, a distinct Companionship pricing tier, and tiered per-category commission are all deferred. | handoff-after-backend-phase-5, backend-phase-5 follow-up | product pulls it | nurse-catalog-and-pricing |
| BL-232 | product | GPS-radius "nurses near me" map discovery is explicitly not planned — coverage stays named-district-only. | handoff-after-backend-phase-4/7 | none — permanent product decision | search-and-discovery |
| BL-233 | server | Automated MoH/INO license lookup and a professional-liability-insurance verification step type are both deferred. | handoff-after-backend-phase-6 | a lookup portal exists / product pulls it | nurse-verification |
| BL-234 | ops | The credential-expiry scan and the EVV no-show sweep both run only via manual admin endpoints — no cron calls either yet. | handoff-after-backend-phase-6/9, backend-phase-6 follow-up | scheduled-ops phase | nurse-verification, booking-lifecycle-evv |
| BL-235 | server | SMS.ir/Ghasedak SMS adapters are not built — only Kavenegar's real-SMS path is wired (attempting either throws at startup by design, never silently mocks). | handoff-after-refinement-phase-8, refinement-phase-8 follow-up | a second SMS vendor is needed | auth-login-otp |
| BL-236 | ops | Finnotech/Moadian token-exchange refresh and the Moadian signing certificate are not wired — both are deploy-time actions once real credentials exist. | handoff-after-refinement-phase-8 | going to a real Moadian integration | partner-center |
| BL-237 | ops | The Moadian reconciliation poll and the refund-settlement (BNPL processing→succeeded) poll are both thin/manual — no scheduled cron for either. | handoff-after-backend-phase-11, handoff-after-refinement-phase-7/8, refinement-phase-6/7/8 follow-ups | scheduled-ops phase | cancellation-and-refunds, bnpl-installments |
| BL-238 | server | Per-provider-code BNPL revert is incomplete — the refund path only drives the SnappPay default regardless of the transaction's actual provider. | handoff-after-refinement-phase-8, refinement-phase-8 follow-up | a second BNPL provider goes live | bnpl-installments |
| BL-239 | product | A dedicated merchant-of-record center-settlement payout path (تسهیم split leg) is deferred per decision 6.6. | handoff-after-refinement-phase-8, refinement-phase-6/8 follow-ups | product pulls it | partner-center |
| BL-240 | ops | The weekly payout-batch cron generates automatically, but processing a batch stays a deliberate, explicit admin action by design (not a bug). | handoff-after-refinement-phase-7, backend-phase-13 follow-up | none — permanent product decision | nurse-earnings-and-payouts |
| BL-241 | product | On-demand/instant nurse payout withdrawal and per-nurse payout-frequency configuration are both deferred (MVP is one fixed weekly cadence for everyone). | handoff-after-backend-phase-13, frontend-phase-12 follow-up 4 | product pulls it | nurse-earnings-and-payouts |
| BL-242 | server | Automated clawback recovery beyond simple next-batch netting is not built. | handoff-after-backend-phase-13 | product pulls it | nurse-earnings-and-payouts |
| BL-243 | server | Two-way (nurse-reviews-customer) double-blind reviews with timed reveal are not built. | handoff-after-backend-phase-14 | product pulls it | reviews |
| BL-244 | server | A first-class `incidents` entity is not built — manual suspension + support alerts stand in for now. | handoff-after-backend-phase-14 | product pulls it | admin-backoffice |
| BL-245 | server | **Verified 2026-08-02 (phase 5), resolving this row's own trigger:** `ResolveSupportAlert`, `AssignSupportAlert` (`SupportAlertsController.cs`), and nurse suspension (`AdminSuspendVerificationCommand`, feature folder `SuspendVerification`) are all real, built, and code-traced. Only `FlagConcern` — a softer flag-without-suspending action — is genuinely absent from the codebase; not separately filed as it has no UI or caller either. | backend-phase-14 follow-up, phase-5 roadmap verification | none — resolved; the residual `FlagConcern` gap is small enough to pick up opportunistically, not worth its own BL-### | admin-backoffice |
| BL-246 | server | Partner-center license verification is mocked to manual-approve at MVP rather than an automated eNamad/MoH check. | handoff-after-backend-phase-15 | product pulls it | partner-center |
| BL-247 | product | There is no telephony seam for emergencies — the emergency contact is an out-of-platform `tel:` link by deliberate design. | handoff-after-backend-phase-15 | none — permanent product decision | messaging-tickets |
| BL-248 | client | SMS/push notification channels are not built — only in-app notifications are real. | handoff-after-refinement-phase-9 | notification UX demands out-of-app reach | notifications |
| BL-249 | server | Legacy `UserRefreshTokens` (gRPC auth path) still exists alongside the real session-based REST auth. | backend-phase-2 follow-up | gRPC moves to sessions, or is dropped | auth-login-otp |
| BL-250 | client | The ESLint unused-vars gate is a repo-wide no-op — the config patches an export path that doesn't carry the rule, so `@typescript-eslint/no-unused-vars` never actually runs despite `client/CLAUDE.md`'s golden rule 11 claiming it does. | frontend-phase-13 follow-up 1 | dedicated infra task | — |
| BL-251 | client | PWA/offline caching (Workbox) is unbuilt — marked "maybe" in the original product backlog, i.e. optional. | product/notes open-questions.md | product pulls it | — |
| BL-252 | server | Optional short-TTL cache over hot search result pages was shipped no-cache at MVP. | backend-phase-7 follow-up | search read latency becomes a real problem | search-and-discovery |
| BL-253 | server | A single explicit DB transaction for the payment-webhook confirm path (currently two commits, kept safe via idempotency + a forward-only guard) is flagged as future hardening. | backend-phase-10 follow-up | `IUnitOfWork` grows a transaction scope | checkout-and-payment |
| BL-254 | server | The partner-center dashboard's sponsored-nurse list is capped at 50 with an exact count — needs pagination if a center's roster grows past that. | backend-phase-15 follow-up | a center exceeds 50 sponsored nurses | partner-center |
| BL-255 | server | `Bookings`/`Invoices.partner_center_id` columns exist with no DB-level FK — only `nurse_profiles.partner_center_id` got one, per the shipping phase's own Definition of Done. | backend-phase-15 follow-up, backend-phase-11 follow-up | a data-integrity pass on partner-center columns | partner-center |
| BL-256 | client | Skeleton→content crossfade exists as a one-line-per-screen pattern but was never retrofitted onto every list/detail page. | ui-phase-12 follow-up 1 | a dedicated visual-polish pass | — |
| BL-257 | client | No true desktop search layout — a full responsive pass beyond the phone-width frame was explicitly deferred post-chain. | ui-phase-12 follow-up 2 | product decides to support desktop | search-and-discovery |
| BL-258 | client | Persian OG image variant is Latin-only by deliberate scope cut on `/welcome` and other share cards. | ui-phase-13 follow-up, public-front-door gap-7 | Persian social sharing becomes a priority | public-front-door |
| BL-259 | server | List-row EVV presence indicator (a lightweight "currently checked in" flag on the booking list DTO) was never built — needs a product/API decision to avoid an N+1 read. | ui-phase-5 follow-up 1 | product prioritizes a list-level EVV signal | booking-lifecycle-evv |
| BL-260 | client | Web-push for new nurse booking requests remains deferred — the 15-second poll is the only freshness mechanism. | ui-phase-7 follow-up 5, REQ-054 | push infra (service worker + VAPID + dispatch rail) is built | booking-request |
| BL-261 | client | Ticket attachments' composer button is fully built but gated off pending the upload/signed-URL backend. | REQ-060 (see also BL-168) | attachment backend delivered | messaging-tickets |
| BL-262 | product | Real payment-gateway/Shaparak logos near the pay CTA are deferred — no licensed assets yet; a generic lock-icon trust notice stands in. | ui-phase-6 follow-up 5 | licensed gateway assets obtained | checkout-and-payment |
+147
View File
@@ -0,0 +1,147 @@
# Decisions — the distilled engineering decision log
> Last verified: 2026-08-02 against commit `51e86a1`.
Non-obvious decisions with a reason, extracted from `dev/`'s ~3MB of build history so they survive
`dev/`'s move to `archive/` in phase 6. **`product/` wins for business rules** — this file is for
*engineering* decisions, and for business decisions made **during** the build that never made it back into
`product/` (each of those also gets a note filed in [product/notes/](../../product/notes/open-questions.md)).
Each entry: what was decided, when, why, where it binds.
---
## Business rules made during the build (candidates for `product/` too)
**Commission 0.15 / VAT 0.10, VAT on commission only.** The canonical fee model as of refinement-phase-3:
platform commission is 15% of the gross booking amount; VAT is 10% of the *commission*, not of gross. This
became the single source of truth after the pre-refinement code computed VAT two different ways on two
surfaces (carved out of commission at checkout vs. added to it on the invoice, a 341 IRR disagreement on one
seeded booking — see [backlog.md](backlog.md) BL-060, still open on the invoice screen specifically). Binds:
`server/src/Core/Baya.Application/Features/Payments/`, every invoice/checkout DTO.
**`district_id = NULL` means whole-city, in both directions.** A nurse service area with no district covers
every district in the city; a search with no district filter matches both district-scoped and whole-city
rows. Binds: `nurse_service_areas`, `SqlNurseSearch`, the coverage-picker UI. See
[backlog.md](backlog.md) BL-077 for the one place this invariant currently double-counts a nurse.
**Verification `status` is the source of truth; `is_verified` is a guarded flip.** The nurse-facing status
enum drives all business logic; `nurse_profiles.is_verified` only flips inside the same transaction that
moves status to `approved`, never independently. Decided backend-phase-6. Binds: `NurseVerificationService`,
`nurse_search_index.is_searchable`.
**Booking status is forward-only; the three-amount split has a DB CHECK constraint.** No booking status
transition may move backward, enforced in `BookingTransitions`/`BookingSessionTransitions`
(CONVENTIONS §6 pattern). The `gross`/`platform_commission`/`nurse_payout` three-way split on a booking is
CHECK-constrained to balance at the DB layer, not just in application code. Decided backend-phase-9.
**The two-stage clinical-disclosure gate; EVV is advisory, never a block.** Care details are disclosed in two
stages — coarse notes pre-acceptance, full encrypted `booking_care_instructions` only to the assigned nurse
post-confirmation. A geofence mismatch on check-in/out (EVV) raises a support alert but never blocks the
visit from proceeding — decided explicitly to avoid a GPS false-positive stranding a nurse mid-shift. Binds:
`booking_requests` vs `booking_care_instructions`, `CheckInVisitCommand`/`CheckOutVisitCommand`.
**Webhook idempotency is upsert-first; ledger postings must balance.** Every external webhook handler
(payment PSP, BNPL, payout transfer) upserts on the provider's idempotency key *before* any side effect, so a
replayed webhook is a no-op rather than a double-post. Every `LedgerPosting` is validated to balance (debits
= credits) before commit — decided backend-phase-10, the same phase that found the payment-webhook confirm
path needs two DB commits (booking creation, then transaction+ledger) because ledger legs need the
DB-generated `booking_id`; flagged as future hardening once `IUnitOfWork` grows a transaction scope (see
[backlog.md](backlog.md) BL-253).
**Reviews recompute from source; nurse care records are append-only.** A nurse's `average_rating`/
`total_reviews` are always recomputed from the live review rows, never incrementally maintained, to avoid
drift. Visit-note/care-record writes are append-only — no record is ever edited or deleted, only superseded
by a newer entry. Decided backend-phase-14.
**`is_internal` ticket messages never appear in user-facing types.** The admin-only internal-note boundary on
a support ticket is enforced by keeping `isInternal` out of every client-facing TypeScript type entirely —
not by a runtime filter that could be bypassed. Decided frontend-phase-14, re-confirmed frontend-phase-15
when the admin console was built on top of the same domain.
**One payout per booking (UNIQUE); whole-clawback greedy netting.** A `nurse_payouts` row is
UNIQUE-constrained to one per booking — a booking can never be paid out twice. Clawback recovery uses
greedy whole-amount netting against the next batch rather than partial installments. Decided
backend-phase-13; automated recovery beyond simple netting is deferred (see [backlog.md](backlog.md)
BL-242).
**Escrow releases after a confirmed check-out; weekly payout generation is automatic, processing stays
manual.** Funds move from `escrow_held` to `nurse_payable` only after a checked-out session passes its
dispute window. Payout *batch generation* runs on a weekly cron (refinement-phase-7); actually *transferring*
money in a batch stays a deliberate, explicit admin action by design — decided refinement-phase-7,
re-affirmed in the backend-phase-13 handoff. Not a bug; see [backlog.md](backlog.md) BL-240.
**Config lives in files, not a secret store — deliberate pre-launch trade.** `dotnet user-secrets` was
removed (`<UserSecretsId>` dropped from the `.csproj`); all configuration, including live credentials, lives
in `appsettings.*.json` / `.env.*` / `docker-compose.yml`. This is explicitly temporary — root
[CLAUDE.md](../../CLAUDE.md) §6 requires rotating every credential and moving the secret half out of git
before real users (see [backlog.md](backlog.md) BL-003). The one value that must **never** change once real
data exists: `Seams:FieldEncryption:Key`/`:HashKey`.
**Error state is never an empty state (the client convention).** A failed query must never silently render
the same UI as "no data" — decided in the frontend-phase-1 primitives pass. Still occasionally violated; see
[backlog.md](backlog.md) BL-078 for the one live regression found this phase (`nurse-service-areas`'s
coverage screen drops `isError`).
**Root `/` forks by auth via a middleware rewrite, never a redirect.** An anonymous visitor to `/` gets the
public landing page's content rewritten in at the same URL; the canonical URL and address bar never change.
Decided ui-phase-13, chosen specifically so `/` stays a stable, shareable, indexable URL for both audiences.
---
## Engineering decisions
**Phase 2 → Phase 4 handoff, "the rename is filed."** The placeholder secret sentinel
`SET_VIA_USER_SECRETS_OR_ENV` keeps its name — renaming it needs a server-code + test change that is out of
a documentation phase's scope, and the string is load-bearing across several live files. Filed as
[backlog.md](backlog.md) BL-219, deferred (cleanup-of-convenience, no urgency).
**Phase 7 dropped the pre-commit secret-scan hook — MVP stage, no need for it yet.** `.githooks/pre-commit`
(the `qw123321`/private-key/AWS-key/SQL-host/connection-string scan) and `.githooks/README.md` were deleted
outright, along with every doc reference to them (root `CLAUDE.md`'s repo-layout table and quick start,
[git-and-gates.md](../rules/shared/git-and-gates.md), [documentation.md](../rules/documentation.md),
[identity.md](../rules/server/identity.md), [config-matrix.md](../integration/config-matrix.md),
[DEPLOY.md](../../DEPLOY.md)). The underlying trade this repo already made — committed live credentials,
config in files not a secret store (root [CLAUDE.md](../../CLAUDE.md) §6) — is unchanged; this only removes
the local mechanical backstop against a *new* leak. Revisit before onboarding real users, alongside the
credential rotation already required by that trade.
**Hardening ledger re-verification (C-10) confirms the ledger was right to distrust its own checkboxes.**
All 18 hardening items were re-traced against `b876490` rather than trusted as-filed: 3 were already fixed
(client-only, landed in the "manual improvement" commits well before this doc chain started), 4 are
partially fixed (real progress with concrete residue), and 11 are unchanged since 2026-07-16 despite 14 UI
phases, 2 manual-testing iterations, and a deploy running on top of them. See
[backlog-closed.md](backlog-closed.md) and [backlog.md](backlog.md) for the full disposition of each.
**REQ-061 (admin user directory) is filed for real, despite never getting a ledger header (C-15).**
`ui-phase-11-report.md` claimed "REQ-061…064 appended," but the append-only ledger's numbering jumps
060→062 — REQ-061's body survived as an orphaned, headerless block. Ten live client files depend on the
endpoints it describes. Ruling: it is a genuine, currently-undelivered backend gap and is carried into
[backlog.md](backlog.md) as BL-029, not treated as a documentation artifact to discard.
**Five REQs were filed narrower or wrong relative to the live server (C-16).** REQ-050, REQ-063, REQ-066,
REQ-067 and the REQ-029/030 admin-mock justification were all checked against the live swagger rather than
against another document, during phase 2. `variantLabel` already shipped before REQ-050 was filed;
`tickets/close`+`/reopen` already ship (only `assign` is missing); `search/nurses` and
`nurses/{id}/profile` are already anonymous (only rate-limiting and a privacy sign-off are missing,
respectively). Ruling: REQ status is derived from code, never copied from the ledger's own prose — this
phase's REQ classification (see [backlog.md](backlog.md) and [backlog-closed.md](backlog-closed.md))
followed the same rule throughout, and found one further instance on its own: REQ-050's "neither field
exists" claim was stale even for the field it explicitly named (`variantLabel`), and REQ-057's residue was
found duplicated across two unrelated flows independently citing the same missing DTO field.
**A `true` mock flag does not mean the domain is fully fake, and a `false` flag does not mean it's fully
real.** `verification` has 10 of 14 operations live and probed while mocked; `payment` is flag-real with only
2 of 6 operations actually working. Decided as the framing for this whole reconciliation (phase 3's finding)
— every BL item in this backlog that touches a `USE_*_MOCK` flag states the *specific* operations affected,
never just cites the flag.
**Deferred items keep their pull-trigger, not a target date.** Following the phase-chain convention set in
`dev/shared-working-context/backend/handoff/`, every item in [backlog.md](backlog.md)'s Deferred section
carries the condition that should cause it to be picked up (a specific product decision, a scale threshold,
a second integration) rather than a scheduled date. Phase 5 owns turning these into a sequenced roadmap.
**BL-245's own trigger ("phase 5 verification pass") was executed during phase 5.** `ResolveSupportAlert`,
`AssignSupportAlert`, and nurse suspension (`AdminSuspendVerificationCommand`) were all confirmed real and
code-traced; only `FlagConcern` is genuinely unbuilt. Recorded here rather than left as an open question in
[roadmap/deferred.md](../roadmap/deferred.md) — a small, direct illustration of "verify, don't copy" applied
one phase later than the item that requested it.
+68
View File
@@ -0,0 +1,68 @@
# Implemented — the product overlay
> Last verified: 2026-08-02 against commit `b876490`.
The mapping [`product/`](../../product/index.md)'s 14 business areas need and never had: which flow builds
each area, and what state it's actually in. `product/` itself stays untouched — this file is the bridge
between "what the business is" and "what we built." States: `built` (end-to-end real) · `partial` (real with
named gaps) · `mocked` (UI real, data fake) · `not started` · `deferred`.
Every row's Gaps column links into [backlog.md](backlog.md); the full evidence is in the linked
[docs/flows/](../flows/index.md) file, not restated here.
## Business area → build state
| # | Business area | Doc | Primary flow(s) | State | Gaps |
| --- | --- | --- | --- | --- | --- |
| 01 | Actors & Onboarding | [product/business/01-…](../../product/business/01-actors-and-onboarding.md) | [auth-login-otp](../flows/auth-login-otp.md) **(built)**, [onboarding-customer](../flows/onboarding-customer.md), [onboarding-nurse](../flows/onboarding-nurse.md) | partial | BL-023, BL-031..038, BL-085..092, BL-193..197, BL-249 |
| 02 | Nurse Verification | [product/business/02-…](../../product/business/02-nurse-verification.md) | [nurse-verification](../flows/nurse-verification.md) | **mocked** | BL-010 (blocker), BL-080..084, BL-190..192, BL-214, BL-233 |
| 03 | Catalog & Pricing | [product/business/03-…](../../product/business/03-service-catalog-and-pricing.md) | [nurse-catalog-and-pricing](../flows/nurse-catalog-and-pricing.md) | partial | BL-018 (blocker — zero option groups outside Development), BL-069..072, BL-176..178, BL-231 |
| 04 | Search & Matching | [product/business/04-…](../../product/business/04-search-and-matching.md) | [search-and-discovery](../flows/search-and-discovery.md) | partial | BL-014 (blocker), BL-103, BL-104, BL-210..213, BL-221, BL-232 |
| 05 | Booking & Scheduling | [product/business/05-…](../../product/business/05-booking-and-scheduling.md) | [booking-request](../flows/booking-request.md) | partial | BL-006 (blocker), BL-040..044, BL-071, BL-138..143, BL-228, BL-260 |
| 06 | EVV / Service Delivery | [product/business/06-…](../../product/business/06-evv-and-service-delivery.md) | [booking-lifecycle-evv](../flows/booking-lifecycle-evv.md) | partial | BL-016 (blocker), BL-039, BL-043..046, BL-133..137, BL-259 |
| 07 | Cancellation & Refunds | [product/business/07-…](../../product/business/07-cancellation-and-refunds.md) | [cancellation-and-refunds](../flows/cancellation-and-refunds.md) | **mocked** | BL-009 (blocker), BL-047..054, BL-144..148, BL-215, BL-237 |
| 08 | Payments & Escrow | [product/business/08-…](../../product/business/08-payments-and-escrow.md) | [checkout-and-payment](../flows/checkout-and-payment.md) | partial | BL-005 (blocker), BL-058..062, BL-156..159, BL-253, BL-262 |
| 09 | Installments / BNPL | [product/business/09-…](../../product/business/09-installments-bnpl.md) | [bnpl-installments](../flows/bnpl-installments.md) | **mocked** | BL-007, BL-008 (blockers), BL-126..132, BL-227, BL-238 |
| 10 | Payouts | [product/business/10-…](../../product/business/10-payouts.md) | [nurse-earnings-and-payouts](../flows/nurse-earnings-and-payouts.md) | **mocked** | BL-012 (blocker), BL-073..076, BL-179..184, BL-240..242 |
| 11 | Reviews, Trust & Safety | [product/business/11-…](../../product/business/11-reviews-trust-and-safety.md) | [reviews](../flows/reviews.md) | partial | BL-017 (blocker), BL-099..102, BL-207..209, BL-243 |
| 12 | Messaging & Emergencies | [product/business/12-…](../../product/business/12-messaging-and-emergencies.md) | [messaging-tickets](../flows/messaging-tickets.md) | partial | BL-063..068, BL-160..168, BL-247, BL-261 |
| 13 | Tax, Invoicing & Legal | [product/business/13-…](../../product/business/13-tax-invoicing-and-legal.md) | [partner-center](../flows/partner-center.md) (+ invoice half of `checkout-and-payment`, VAT — see BL-060) | **mocked — weakest area** | BL-013 (blocker), BL-093..095, BL-198..201, BL-236, BL-239, BL-246, BL-254, BL-255 |
| 14 | Notifications & Admin | [product/business/14-…](../../product/business/14-notifications-and-admin.md) | [notifications](../flows/notifications.md) (partial), [admin-backoffice](../flows/admin-backoffice.md) (**mocked**) | **mocked (admin half)** | BL-001, BL-002 (blockers — the RBAC finding that touches 11 of 14 areas), BL-114..117, BL-124, BL-125, BL-169..175, BL-244, BL-245 |
**No area is orphaned** — all 14 map to a primary flow, matching [docs/flows/index.md](../flows/index.md)'s
coverage accounting. **Area 13 is confirmed the weakest**: invoicing and VAT are split across two flows with
no single owner, the entire partner-center portal is mocked with zero tenancy, and BNPL/invoicing can name
two different merchant-of-record centers for the same booking (BL-199).
**One flow has no `product/` source at all**: `account-and-settings` is a ui-phase-9 UI decision with no
business-area file behind it — not a gap in this table, just a fact worth knowing before editing it.
## Cross-cutting: the RBAC finding
[BL-001](backlog.md#blockers-18) (admin RBAC grants only the literal role `admin`, which no seeded account
holds) is the single defect with the widest blast radius in this table — it independently degrades the admin
half of areas 02, 07, 09, 10, 11, 12, 13 and 14, plus the admin-only surfaces inside 03, 05 and 06. Fixing it
does not fix any area's `mocked` state on its own (most of those are separately blocked by their own
`USE_*_MOCK` flag or missing route — see each row above), but it is the single highest-leverage fix in the
whole backlog: [docs/flows/index.md](../flows/index.md#the-six-things-that-surprise-everyone) independently
found the same root cause from the code side.
## Data model — never-built product tables
The STATUS logs and [product/data-model/index.md](../../product/data-model/index.md) name **8 tables**
modeled in the product data model that have no implementation today, all `[DEFERRED]` by product decision
(not oversight — see [decisions.md](decisions.md)):
| Table | Backs | Product doc |
| --- | --- | --- |
| `organizations` | Employer/company account model | [13-partner-centers-and-future.md](../../product/data-model/13-partner-centers-and-future.md) |
| `organization_nurses` | Employer↔nurse membership | same |
| `fraud_flags` | ML-scored fraud signals (manual suspension covers this today) | same |
| `recurring_booking_schedules` | Recurring/subscription bookings | same |
| `bnpl_settlement_entries` | Tranched BNPL settlement (one settlement row covers it today) | [08-bnpl.md](../../product/data-model/08-bnpl.md) |
| `nurse_availability_slots` | Soft scheduling-guidance windows | [05-booking-and-scheduling.md](../../product/data-model/05-booking-and-scheduling.md) |
| `nurse_availability_exceptions` | Time-off exceptions to the above | same |
| `incidents` | First-class incident entity (support alerts cover this today) | [11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
None of these are backlog items — they're recorded product decisions to not build yet, carried in
[backlog.md](backlog.md)'s Deferred section (BL-222 through BL-247) with their pull-triggers.
+75
View File
@@ -0,0 +1,75 @@
# Status — where the project actually is
> Last verified: 2026-08-02 against commit `b876490`. Populated by phase 4 of the
> [documentation clean-up chain](../../archive/clarify-chain/README.md).
This is the page you open after two weeks away. "What's left?" now has one honest answer instead of five
unreconciled ledgers.
## The picture in one table
| | Count |
| --- | --- |
| Flows: built · partial · mocked · not started | 1 · 15 · 7 · 0 (of 23) |
| Business areas covered (of 14) | 14 — none orphaned; **area 13 (Tax, Invoicing & Legal) is weakest** |
| Backlog items — open + deferred | **262** (`BL-001``BL-262`) |
| — blocker | 18 |
| — major | 86 |
| — minor | 115 |
| — deferred (has a pull-trigger) | 43 |
| Backlog items — closed this phase | 88 |
| Raw candidate rows harvested (pre-dedup, across 10 sources) | ~700 |
Full detail: [backlog.md](backlog.md) (open) · [backlog-closed.md](backlog-closed.md) (closed) ·
[implemented.md](implemented.md) (business-area overlay) · [decisions.md](decisions.md) (the ADR log).
## The five things standing between here and a usable product
1. **Admin RBAC is structurally dead ([BL-001](backlog.md#blockers-18)/[BL-002](backlog.md#blockers-18)).**
`DynamicPermissionService` grants only the literal role `admin`, which no seeded account holds — every
admin surface 403s. This one root cause independently degrades **11 of 14 business areas**
([implemented.md](implemented.md#cross-cutting-the-rbac-finding)). Highest leverage fix in the backlog.
2. **The two real money rails both dead-end before completion.** Card payment redirects to a host that
doesn't exist and nothing fires the PSP webhook locally ([BL-005](backlog.md#blockers-18)); no BNPL
gateway is ever seeded, so every BNPL call 400s, and the wizard's own mock cross-imports a store that
404s on any real booking id ([BL-007](backlog.md#blockers-18)/[BL-008](backlog.md#blockers-18)). Neither
money path can be walked end-to-end from a browser today.
3. **Five domains are 100% client-mocked while a working server sits behind them** — verification, refunds,
nurse payouts, patient/care records, and the entire partner-center portal
([BL-009](backlog.md#blockers-18), [BL-010](backlog.md#blockers-18), [BL-011](backlog.md#blockers-18),
[BL-012](backlog.md#blockers-18), [BL-013](backlog.md#blockers-18)). Three of the five would also *break*
on a naive flip — the client and server DTO shapes have drifted.
4. **The booking payment window lies to the customer.** Deadline timestamps ship with no timezone, so in
Tehran a 30-minute countdown renders as ~4 hours and the request silently expires while the timer still
shows time left ([BL-006](backlog.md#blockers-18)).
5. **Two pre-launch security items are live right now.** The repo's committed credentials (DB `sa`, both
encryption-key halves, three third-party API keys) have never been rotated
([BL-003](backlog.md#blockers-18)), and a Development-only OTP-read endpoint is reachable on the
production domain because the deployment runs as Development
([BL-004](backlog.md#blockers-18)).
## Where to go next
| Question | Answer |
| --- | --- |
| Is flow X built? | [docs/flows/index.md](../flows/index.md) — the status table, 23 flows |
| How do I test it? | [docs/flows/testing-setup.md](../flows/testing-setup.md) |
| What's the client↔server contract? | [docs/integration/index.md](../integration/index.md) |
| What are the hard rules? | [docs/rules/index.md](../rules/index.md) |
| What's open, and how bad is it? | [backlog.md](backlog.md) |
| What's already closed? | [backlog-closed.md](backlog-closed.md) |
| Which business area is built vs. mocked? | [implemented.md](implemented.md) |
| Why was X built that way? | [decisions.md](decisions.md) |
| What's next, in order? | [docs/roadmap/index.md](../roadmap/index.md) |
## How this file's numbers were produced
Five ledgers — the [hardening issues list](../../archive/post-phase/hardening/issues.md) (18 items, all
unticked since 2026-07-16), the [67-REQ contract ledger](../../archive/build-chain/working-context/frontend/requests/for-backend.md),
53 phase reports' own "Follow-ups" sections, 22 backend hand-off files' deferrals, and two rounds of raw
manual-testing notes — were harvested in full, then every hardening item, every REQ, and every manual-testing
bullet was **re-verified against the code at `b876490`**, not trusted as filed. Phase 3's **283
already-verified flow gaps** (each with a live code trace, several walked against a booted server) are this
phase's primary, freshest input and form the backbone of [backlog.md](backlog.md). Nothing here is copied
from a stale document without a fresh check — the phase-chain's own rule (["verify, don't
copy"](../../archive/clarify-chain/README.md#non-negotiables-for-every-phase)) held throughout.