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
+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.