Compare commits

...

8 Commits

Author SHA1 Message Date
hamid 70fb0a9202 refinement phase 9 2026-07-13 22:52:57 +03:30
hamid ef3024ef2f refinement phase 8 2026-07-13 21:49:50 +03:30
hamid 7edadadea1 refinement phase 7 2026-07-13 17:48:50 +03:30
hamid 70268ecc06 refinement phase 6 2026-07-13 17:03:45 +03:30
hamid d4147342da refinement phase 5 2026-07-13 16:00:34 +03:30
hamid 64f6aa45c9 refinement phase 4 2026-07-13 12:29:00 +03:30
hamid 314763f764 refinement phase 3 2026-07-13 11:26:39 +03:30
hamid 1ce36f9414 refinement phase 2 2026-07-13 01:14:35 +03:30
352 changed files with 49149 additions and 1002 deletions
+26
View File
@@ -0,0 +1,26 @@
# Git hooks
Repo-managed git hooks (they live in version control, unlike `.git/hooks`).
## Enable (once per clone)
```bash
git config core.hooksPath .githooks
```
## `pre-commit` — secret scan
A fast, dependency-free backstop for the root `CLAUDE.md` rule **"Never commit secrets"**
(refinement-phase-5). It rejects a commit that stages:
- the historically-leaked SQL Server host `87.107.152.16`,
- the retired hardcoded admin password `qw123321`,
- a **real** connection-string password in any `appsettings*.json` (only the `SET_VIA_USER_SECRETS_OR_ENV`
placeholder is allowed — real values belong in user-secrets / environment variables),
- private-key material or an AWS access-key id, anywhere.
It scans only staged additions, so it is quick. It is **not** a replacement for a full scanner
(gitleaks / trufflehog) in CI — it is the local first line of defence.
Bypass a false positive with `git commit --no-verify` (use sparingly, and only when you are certain the
flagged line is not a secret).
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
#
# Balinyaar secret-scanning pre-commit hook (refinement-phase-5).
# Blocks a commit that stages an obvious credential. This is a fast, dependency-free backstop for the
# root CLAUDE.md rule "Never commit secrets" — not a replacement for gitleaks/trufflehog in CI.
#
# Enable once per clone: git config core.hooksPath .githooks
# Bypass a false positive: git commit --no-verify (use sparingly, and only when you are certain)
#
set -euo pipefail
# Committed placeholders are allowed — real values are not. Keep in sync with StartupSecretsGuard.
PLACEHOLDER='SET_VIA_USER_SECRETS_OR_ENV'
# Only scan added/changed lines in text files that are staged.
staged=$(git diff --cached --name-only --diff-filter=ACM)
[ -z "$staged" ] && exit 0
violations=0
report() { printf ' ✖ %s\n' "$1"; violations=$((violations + 1)); }
while IFS= read -r file; do
# Skip this hook, lockfiles, and binaries.
case "$file" in
.githooks/*) continue ;;
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.dll|*.exe|*.snk) continue ;;
esac
[ -f "$file" ] || continue
added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++' || true)
[ -z "$added" ] && continue
# The historically-leaked SQL Server host — must never reappear.
echo "$added" | grep -Eq '87\.107\.152\.16' && report "$file: leaked SQL Server host 87.107.152.16"
# The retired hardcoded admin password.
echo "$added" | grep -Eq 'qw123321' && report "$file: hardcoded admin password 'qw123321'"
# A real (non-placeholder) connection-string password in a committed appsettings file.
case "$file" in
*appsettings*.json)
echo "$added" \
| grep -Ei 'Password=[^;"'"'"' ]+' \
| grep -viq "Password=${PLACEHOLDER}" \
&& report "$file: connection-string password must be '${PLACEHOLDER}' (real value belongs in user-secrets/env)"
;;
esac
# Private keys and common cloud tokens, anywhere.
echo "$added" | grep -Eq -- '-----BEGIN (RSA|EC|OPENSSH|PRIVATE) .*PRIVATE KEY-----' && report "$file: private key material"
echo "$added" | grep -Eq 'AKIA[0-9A-Z]{16}' && report "$file: AWS access key id"
done <<< "$staged"
if [ "$violations" -gt 0 ]; then
echo ""
echo "Commit blocked: $violations potential secret(s) staged. Move the real value to user-secrets"
echo "(Development) or an environment variable (deploy) and commit only the '${PLACEHOLDER}' placeholder."
echo "See dev/post-phase/refinement/RUNBOOK.md. To override a false positive: git commit --no-verify"
exit 1
fi
exit 0
+39 -9
View File
@@ -117,7 +117,7 @@ client/
│ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me) │ │ ├── layout.tsx # 'use client' — wraps PrivateLayout; mounts useSessionRoleSync (hydrates AuthContext roles from /me)
│ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here │ │ ├── select-role/page.tsx # /select-role — first-use role picker (no public role yet); role router lands here
│ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment │ │ ├── (customer)/ # Customer (family) app — mobile-first, bottom-tab nav; no URL segment
│ │ │ ├── layout.tsx # 'use client' — wraps CustomerLayout │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=customer) → CustomerLayout
│ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges) │ │ │ ├── page.tsx # / (A5 home — 'use client'; greeting+avatar, search bar, data-driven category grid, first-login onboarding gate + record/profile nudges)
│ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3) │ │ │ ├── search/ # /search — f6 discovery: C1 filter screen (page.tsx: reused category grid + f3 region picker + prominent same-gender facet + Toman price + live-count CTA; useSearchFilters colocated controller) → results/ (C2) → nurse/[nurseId]/ (C3)
│ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params │ │ │ │ ├── page.tsx # C1 search & filter; reads ?category_id preselect; pushes filter set to C2 as URL query params
@@ -136,8 +136,7 @@ client/
│ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews) │ │ │ │ ├── [id]/review/page.tsx # /bookings/[id]/review — f13 leave-a-review (b14): RatingInput + body + ReviewTagSelector; gated on completed/closed + server can_review + 1:1; on submit → persistent "under review" (pending_moderation, never public here); already-reviewed shows the review state, never a 2nd form (services/reviews)
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=) │ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + «پرداخت اقساطی» → f11 BNPL wizard
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches) │ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired (the payment mock-gateway harness was removed in refinement-phase-4 when USE_PAYMENT_MOCK flipped; on the real path the PSP redirectUrl is absolute)
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
│ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee) │ │ │ │ ├── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice); REUSED by f11 (?method=bnpl adds «پرداخت‌شده با اقساط» — a settled BNPL order is a card payment net-of-fee)
│ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=) │ │ │ │ └── bnpl/ # f11 BNPL installment checkout (the alternate branch off C6, reached with ?request_id=)
│ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere │ │ │ │ ├── page.tsx # D1→D4 stateful wizard (StepperHeader): D1 method/provider · D2 plan · D3 eligibility · D4 schedule+contract → provider handoff; card fall-back → C6 everywhere
@@ -155,7 +154,7 @@ client/
│ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging │ │ │ ├── support/tickets/ # /support/tickets — f14 "My Tickets" inbox (TicketInboxScreen role="customer") ↔ support/tickets/[id]/page.tsx thread (TicketThreadScreen); thin role-passing wrappers over @/components/messaging
│ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here │ │ │ └── notifications/page.tsx # /notifications — f14 notification center (NotificationCenter role="customer"); the TopBar bell deep-links here
│ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell │ │ ├── nurse/ # Nurse app (/nurse/…) — sidebar shell
│ │ │ ├── layout.tsx # 'use client' — wraps NurseLayout │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=nurse) → NurseLayout
│ │ │ ├── page.tsx # /nurse (dashboard) │ │ │ ├── page.tsx # /nurse (dashboard)
│ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail) │ │ │ ├── requests/ # /nurse/requests — f7 incoming booking-requests inbox (page.tsx: pending list, per-request countdown + gender chip + notes preview) ↔ requests/[id]/page.tsx detail (only customerNotes + masked city/district; accept/reject-with-reason invalidate inbox+detail)
│ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder) │ │ │ ├── profile/page.tsx # /nurse/profile — B7 profile bootstrap (avatar+bio+years; unverified placeholder)
@@ -174,7 +173,7 @@ client/
│ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx │ │ │ ├── support/tickets/ # /nurse/support/tickets — f14 nurse "My Tickets" (same TicketInboxScreen/TicketThreadScreen, role="nurse") ↔ support/tickets/[id]/page.tsx
│ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here │ │ │ └── notifications/page.tsx # /nurse/notifications — f14 notification center (role="nurse"); the nurse-shell bell deep-links here
│ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces). │ │ ├── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell (f15). Every screen is role-gated via useAdminCapabilities(); the sidebar hides a console the current admin role can't act on (server still enforces).
│ │ │ ├── layout.tsx # 'use client' — wraps AdminLayout (capability-gated nav) │ │ │ ├── layout.tsx # 'use client' — RoleGuard(expected=admin) → AdminLayout (capability-gated nav)
│ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards │ │ │ ├── page.tsx # /admin — f15 overview landing: a capability-gated grid of console cards
│ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified) │ │ │ ├── verification/ # /admin/verification — f15 review queue (page.tsx: status-filtered nurse worklist) ↔ [nurseId]/page.tsx per-nurse case (DocumentViewer signed-URL docs, pass/reject+reason per step, structured credential entry, Approve enabled only when all steps pass — client never writes is_verified)
│ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket) │ │ │ ├── tickets/ # /admin/tickets — f15 global ticket queue (page.tsx: filter status/category/referenceCode) ↔ [id]/page.tsx admin thread (AdminMessageBubble renders isInternal notes distinctly; internal-note composer; RefundPanel opens from a refund ticket)
@@ -189,7 +188,7 @@ client/
│ │ │ ├── users/page.tsx # /admin/users │ │ │ ├── users/page.tsx # /admin/users
│ │ │ └── notifications/page.tsx # /admin/notifications │ │ │ └── notifications/page.tsx # /admin/notifications
│ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404). │ │ └── partner/ # Partner-center portal (/partner/…) — a SEPARATE authz scope (f15). A center admin is not a Balinyaar admin; each page resolves the caller's OWN center (useMyPartnerCenter → access-denied on 403/404).
│ │ ├── layout.tsx # 'use client' — wraps PartnerLayout (own partner nav) │ │ ├── layout.tsx # 'use client' — RoleGuard (no expected role — hydration-only) → PartnerLayout (own partner nav; self-gates via useMyPartnerCenter)
│ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator │ │ ├── page.tsx # /partner — center home: onboarding/verification state banner + license fields + is_merchant_of_record indicator
│ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge) │ │ ├── nurses/page.tsx # /partner/nurses — the center's sponsored nurses (verification badge)
│ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries) │ │ ├── bookings/page.tsx # /partner/bookings — the bookings the center legally covers (read-only summaries)
@@ -237,7 +236,7 @@ client/
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts │ ├── messaging/ # f14 tickets composites (import from @/components/messaging). Screens shared by the customer+nurse pages (role decides chrome): TicketInboxScreen, TicketThreadScreen (+ TicketMessageList), ContactSupportDialog (new-ticket → shows referenceCode), MessageComposer (optimistic send, draft-preserving), BookingSupportEntry (page-local glue on f8 booking detail — reuses the cached booking + care query, no refetch). Pure/tested: MessageBubble (mine/theirs, RTL-mirrored, never any internal-note styling), TicketListCard (prominent referenceCode + unread indicator + null-safe link), EmergencyBanner (post-confirmation tel: playbook, no VoIP seam). Helpers: statusKind.ts, authorLabel.ts
│ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts │ ├── notifications/ # f14 notification composites (import from @/components/notifications). NotificationBell (chrome container — subscribes to the polling count so only it re-renders) → NotificationBellView (pure, tested), NotificationRow (pure, tested: unread emphasis + server title/body), NotificationCenter (shared page body: unread-first, mark-read-on-open + mark-all, deep-links via notificationDeepLink). Helper: notificationIcon.ts
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, RoleGuard (role-aware shell guard, tested), AuthAccountError (/me-failed recovery), useCountdown
├── i18n/ ├── i18n/
│ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa' │ ├── routing.ts # defineRouting — locales: ['en', 'fa'], defaultLocale: 'fa'
│ └── request.ts # getRequestConfig — loads messages/${locale}.json │ └── request.ts # getRequestConfig — loads messages/${locale}.json
@@ -277,7 +276,7 @@ client/
│ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie │ ├── client.ts # getClientCookie, setClientCookie, deleteClientCookie
│ └── index.ts # Re-exports constants ONLY (never server/client) │ └── index.ts # Re-exports constants ONLY (never server/client)
├── services/ # Domain services — no top-level barrel; import directly from the file ├── services/ # Domain services — no top-level barrel; import directly from the file
│ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync │ ├── auth/ # Phone-OTP auth: requestOtp/verifyOtp/refresh/logout/me/selectRole + role router (routing.ts) + useSessionRoleSync + useRoleHydration (resolved-vs-pending role state for RoleGuard)
│ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper │ ├── patients/ # Care-recipient CRUD (b3 PatientDto + client-augmented relation/conditions), soft-archive; age.ts helper
│ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam) │ ├── profiles/ # Customer + nurse profile get/upsert + avatar (behind the ProfilesApi seam)
│ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states │ ├── nurse/ # Nurse payout bank accounts + IBAN(Sheba) util (iban.ts) + ownership-inquiry states
@@ -408,7 +407,7 @@ async function MyServerComponent() {
- `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard` - `'records'` — the f13 E2 care-record viewer + the nurse visit-note panel: the ownership banner, the four tab labels (`tab_{medications,routine,history,tasks}`), the access-denied + not-found cards, the editable-record field labels (`med_*`/`routine_*`/`task_*`) + empty states, the paged-history controls (`prev`/`next`/`page_of`) + visit-note author fallback, and the nurse composer copy (`notes_title`/`tasks_checklist_title`/`note_*`/`continuity_title`); shared enum labels (relation/gender/condition) are REUSED from `onboarding`/`patients`, never re-keyed; consumed by the E2 record page + `NurseVisitNotesPanel` + `VisitNoteCard`
- `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry` - `'tickets'` — the f14 messaging surface (tickets are the only post-booking channel): the inbox (`title`/`contact_support`/`empty_*`/`error_body`), the category + status labels keyed off the code (`category_{support,coordination,refund,emergency}`/`status_{open,closed}`), the linked-entity hints (`linked_booking`/`linked_refund` with `{id}`), `ref_code_label`, the new-ticket dialog (`new_ticket_title`/`category_label`/`subject_label`/`message_label`/`submit`/`created_*`/`view_thread`), the thread (`back_to_tickets`/`thread_*`/`closed_notice`), the composer (`sending`/`send`/`send_failed`/`composer_placeholder`), the author-role labels (`author_{customer,nurse,support,system}``admin`→support), and the **emergency playbook** (`emergency_title`/`emergency_body`/`emergency_call {name}`/`emergency_call_generic`/`emergency_open_ticket`) + `open_from_booking`; consumed by the ticket screens, `MessageBubble`/`TicketListCard`/`EmergencyBanner`/`ContactSupportDialog`/`MessageComposer`/`BookingSupportEntry`
- `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell` - `'notifications'` — the f14 notification center + bell: `title`, `empty_*`, `error_body`, `retry`, `mark_all_read`, `load_more`, and the polled-bell aria (`bell_aria` with `{count, number}`); the row `title`/`body` are **server-rendered** copy, not keys. Consumed by `NotificationCenter` + `NotificationBell`
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) - `'auth'` — the phone-OTP login flow, role router, RoleGuard (loading/`account_error_*`/`guard_denied`), and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
- `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites - `'admin'` — the f15 backoffice consoles: verification queue/case, refund panel, payout dashboard/detail, review moderation, config editor + change-history, holiday manager, support-alert board, audit viewer, admin ticket queue/thread, RBAC grid, and admin-side partner management. Includes the **Persian legal terms** (پروانه تأسیس / مسئول فنی / نماد اعتماد الکترونیکی) and the enum-label prefixes keyed off the stable code (`step_*`/`agg_*`/`atype_*`/`astatus_*`/`sev_*`/`htype_*`/`dtype_*`/`batch_status_*`/`pstatus_*`/`channel_*`/`rstatus_*`/`mstatus_*`/`center_state_*`/`role_*`/`tcat_*`/`tstatus_*`). Consumed by the `/admin/*` screens + the `@/components/admin` composites
- `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow` - `'partner'` — the f15 partner-center portal (a separate authz scope): center home/onboarding-state, sponsored nurses/bookings, and the merchant-of-record settlement/invoice view (سامانه مودیان, commission/VAT decomposition). Consumed by the `/partner/*` screens + `PartnerSettlementRow`
@@ -659,6 +658,19 @@ Every domain follows the same shape: `types.ts` (wire types + the domain's `Api`
twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config twice — a real `clientApi.ts` and an in-memory `mockApi.ts` — and select in `apis/index.ts` by a config
flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in flag (`USE_{DOMAIN}_MOCK`). Hooks import the selected `api`; the swap is one line. Record every mock in
`dev/shared-working-context/reports/mocks-registry.md`. `dev/shared-working-context/reports/mocks-registry.md`.
- **De-mock status (refinement-phase-4):** **14 domains are now REAL** (`USE_*_MOCK = false`): `auth`,
`geography`, `patients`, `profiles`, `nurse` (bank), `addresses`, `serviceAreas`, `catalog`, `search`,
`bookingRequests`, `bookings`, `payment`, `reviews`, `notifications`, `tickets`. Flipping them required
updating each `clientApi.ts` to **consume the fields Phase-3 delivered** (search name/avatar/distance +
`nurses/{id}/profile`; patient relation/conditions; address `provinceId`; booking-request
`variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt`/`clientMessageId`; review `my_review`
mapper; profile `avatarUrl`/`preferredLanguage` + a **multipart avatar upload** now that `clientFetch`
passes `FormData` bodies through). **7 domains stay mocked** because a precondition REQ is deferred/unsafe:
`verification` (REQ-034 admin queue), `refunds` (REQ-035 admin preview), `payouts` (REQ-036 admin preview),
`admin` (REQ-031 RBAC roles), `bnpl` (REQ-022/024 options/schedule/wallet), `partnerCenter` (REQ-032/033/038
portal reads + `/me` signal), `patientRecords` (REQ-027 endpoints exist but the client family-record
`id` model is `string` vs the wire's `int` — the customer-edit PUT is write-unsafe until reconciled). Note:
the `EVV_GPS_MODE` seam auto-selects `off` (real `navigator.geolocation`) once `USE_BOOKINGS_MOCK=false`.
- **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode, - **The wire envelope:** the server wraps responses in `ApiEnvelope<T>` (`{ isSuccess, statusCode,
message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so message, requestId, data }`, camelCase — see `lib/api/types.ts`). `clientFetch` returns the raw body, so
a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` + a real `clientApi` reads the payload via `unwrap()`. Types are derived from `dev/contracts/` +
@@ -685,6 +697,24 @@ splash while `/me` loads so the wrong shell never flashes. The routing decision
`resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware `resolveRoleDestination(me, intendedRole)` in `src/services/auth/routing.ts` (unit-tested). The middleware
still owns the auth gate; the router only decides *which app*. still owns the auth gate; the router only decides *which app*.
**Role-aware shell guard (resolved-vs-pending hydration).** Every private shell — `(customer)`, `nurse`,
`admin`, `partner` — wraps its layout in **`RoleGuard`** (`src/components/auth/RoleGuard.tsx`). This exists
because the *core* role bug is conflating **"`/me` hasn't resolved yet"** with **"the user has no
nurse/admin role"**: a fresh `/me` in-flight used to fall through the `DEFAULT_ROLE = customer` fallback and
flash a nurse the customer app (or strand them there if `/me` failed). `RoleGuard` reads
**`useRoleHydration()`** (`services/auth`, a discriminated `loading | error | ready` over `useMe`) and:
- **loading** → a neutral brand splash (never the customer shell as a stand-in);
- **error** (`/me` failed, e.g. API down) → `AuthAccountError` with retry (never a silent customer fallback —
a transient error must not downgrade a nurse/admin);
- **role mismatch** → redirect to the caller's real app via `resolveRoleDestination` (the single "which app"
source) with a `guard_denied` toast, instead of rendering a shell they lack the role for.
A shell passes `expected={APP_ROLES.*}`; the partner portal passes **no** `expected` (it isn't an `AppRole`
— it self-gates on `useMyPartnerCenter`, so `RoleGuard` there only hardens hydration). The guard is **UX/chrome,
not security** — the server authorizes every endpoint; a dual customer+nurse session holds both roles and moves
freely between the family and nurse apps. `useActorRole()`'s `DEFAULT_ROLE` fallback is now only a last resort
(the guard ensures roles are hydrated before a shell renders), never the loading state.
**Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone, **Session state lives in `AuthContext`** (`src/context/auth/`), now carrying `SessionUser { id?, phone,
roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()` roles: AppRole[] }`. The root layout resolves the session on the server with `getServerAuthState()`
(`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared (`src/lib/auth/server.ts`) — which reads the `access_token` cookie and checks the JWT `exp` via the shared
+5 -1
View File
@@ -648,7 +648,11 @@
"role_customer_desc": "Book nurses and home care", "role_customer_desc": "Book nurses and home care",
"role_nurse": "Nurse", "role_nurse": "Nurse",
"role_nurse_desc": "Offer nursing services", "role_nurse_desc": "Offer nursing services",
"continue": "Continue" "continue": "Continue",
"guard_denied": "You don't have access to that area.",
"account_error_title": "Couldn't load your account",
"account_error_body": "We couldn't reach Balinyaar to load your account. Check your connection and try again.",
"account_error_retry": "Try again"
}, },
"verification": { "verification": {
"title": "Verification", "title": "Verification",
+9 -5
View File
@@ -50,7 +50,7 @@
"close": "بستن", "close": "بستن",
"optional": "اختیاری", "optional": "اختیاری",
"currency_toman": "تومان", "currency_toman": "تومان",
"brand": "بلینیار", "brand": الین یار",
"brand_tagline": "مراقبت مطمئن در خانه" "brand_tagline": "مراقبت مطمئن در خانه"
}, },
"shell": { "shell": {
@@ -622,7 +622,7 @@
"issuer_platform": "بالین‌یار" "issuer_platform": "بالین‌یار"
}, },
"auth": { "auth": {
"customer_title": "ورود به بلینیار", "customer_title": "ورود به بالین یار",
"customer_subtitle": "با شماره موبایل خود وارد شوید", "customer_subtitle": "با شماره موبایل خود وارد شوید",
"nurse_title": "ورود پرستاران", "nurse_title": "ورود پرستاران",
"nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری", "nurse_subtitle": "ویژه پرستاران دارای پروانه نظام پرستاری",
@@ -642,13 +642,17 @@
"resend": "ارسال مجدد کد", "resend": "ارسال مجدد کد",
"change_number": "تغییر شماره", "change_number": "تغییر شماره",
"routing_title": "در حال ورود…", "routing_title": "در حال ورود…",
"select_role_title": "به بلینیار خوش آمدید", "select_role_title": "به بالین یار خوش آمدید",
"select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید", "select_role_subtitle": "برای شروع، نقش خود را انتخاب کنید",
"role_customer": "خانواده", "role_customer": "خانواده",
"role_customer_desc": "برای رزرو پرستار و مراقبت در منزل", "role_customer_desc": "برای رزرو پرستار و مراقبت در منزل",
"role_nurse": "پرستار", "role_nurse": "پرستار",
"role_nurse_desc": "برای ارائه خدمات پرستاری", "role_nurse_desc": "برای ارائه خدمات پرستاری",
"continue": "ادامه" "continue": "ادامه",
"guard_denied": "شما به این بخش دسترسی ندارید.",
"account_error_title": "حساب شما بارگذاری نشد",
"account_error_body": "در ارتباط با بالین یار برای بارگذاری حساب شما مشکلی پیش آمد. اتصال خود را بررسی کنید و دوباره تلاش کنید.",
"account_error_retry": "تلاش مجدد"
}, },
"verification": { "verification": {
"title": "احراز هویت", "title": "احراز هویت",
@@ -656,7 +660,7 @@
"retry": "تلاش مجدد", "retry": "تلاش مجدد",
"load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.", "load_error": "بارگذاری وضعیت احراز هویت ممکن نشد.",
"start_title": "احراز هویت را آغاز کنید", "start_title": "احراز هویت را آغاز کنید",
"start_body": "اعتماد، بنیان بلینیار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.", "start_body": "اعتماد، بنیان بالین یار است. با تکمیل این مراحل، خانواده‌ها با اطمینان شما را انتخاب می‌کنند.",
"start_cta": "شروع احراز هویت", "start_cta": "شروع احراز هویت",
"starting": "در حال آماده‌سازی…", "starting": "در حال آماده‌سازی…",
"progress_title": "پیشرفت احراز هویت", "progress_title": "پیشرفت احراز هویت",
@@ -1,76 +0,0 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('gateway_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('gateway_hint')}
</Typography>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -1,12 +1,20 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { CustomerLayout } from '@/layout'; import { CustomerLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/* /*
* Customer (family) route group — the primary mobile-first experience with the * Customer (family) route group — the primary mobile-first experience with the
* 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL * 5-tab bottom nav. A route group `(customer)` adds chrome without adding a URL
* segment, so these screens live at the app root (/, /bookings, /patients, …). * segment, so these screens live at the app root (/, /bookings, /patients, …).
* RoleGuard gates it on a resolved customer role: a pure nurse lands on /nurse,
* a role-less user on /select-role — never the family app as a loading stand-in.
*/ */
export default function CustomerRouteLayout({ children }: { children: ReactNode }) { export default function CustomerRouteLayout({ children }: { children: ReactNode }) {
return <CustomerLayout>{children}</CustomerLayout>; return (
<RoleGuard expected={APP_ROLES.CUSTOMER}>
<CustomerLayout>{children}</CustomerLayout>
</RoleGuard>
);
} }
@@ -8,16 +8,25 @@ import { isIranianMobile } from '@/components/PhoneNumberField';
import { ROUTES } from '@/constants'; import { ROUTES } from '@/constants';
import { digitsOnly } from '@/utils'; import { digitsOnly } from '@/utils';
import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles'; import { useCustomerProfile, useUpsertCustomerProfile } from '@/services/profiles';
import { useMe } from '@/services/auth';
import type { CustomerProfile } from '@/services/profiles/types'; import type { CustomerProfile } from '@/services/profiles/types';
/** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */ /** Customer profile — name, preferred language, and the emergency contact. No national-ID KYC. */
export default function CustomerProfilePage() { export default function CustomerProfilePage() {
const { data: profile, isLoading } = useCustomerProfile(); const { data: profile, isLoading } = useCustomerProfile();
const { data: me } = useMe();
if (isLoading) return <AppLoading />; if (isLoading) return <AppLoading />;
return <CustomerProfileForm initial={profile ?? null} />; // The customer name is owned by `/me` (REQ-007), not `CustomerProfileDto` — prefill it from there so
// editing the emergency contact never blanks (and re-saves as null) the existing name.
return (
<CustomerProfileForm initial={profile ?? null} nameFallback={{ firstName: me?.firstName ?? null, lastName: me?.lastName ?? null }} />
);
} }
const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }> = ({ initial }) => { const CustomerProfileForm: FunctionComponent<{
initial: CustomerProfile | null;
nameFallback: { firstName: string | null; lastName: string | null };
}> = ({ initial, nameFallback }) => {
const t = useTranslations('profile'); const t = useTranslations('profile');
const ta = useTranslations('address'); const ta = useTranslations('address');
const tc = useTranslations('common'); const tc = useTranslations('common');
@@ -25,8 +34,8 @@ const CustomerProfileForm: FunctionComponent<{ initial: CustomerProfile | null }
const { enqueueSnackbar } = useSnackbar(); const { enqueueSnackbar } = useSnackbar();
const upsert = useUpsertCustomerProfile(); const upsert = useUpsertCustomerProfile();
const [firstName, setFirstName] = useState(initial?.firstName ?? ''); const [firstName, setFirstName] = useState(initial?.firstName ?? nameFallback.firstName ?? '');
const [lastName, setLastName] = useState(initial?.lastName ?? ''); const [lastName, setLastName] = useState(initial?.lastName ?? nameFallback.lastName ?? '');
const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa'); const [language, setLanguage] = useState(initial?.preferredLanguage ?? 'fa');
const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? ''); const [emergencyName, setEmergencyName] = useState(initial?.defaultEmergencyContactName ?? '');
const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? '')); const [emergencyPhone, setEmergencyPhone] = useState(digitsOnly(initial?.defaultEmergencyContactPhone ?? ''));
@@ -1,11 +1,18 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { AdminLayout } from '@/layout'; import { AdminLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/* /*
* Admin / backoffice route group (/admin/…) — desktop-oriented ops console (f15) * Admin / backoffice route group (/admin/…) — desktop-oriented ops console (f15)
* with a persistent sidebar. * with a persistent sidebar. RoleGuard gates the shell on the (collapsed) admin actor
* role; the per-console fine-grained gating stays with useAdminCapabilities inside.
*/ */
export default function AdminRouteLayout({ children }: { children: ReactNode }) { export default function AdminRouteLayout({ children }: { children: ReactNode }) {
return <AdminLayout>{children}</AdminLayout>; return (
<RoleGuard expected={APP_ROLES.ADMIN}>
<AdminLayout>{children}</AdminLayout>
</RoleGuard>
);
} }
@@ -1,11 +1,18 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { NurseLayout } from '@/layout'; import { NurseLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
import { APP_ROLES } from '@/constants';
/* /*
* Nurse route group (/nurse/…) — its own shell (dashboard, verification, EVV visits). * Nurse route group (/nurse/…) — its own shell (dashboard, verification, EVV visits).
* A real path segment keeps nurse screens namespaced under /nurse. * A real path segment keeps nurse screens namespaced under /nurse. RoleGuard redirects
* a caller without the nurse role home (with a toast); a nurse never flashes the wrong app.
*/ */
export default function NurseRouteLayout({ children }: { children: ReactNode }) { export default function NurseRouteLayout({ children }: { children: ReactNode }) {
return <NurseLayout>{children}</NurseLayout>; return (
<RoleGuard expected={APP_ROLES.NURSE}>
<NurseLayout>{children}</NurseLayout>
</RoleGuard>
);
} }
@@ -1,12 +1,19 @@
'use client'; 'use client';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { PartnerLayout } from '@/layout'; import { PartnerLayout } from '@/layout';
import { RoleGuard } from '@/components/auth';
/* /*
* Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center * Partner-center portal route group (/partner/…) — a separate authz scope from /admin (f15). A center
* admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's * admin sees only their own center; tenancy is server-enforced and each portal page resolves the caller's
* own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state). * own center via `useMyPartnerCenter` (a 403/404 renders the access-denied state). The RoleGuard here takes
* no `expected` role — partner scope isn't an `AppRole`, so the guard only hardens `/me` hydration (neutral
* loading/error instead of the raw shell); the center-resolution gate stays with `useMyPartnerCenter`.
*/ */
export default function PartnerRouteLayout({ children }: { children: ReactNode }) { export default function PartnerRouteLayout({ children }: { children: ReactNode }) {
return <PartnerLayout>{children}</PartnerLayout>; return (
<RoleGuard>
<PartnerLayout>{children}</PartnerLayout>
</RoleGuard>
);
} }
@@ -0,0 +1,48 @@
'use client';
import { FunctionComponent } from 'react';
import { CircularProgress, Stack, Typography } from '@mui/material';
import { useTranslations } from 'next-intl';
import { AppButton } from '@/components';
import AppIcon from '@/components/common/AppIcon';
import BrandMark from './BrandMark';
interface AuthAccountErrorProps {
onRetry: () => void;
isRetrying: boolean;
}
/**
* Shown by `RoleGuard` when `/me` fails on a private route (e.g. the API is unreachable). Surfacing an
* explicit "couldn't load your account" recovery is the deliberate alternative to silently defaulting to
* the customer shell — a transient error must never downgrade a nurse/admin to the family app.
* @component AuthAccountError
*/
const AuthAccountError: FunctionComponent<AuthAccountErrorProps> = ({ onRetry, isRetrying }) => {
const t = useTranslations('auth');
return (
<Stack
sx={{ alignItems: 'center', justifyContent: 'center', minHeight: '70vh', gap: 2, px: 2, textAlign: 'center' }}
>
<BrandMark />
<AppIcon icon="warning" size={40} color="var(--bal-warning)" />
<Typography variant="h6" component="h1">
{t('account_error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
{t('account_error_body')}
</Typography>
<AppButton
color="primary"
variant="contained"
onClick={onRetry}
disabled={isRetrying}
startIcon={isRetrying ? <CircularProgress size={18} color="inherit" /> : undefined}
>
{t('account_error_retry')}
</AppButton>
</Stack>
);
};
export default AuthAccountError;
@@ -0,0 +1,93 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from '../../theme';
import type { RoleHydration } from '@/services/auth';
const mockReplace = jest.fn();
const mockEnqueue = jest.fn();
jest.mock('next/navigation', () => ({
...jest.requireActual('next/navigation'),
useRouter: () => ({ replace: mockReplace }),
}));
jest.mock('next-intl', () => ({ useLocale: () => 'fa', useTranslations: () => (key: string) => key }));
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: mockEnqueue }) }));
let hydration: RoleHydration;
jest.mock('@/services/auth', () => ({ useRoleHydration: () => hydration }));
import RoleGuard from './RoleGuard';
const CHILD = <div data-testid="shell">shell content</div>;
function renderGuard(expected?: 'customer' | 'nurse' | 'admin') {
render(
<ThemeProvider>
<RoleGuard expected={expected}>{CHILD}</RoleGuard>
</ThemeProvider>,
);
}
describe('<RoleGuard/>', () => {
beforeEach(() => {
mockReplace.mockReset();
mockEnqueue.mockReset();
});
it('renders a neutral splash (not the shell) while /me is loading', () => {
hydration = { status: 'loading' };
renderGuard('nurse');
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it('renders the account-error state (not the shell) when /me failed', () => {
hydration = { status: 'error', retry: jest.fn(), isRetrying: false };
renderGuard('nurse');
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
expect(screen.getByText('account_error_title')).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it('calls retry when the account-error button is pressed', async () => {
const retry = jest.fn();
hydration = { status: 'error', retry, isRetrying: false };
renderGuard('nurse');
await userEvent.click(screen.getByText('account_error_retry'));
expect(retry).toHaveBeenCalledTimes(1);
});
it('renders the shell when the session holds the expected role', () => {
hydration = { status: 'ready', me: { roles: ['nurse'] } as never, appRoles: ['nurse'] };
renderGuard('nurse');
expect(screen.getByTestId('shell')).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it('lets a dual customer+nurse session into the nurse shell', () => {
hydration = { status: 'ready', me: { roles: ['customer', 'nurse'] } as never, appRoles: ['customer', 'nurse'] };
renderGuard('nurse');
expect(screen.getByTestId('shell')).toBeInTheDocument();
});
it('redirects (with a toast) a pure customer away from the nurse shell', async () => {
hydration = { status: 'ready', me: { roles: ['customer'] } as never, appRoles: ['customer'] };
renderGuard('nurse');
expect(screen.queryByTestId('shell')).not.toBeInTheDocument();
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/'));
expect(mockEnqueue).toHaveBeenCalledWith('guard_denied', { variant: 'warning' });
});
it('redirects a role-less user to select-role', async () => {
hydration = { status: 'ready', me: { roles: [] } as never, appRoles: [] };
renderGuard('customer');
await waitFor(() => expect(mockReplace).toHaveBeenCalledWith('/fa/select-role'));
});
it('with no expected role (partner scope) renders once /me resolves without redirecting', () => {
hydration = { status: 'ready', me: { roles: ['customer'] } as never, appRoles: ['customer'] };
renderGuard(undefined);
expect(screen.getByTestId('shell')).toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
});
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { FunctionComponent, ReactNode, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { useSnackbar } from 'notistack';
import { type AppRole } from '@/constants';
import { useRoleHydration } from '@/services/auth';
import { resolveRoleDestination } from '@/services/auth/routing';
import AuthSplash from './AuthSplash';
import AuthAccountError from './AuthAccountError';
interface RoleGuardProps {
/**
* The actor shell being entered. When set, a session that lacks the role is **redirected** to its real
* destination (chrome UX, not security — the server still authorizes every endpoint). Omit for a scope
* not keyed on `AppRole` (the partner portal, which self-gates via `useMyPartnerCenter`); the guard then
* only hardens hydration — a neutral loading/error state instead of the raw shell while `/me` resolves.
*/
expected?: AppRole;
children: ReactNode;
}
/**
* The client-side role-aware navigation guard for the private shells (refinement-phase-2). It gates a shell
* on the resolved-vs-pending role state (`useRoleHydration`) so the wrong actor app never renders:
* - **loading** — a brand splash while `/me` is in flight (never the customer shell as a stand-in);
* - **error** — an explicit account-error recovery when `/me` failed (never a silent customer fallback);
* - **role mismatch** — redirect to the caller's real app (`resolveRoleDestination`, the single source of
* "which app") with a toast, rather than rendering a shell they lack the role for.
*
* A dual customer+nurse session holds both roles, so it passes either shell's guard and can move freely
* between the family and nurse apps.
* @component RoleGuard
*/
const RoleGuard: FunctionComponent<RoleGuardProps> = ({ expected, children }) => {
const t = useTranslations('auth');
const router = useRouter();
const locale = useLocale();
const { enqueueSnackbar } = useSnackbar();
const hydration = useRoleHydration();
const me = hydration.status === 'ready' ? hydration.me : null;
const appRoles = hydration.status === 'ready' ? hydration.appRoles : null;
const allowed = !expected || (appRoles?.includes(expected) ?? false);
// Stable across renders for a given identity (a string), so the redirect effect fires once, not per render.
const redirectTo = me && !allowed ? `/${locale}${resolveRoleDestination(me)}` : null;
useEffect(() => {
if (!redirectTo) return;
enqueueSnackbar(t('guard_denied'), { variant: 'warning' });
router.replace(redirectTo);
}, [redirectTo, enqueueSnackbar, t, router]);
if (hydration.status === 'loading') return <AuthSplash message={t('routing_title')} />;
if (hydration.status === 'error')
return <AuthAccountError onRetry={hydration.retry} isRetrying={hydration.isRetrying} />;
// Role mismatch — hold the neutral splash while the redirect above navigates away.
if (!allowed) return <AuthSplash message={t('routing_title')} />;
return <>{children}</>;
};
export default RoleGuard;
+2
View File
@@ -2,3 +2,5 @@ export { default as LoginFlow } from './LoginFlow';
export { default as RoleRouter } from './RoleRouter'; export { default as RoleRouter } from './RoleRouter';
export { default as SelectRole } from './SelectRole'; export { default as SelectRole } from './SelectRole';
export { default as AuthSplash } from './AuthSplash'; export { default as AuthSplash } from './AuthSplash';
export { default as RoleGuard } from './RoleGuard';
export { default as AuthAccountError } from './AuthAccountError';
@@ -8,6 +8,13 @@ jest.mock('next-intl', () => ({
})); }));
jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) })); jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() }) }));
// This is a behavioural test of the two-stage-disclosure gate; it uses the in-memory bookings mock as
// its data fixture (seeded booking 5001 + care instructions). Pin the seam to the mock so the test is
// independent of the production `USE_BOOKINGS_MOCK` flag (flipped to real in refinement-phase-4).
jest.mock('@/services/bookings/apis', () => ({
bookingsApi: jest.requireActual('@/services/bookings/apis/mockApi').bookingsMockApi,
}));
import BookingDetailView from './BookingDetailView'; import BookingDetailView from './BookingDetailView';
import { bookingsApi } from '@/services/bookings/apis'; import { bookingsApi } from '@/services/bookings/apis';
+5 -3
View File
@@ -19,9 +19,11 @@ const ROLE_PRECEDENCE: AppRole[] = [APP_ROLES.ADMIN, APP_ROLES.NURSE, APP_ROLES.
/** /**
* The actor experience the current session should see, read from the session roles. * The actor experience the current session should see, read from the session roles.
* *
* Roles are seeded by the server in f1-b2; until then sessions carry no roles and * Chrome-only signal: which nav/shell the shells build from the collapsed session roles. The DEFAULT_ROLE
* this returns DEFAULT_ROLE (customer) so the shells degrade gracefully. Route-group * (customer) fallback is a **last resort**, not the loading state — the shells are wrapped in `RoleGuard`
* layouts use it to drive role-aware navigation and (later) access guards. * (refinement-phase-2), which holds a neutral splash until `/me` resolves and redirects a role mismatch, so
* this is only ever read once the roles are hydrated. Never gate "which app" on this fallback; that decision
* is `resolveRoleDestination` (via `RoleGuard`/`RoleRouter`), the single source of truth.
*/ */
export function useActorRole(): AppRole { export function useActorRole(): AppRole {
const [state] = useAuth(); const [state] = useAuth();
+5 -1
View File
@@ -36,9 +36,13 @@ export async function clientFetch<T>(path: string, options?: RequestInit, isRetr
const locale = window.location.pathname.split('/')[1] || 'fa'; const locale = window.location.pathname.split('/')[1] || 'fa';
const reqHeaders: Record<string, string> = { const reqHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'Accept-Language': locale, 'Accept-Language': locale,
}; };
// Let the browser set `Content-Type` (with the multipart boundary) for FormData bodies — a manual
// JSON content-type there breaks the upload. JSON bodies still declare it explicitly.
if (!(options?.body instanceof FormData)) {
reqHeaders['Content-Type'] = 'application/json';
}
if (token) { if (token) {
reqHeaders['Authorization'] = `Bearer ${token}`; reqHeaders['Authorization'] = `Bearer ${token}`;
} }
@@ -5,11 +5,14 @@ import type { CreateAddressInput, CustomerAddress, CustomerAddressDto, Addresses
const BASE = '/api/v1/customer_addresses'; const BASE = '/api/v1/customer_addresses';
// The wire `CustomerAddressDto` has no `provinceId` (REQ-009). Reads default it to null; writes // REQ-009 (delivered): the wire `CustomerAddressDto` now carries `provinceId` (joined from
// echo the caller's chosen province onto the returned row so the just-saved address can be // `cities.province_id`), so a freshly-fetched address prefills the cascade. The caller's chosen
// re-edited with its cascade prefilled (not yet persisted server-side). // province is kept as a fallback for the optimistic just-saved echo.
function toAddress(dto: CustomerAddressDto, provinceId?: number | null): CustomerAddress { interface AddressWire extends CustomerAddressDto {
return { ...dto, provinceId: provinceId ?? null }; provinceId: number;
}
function toAddress(dto: AddressWire, provinceId?: number | null): CustomerAddress {
return { ...dto, provinceId: dto.provinceId ?? provinceId ?? null };
} }
// Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 — // Only the contract fields cross the wire. `latitude`/`longitude` are the picked pin (REQ-008 —
@@ -43,7 +46,7 @@ export const addressesClientApi: AddressesApi = {
// geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`. // geo lookups' explicit `province_id`/`city_id`); match the patients template's `pageSize`.
query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE)); query.set('pageSize', String(params?.pageSize ?? ADDRESSES_PAGE_SIZE));
const page = unwrap( const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<CustomerAddressDto>>>(`${BASE}/list?${query.toString()}`), await clientFetch<ApiEnvelope<Paginated<AddressWire>>>(`${BASE}/list?${query.toString()}`),
); );
return { ...page, items: page.items.map((dto) => toAddress(dto)) }; return { ...page, items: page.items.map((dto) => toAddress(dto)) };
}, },
@@ -51,7 +54,7 @@ export const addressesClientApi: AddressesApi = {
create: async (input) => create: async (input) =>
toAddress( toAddress(
unwrap( unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/create`, { await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
@@ -62,7 +65,7 @@ export const addressesClientApi: AddressesApi = {
update: async (id, input) => update: async (id, input) =>
toAddress( toAddress(
unwrap( unwrap(
await clientFetch<ApiEnvelope<CustomerAddressDto>>(`${BASE}/update/${id}`, { await clientFetch<ApiEnvelope<AddressWire>>(`${BASE}/update/${id}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
+1 -1
View File
@@ -7,7 +7,7 @@
* `api/v1/customer_addresses/*` routes — no hook/component changes * `api/v1/customer_addresses/*` routes — no hook/component changes
* (see dev/shared-working-context/reports/mocks-registry.md). * (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_ADDRESSES_MOCK = true; export const USE_ADDRESSES_MOCK = false;
/** Address lists change only on mutation; keep them warm across screen visits. */ /** Address lists change only on mutation; keep them warm across screen visits. */
export const ADDRESSES_STALE_TIME = 60_000; export const ADDRESSES_STALE_TIME = 60_000;
@@ -0,0 +1,28 @@
import { useMe } from './useMe';
import { toAppRoles } from '../routing';
import type { Me } from '../types';
import type { AppRole } from '@/constants';
/**
* The resolved-vs-pending role state for a private route. The core refinement-phase-2 fix: a shell must
* distinguish **"/me hasn't resolved yet"** from **"the user has no nurse/admin role"** — conflating the
* two is what silently showed a nurse the customer app (a fresh `/me` in-flight fell through the
* `DEFAULT_ROLE = customer` fallback). This exposes that distinction so `RoleGuard` can render a neutral
* loading state while pending, an explicit error state when `/me` failed, and only route on a resolved
* role set.
*
* `error` fires only when `/me` has no data at all; a background refetch that fails while we still hold a
* cached identity keeps serving `ready` (don't downgrade a known nurse on a transient blip).
*/
export type RoleHydration =
| { status: 'loading' }
| { status: 'error'; retry: () => void; isRetrying: boolean }
| { status: 'ready'; me: Me; appRoles: AppRole[] };
export function useRoleHydration(): RoleHydration {
const { data: me, isError, isFetching, refetch } = useMe();
if (me) return { status: 'ready', me, appRoles: toAppRoles(me.roles) };
if (isError) return { status: 'error', retry: () => void refetch(), isRetrying: isFetching };
return { status: 'loading' };
}
+2
View File
@@ -5,3 +5,5 @@ export { useRefresh } from './hooks/useRefresh';
export { useLogout } from './hooks/useLogout'; export { useLogout } from './hooks/useLogout';
export { useSelectRole } from './hooks/useSelectRole'; export { useSelectRole } from './hooks/useSelectRole';
export { useSessionRoleSync } from './hooks/useSessionRoleSync'; export { useSessionRoleSync } from './hooks/useSessionRoleSync';
export { useRoleHydration } from './hooks/useRoleHydration';
export type { RoleHydration } from './hooks/useRoleHydration';
@@ -12,43 +12,27 @@ import type {
const BASE = '/api/v1/booking_requests'; const BASE = '/api/v1/booking_requests';
/**
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price) and `bookingId`
* (REQ-017: a `converted` request gives no way to reach the booking it became).
*/
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null, bookingId: null };
}
/** /**
* Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract * Real HTTP implementation of the `BookingRequestsApi` seam (b8 contract
* `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for * `dev/contracts/domains/booking-requests.md`). Routes are action-style + snake_case; ids for
* accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and * accept/reject/cancel/get come from the **route**, never the body; JSON bodies/fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST. * `clientFetch` returns the raw envelope, so we `unwrap()`. Mutations use POST.
* *
* NOT the primary implementation this phase (`USE_BOOKING_REQUESTS_MOCK = true`): every input id (nurse, * PRIMARY once `USE_BOOKING_REQUESTS_MOCK = false` (refinement-phase-4; REQ-013/014/017 delivered): the
* patient, address) comes from a mock-primary upstream domain today, and the DTO omits `variantPrice` * DTO now carries `variantPrice` + `nurseAvatarUrl` (REQ-013) + `bookingId` (REQ-017), and the list item
* (REQ-013). This client maps everything b8 provides — the `context` arg (a mock-only display aid) is * carries `variantLabel` + `patientAge` (REQ-014), so the wire maps 1:1 to the app DTO. The `context` arg
* ignored here, and the nurse-view masking is done server-side (so `role` is ignored too). Selected once * (a mock-only display aid) is ignored here, and the nurse-view masking is done server-side.
* the upstream domains are live and REQ-013 lands (a single config flip; no hook/component change).
*/ */
export const bookingRequestsClientApi: BookingRequestsApi = { export const bookingRequestsClientApi: BookingRequestsApi = {
create: async (payload: CreateBookingRequestPayload) => create: async (payload: CreateBookingRequestPayload) =>
toDto( unwrap(
unwrap( await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/create`, {
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/create`, { method: 'POST',
method: 'POST', body: JSON.stringify(payload),
body: JSON.stringify(payload), }),
}),
),
), ),
get: async (id: number) => get: async (id: number) => unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/get/${id}`)),
toDto(unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/get/${id}`))),
list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => { list: async (params: BookingRequestListParams): Promise<Paginated<BookingRequestListItem>> => {
const query = new URLSearchParams(); const query = new URLSearchParams();
@@ -62,22 +46,16 @@ export const bookingRequestsClientApi: BookingRequestsApi = {
}, },
accept: async (id: number) => accept: async (id: number) =>
toDto( unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/accept/${id}`, { method: 'POST' })),
),
reject: async (id: number, payload: RejectBookingRequestPayload) => reject: async (id: number, payload: RejectBookingRequestPayload) =>
toDto( unwrap(
unwrap( await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/reject/${id}`, {
await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/reject/${id}`, { method: 'POST',
method: 'POST', body: JSON.stringify(payload),
body: JSON.stringify(payload), }),
}),
),
), ),
cancel: async (id: number) => cancel: async (id: number) =>
toDto( unwrap(await clientFetch<ApiEnvelope<BookingRequestDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
unwrap(await clientFetch<ApiEnvelope<BookingRequestWireDto>>(`${BASE}/cancel/${id}`, { method: 'POST' })),
),
}; };
@@ -11,7 +11,7 @@
* (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component * (REQ-013). Flip to `false` once the upstream domains are live and REQ-013 lands — no hook/component
* change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`). * change (see `dev/shared-working-context/reports/frontend-phase-7-report.md`).
*/ */
export const USE_BOOKING_REQUESTS_MOCK = true; export const USE_BOOKING_REQUESTS_MOCK = false;
/** /**
* The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition * The customer's C5 and the nurse inbox **poll** while a request is non-terminal so a transition
+1 -1
View File
@@ -11,7 +11,7 @@
* `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single * `clientApi` maps the routes 1:1; flip to `false` once conversion (b10) is live client-side — a single
* config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`). * config change, no hook/component edits (see `dev/shared-working-context/reports/frontend-phase-8-report.md`).
*/ */
export const USE_BOOKINGS_MOCK = true; export const USE_BOOKINGS_MOCK = false;
/** /**
* The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and * The booking detail changes on status transitions (payment → confirmed → in_progress → completed) and
+1 -1
View File
@@ -5,7 +5,7 @@
* demo standalone before the backend is reachable in this environment. Flip to false to hit the * demo standalone before the backend is reachable in this environment. Flip to false to hit the
* live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). * live endpoints — no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_CATALOG_MOCK = true; export const USE_CATALOG_MOCK = false;
/** /**
* Categories and a category's option groups/values are **admin-seeded reference data** that changes * Categories and a category's option groups/values are **admin-seeded reference data** that changes
+1 -1
View File
@@ -5,7 +5,7 @@
* Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes * Flip to false to hit the live `api/v1/geo/*` lookups — no hook/component changes
* (see dev/shared-working-context/reports/mocks-registry.md). * (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_GEOGRAPHY_MOCK = true; export const USE_GEOGRAPHY_MOCK = false;
/** /**
* Reference data almost never changes, so it is cached **for the whole session**: an Infinite * Reference data almost never changes, so it is cached **for the whole session**: an Infinite
@@ -10,7 +10,7 @@
* `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll * `__mockPushNotification` to simulate a fresh notification arriving (the bell increments within the poll
* interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change. * interval — phase §7 step 4). Flip to `false` once the upstreams are real — no hook/component change.
*/ */
export const USE_NOTIFICATIONS_MOCK = true; export const USE_NOTIFICATIONS_MOCK = false;
/** Notification-center page size (api-conventions `pageSize`). */ /** Notification-center page size (api-conventions `pageSize`). */
export const NOTIFICATIONS_PAGE_SIZE = 20; export const NOTIFICATIONS_PAGE_SIZE = 20;
+1 -1
View File
@@ -5,7 +5,7 @@
* the pending→verified/mismatch UI transition behind the client mock. Flip to false to use * the pending→verified/mismatch UI transition behind the client mock. Flip to false to use
* the real endpoints — no hook/component changes (mocks-registry.md). * the real endpoints — no hook/component changes (mocks-registry.md).
*/ */
export const USE_NURSE_BANK_MOCK = true; export const USE_NURSE_BANK_MOCK = false;
/** Bank accounts change rarely; keep them warm across screen visits. */ /** Bank accounts change rarely; keep them warm across screen visits. */
export const BANK_STALE_TIME = 30_000; export const BANK_STALE_TIME = 30_000;
+25 -15
View File
@@ -1,17 +1,27 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { CreatePatientInput, Patient, PatientDto, PatientsApi } from '../types'; import type {
ConditionCode,
CreatePatientInput,
Patient,
PatientDto,
PatientsApi,
Relation,
} from '../types';
const BASE = '/api/v1/patients'; const BASE = '/api/v1/patients';
// The wire `PatientDto` has no relation/conditions yet (REQ-005). Reads default them; writes /** REQ-005 (delivered): the wire `PatientDto` now carries `relation`/`conditions` (both nullable). */
// echo the caller's choice onto the returned row so the just-edited card reflects it (not interface PatientWire extends PatientDto {
// yet persisted server-side). relation: Relation | null;
function toPatient(dto: PatientDto, augment?: Pick<CreatePatientInput, 'relation' | 'conditions'>): Patient { conditions: ConditionCode[] | null;
return { ...dto, relation: augment?.relation ?? null, conditions: augment?.conditions ?? [] };
} }
// Only the wire fields cross the boundary — relation/conditions are client-augmented (REQ-005). function toPatient(dto: PatientWire): Patient {
return { ...dto, relation: dto.relation ?? null, conditions: dto.conditions ?? [] };
}
// REQ-005 (delivered): relation/conditions now cross the wire and are persisted server-side.
function toBody(input: CreatePatientInput) { function toBody(input: CreatePatientInput) {
const { displayName, firstName, lastName, birthDate, gender } = input; const { displayName, firstName, lastName, birthDate, gender } = input;
return { return {
@@ -22,13 +32,15 @@ function toBody(input: CreatePatientInput) {
gender, gender,
bloodType: input.bloodType ?? null, bloodType: input.bloodType ?? null,
initialMedicalNotes: input.initialMedicalNotes ?? null, initialMedicalNotes: input.initialMedicalNotes ?? null,
relation: input.relation,
conditions: input.conditions,
}; };
} }
/** /**
* Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch` * Real HTTP implementation of the PatientsApi seam (b3 action-style routes). `clientFetch`
* returns the raw envelope, so each call reads its payload via `unwrap`. Selected once * returns the raw envelope, so each call reads its payload via `unwrap`. Primary once
* USE_PATIENTS_MOCK is false and the relation/conditions fields land. * USE_PATIENTS_MOCK is false (refinement-phase-4; REQ-005 delivered).
*/ */
export const patientsClientApi: PatientsApi = { export const patientsClientApi: PatientsApi = {
list: async (params) => { list: async (params) => {
@@ -36,32 +48,30 @@ export const patientsClientApi: PatientsApi = {
if (params?.page) query.set('page', String(params.page)); if (params?.page) query.set('page', String(params.page));
if (params?.pageSize) query.set('pageSize', String(params.pageSize)); if (params?.pageSize) query.set('pageSize', String(params.pageSize));
const qs = query.toString(); const qs = query.toString();
const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientDto>>>(`${BASE}/list${qs ? `?${qs}` : ''}`)); const page = unwrap(await clientFetch<ApiEnvelope<Paginated<PatientWire>>>(`${BASE}/list${qs ? `?${qs}` : ''}`));
return { ...page, items: page.items.map((dto) => toPatient(dto)) }; return { ...page, items: page.items.map((dto) => toPatient(dto)) };
}, },
get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/get/${id}`))), get: async (id) => toPatient(unwrap(await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/get/${id}`))),
create: async (input) => create: async (input) =>
toPatient( toPatient(
unwrap( unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/create`, { await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/create`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
), ),
input,
), ),
update: async (id, input) => update: async (id, input) =>
toPatient( toPatient(
unwrap( unwrap(
await clientFetch<ApiEnvelope<PatientDto>>(`${BASE}/update/${id}`, { await clientFetch<ApiEnvelope<PatientWire>>(`${BASE}/update/${id}`, {
method: 'POST', method: 'POST',
body: JSON.stringify(toBody(input)), body: JSON.stringify(toBody(input)),
}), }),
), ),
input,
), ),
archive: async (id) => { archive: async (id) => {
+1 -1
View File
@@ -5,7 +5,7 @@
* mock. Flip to false once those fields land — no hook/component changes are needed * mock. Flip to false once those fields land — no hook/component changes are needed
* (see dev/shared-working-context/reports/mocks-registry.md). * (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_PATIENTS_MOCK = true; export const USE_PATIENTS_MOCK = false;
export const PATIENTS_STALE_TIME = 60_000; export const PATIENTS_STALE_TIME = 60_000;
+1 -1
View File
@@ -14,7 +14,7 @@
* store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end. * store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end.
* Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change. * Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change.
*/ */
export const USE_PAYMENT_MOCK = true; export const USE_PAYMENT_MOCK = false;
/** /**
* f11 wired the BNPL method screens (D1D5): C6's «پرداخت اقساطی» seam now navigates into the installment * f11 wired the BNPL method screens (D1D5): C6's «پرداخت اقساطی» seam now navigates into the installment
+38 -14
View File
@@ -25,23 +25,34 @@ async function orNull<T>(promise: Promise<T>): Promise<T | null> {
} }
} }
// The wire DTOs carry no avatar/name yet (REQ-006/007); reads default the augmented fields. /** REQ-006 (delivered): both profile DTOs now carry `avatarUrl`; the customer DTO also carries `preferredLanguage`. */
function toNurseProfile(dto: NurseProfileDto): NurseProfile { interface NurseProfileWire extends NurseProfileDto {
return { ...dto, avatarUrl: null }; avatarUrl: string | null;
} }
function toCustomerProfile(dto: CustomerProfileDto): CustomerProfile { interface CustomerProfileWire extends CustomerProfileDto {
return { ...dto, firstName: null, lastName: null, preferredLanguage: null }; avatarUrl: string | null;
preferredLanguage: string | null;
}
function toNurseProfile(dto: NurseProfileWire): NurseProfile {
return { ...dto, avatarUrl: dto.avatarUrl ?? null };
}
// The customer name lives on `/me` (REQ-007 design), not on `CustomerProfileDto` — the profile screen
// sources first/last name from `useMe`. Here we carry the served `preferredLanguage`; name stays null.
function toCustomerProfile(dto: CustomerProfileWire): CustomerProfile {
return { ...dto, firstName: null, lastName: null, preferredLanguage: dto.preferredLanguage ?? null };
} }
/** /**
* Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). Selected once * Real HTTP implementation of the ProfilesApi seam (b3 action-style routes). PRIMARY once
* USE_PROFILES_MOCK is false and the avatar/name gaps land. `uploadAvatar` has no route yet * USE_PROFILES_MOCK is false (refinement-phase-4; REQ-006/007 delivered). Avatar upload posts multipart
* (REQ-006) and the JSON-only fetch layer can't send multipart — it stays mock-only. * to the b3 `nurse_profiles/avatar` route (the JSON-only default is bypassed for `FormData` bodies —
* see `lib/api/client.ts`); the customer name/language reach the server via the upsert body.
*/ */
export const profilesClientApi: ProfilesApi = { export const profilesClientApi: ProfilesApi = {
getCustomerProfile: async () => getCustomerProfile: async () =>
orNull( orNull(
clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/me`).then((env) => clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/me`).then((env) =>
toCustomerProfile(unwrap(env)), toCustomerProfile(unwrap(env)),
), ),
), ),
@@ -49,11 +60,15 @@ export const profilesClientApi: ProfilesApi = {
upsertCustomerProfile: async (input: UpsertCustomerProfileInput) => upsertCustomerProfile: async (input: UpsertCustomerProfileInput) =>
toCustomerProfile( toCustomerProfile(
unwrap( unwrap(
await clientFetch<ApiEnvelope<CustomerProfileDto>>(`${BASE}/customer_profiles/upsert`, { await clientFetch<ApiEnvelope<CustomerProfileWire>>(`${BASE}/customer_profiles/upsert`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
defaultEmergencyContactName: input.defaultEmergencyContactName, defaultEmergencyContactName: input.defaultEmergencyContactName,
defaultEmergencyContactPhone: input.defaultEmergencyContactPhone, defaultEmergencyContactPhone: input.defaultEmergencyContactPhone,
// REQ-007 (delivered): name + preferred language are accepted on the upsert command.
firstName: input.firstName ?? null,
lastName: input.lastName ?? null,
preferredLanguage: input.preferredLanguage ?? null,
}), }),
}), }),
), ),
@@ -61,13 +76,13 @@ export const profilesClientApi: ProfilesApi = {
getNurseProfile: async () => getNurseProfile: async () =>
orNull( orNull(
clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))), clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/me`).then((env) => toNurseProfile(unwrap(env))),
), ),
upsertNurseProfile: async (input: UpsertNurseProfileInput) => upsertNurseProfile: async (input: UpsertNurseProfileInput) =>
toNurseProfile( toNurseProfile(
unwrap( unwrap(
await clientFetch<ApiEnvelope<NurseProfileDto>>(`${BASE}/nurse_profiles/upsert`, { await clientFetch<ApiEnvelope<NurseProfileWire>>(`${BASE}/nurse_profiles/upsert`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
bio: input.bio, bio: input.bio,
@@ -80,7 +95,16 @@ export const profilesClientApi: ProfilesApi = {
), ),
), ),
uploadAvatar: async (): Promise<AvatarUploadResult> => { // REQ-006 (delivered): only the nurse profile screen uploads an avatar today; it persists immediately
throw new ApiError(501, 'Avatar upload has no backend route yet (REQ-006); served by the mock.'); // via the dedicated multipart route and the returned URL is echoed for display + read back on reload.
uploadAvatar: async (file: File): Promise<AvatarUploadResult> => {
const form = new FormData();
form.append('file', file);
return unwrap(
await clientFetch<ApiEnvelope<AvatarUploadResult>>(`${BASE}/nurse_profiles/avatar`, {
method: 'POST',
body: form,
}),
);
}, },
}; };
+1 -1
View File
@@ -5,7 +5,7 @@
* (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land — * (REQ-006 / REQ-007), so this phase demos behind the mock. Flip to false once those land —
* no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). * no hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_PROFILES_MOCK = true; export const USE_PROFILES_MOCK = false;
/** Profiles are stable within a session; revisiting a screen shouldn't refetch. */ /** Profiles are stable within a session; revisiting a screen shouldn't refetch. */
export const PROFILE_STALE_TIME = 60_000; export const PROFILE_STALE_TIME = 60_000;
+36 -14
View File
@@ -24,11 +24,17 @@ interface NurseReviewsWire {
reviews: Paginated<ReviewListItem>; reviews: Paginated<ReviewListItem>;
} }
/** /** Wire `MyReviewDto` (REQ-026) — keys the moderation state as `moderationStatus` (`'none'` when unreviewed). */
* Wire `ModerationQueueItemDto`. Per the b14 contract it does **not** carry `tagCodes` (REQ-037 — the admin interface MyReviewDto {
* card can't show the review's tags); the client defaults it to `[]` on map. moderationStatus: MyReviewState['status'];
*/ rating: number | null;
type ModerationQueueItemWire = Omit<ModerationQueueItem, 'tagCodes'>; body: string | null;
tagCodes: string[];
createdAt: string | null;
}
/** Wire `ModerationQueueItemDto` — carries `tagCodes` since REQ-037 (delivered in refinement-phase-3). */
type ModerationQueueItemWire = ModerationQueueItem;
/** /**
* Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`, * Real HTTP implementation of the `ReviewsApi` seam (b14 contract `dev/contracts/domains/reviews-records.md`,
@@ -57,14 +63,30 @@ export const reviewsClientApi: ReviewsApi = {
return { aggregate: wire.aggregate, reviews: wire.reviews }; return { aggregate: wire.aggregate, reviews: wire.reviews };
}, },
// REQ-026: proposed owner-scoped read (no wire endpoint yet). 404s until delivered — never called while // REQ-026 (delivered): owner-scoped eligibility read. Wire `reason` is nullable; normalise to `undefined`.
// the domain is mock-primary. Kept symmetric so the swap stays a one-line config flip. getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => {
getReviewEligibility: async (bookingId: number): Promise<ReviewEligibility> => const wire = unwrap(
unwrap(await clientFetch<ApiEnvelope<ReviewEligibility>>(`${API}/bookings/${bookingId}/review_eligibility`)), await clientFetch<ApiEnvelope<{ canReview: boolean; reason: ReviewEligibility['reason'] | null }>>(
`${API}/bookings/${bookingId}/review_eligibility`,
),
);
return { canReview: wire.canReview, reason: wire.reason ?? undefined };
},
// REQ-026: proposed owner-scoped read of the caller's own review for this booking. // REQ-026 (delivered): the caller's own review for this booking + its moderation state. The wire dto keys
getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => // the state as `moderationStatus` (incl. `'none'` when unreviewed); the client model calls it `status`.
unwrap(await clientFetch<ApiEnvelope<MyReviewState>>(`${API}/bookings/${bookingId}/my_review`)), getMyReviewForBooking: async (bookingId: number): Promise<MyReviewState> => {
const wire = unwrap(
await clientFetch<ApiEnvelope<MyReviewDto>>(`${API}/bookings/${bookingId}/my_review`),
);
return {
status: wire.moderationStatus,
rating: wire.rating,
body: wire.body,
tagCodes: wire.tagCodes ?? [],
createdAt: wire.createdAt,
};
},
createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> => createReview: async (bookingId: number, body: CreateReviewRequest): Promise<SubmitReviewResult> =>
unwrap( unwrap(
@@ -84,8 +106,8 @@ export const reviewsClientApi: ReviewsApi = {
`${API}/admin/reviews/moderation_queue?${query.toString()}`, `${API}/admin/reviews/moderation_queue?${query.toString()}`,
), ),
); );
// REQ-037: the wire dto omits tagCodes default to [] so the admin card renders without them. // REQ-037 (delivered): the wire dto carries tagCodes; default to [] only if the server omits it.
return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: [] })) }; return { ...wire, items: wire.items.map((item) => ({ ...item, tagCodes: item.tagCodes ?? [] })) };
}, },
moderateReview: async ( moderateReview: async (
+1 -1
View File
@@ -11,7 +11,7 @@
* review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no * review appear on the profile (the f15 moderation UI is deferred). Flip to `false` once REQ-026 lands — no
* hook/component change (only `clientApi.ts`'s two gap methods start returning real data). * hook/component change (only `clientApi.ts`'s two gap methods start returning real data).
*/ */
export const USE_REVIEWS_MOCK = true; export const USE_REVIEWS_MOCK = false;
/** Page size for the public nurse-reviews list (api-conventions `pageSize`). */ /** Page size for the public nurse-reviews list (api-conventions `pageSize`). */
export const REVIEWS_PAGE_SIZE = 5; export const REVIEWS_PAGE_SIZE = 5;
+71 -33
View File
@@ -1,11 +1,12 @@
import { clientFetch } from '@/lib/api/client'; import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types'; import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import type { PriceUnit } from '@/services/catalog/types'; import type { PriceUnit } from '@/services/catalog/types';
import type { TrustBadge } from '@/services/verification/types';
import { SEARCH_PAGE_SIZE } from '../constants'; import { SEARCH_PAGE_SIZE } from '../constants';
import type { import type {
NurseGender, NurseGender,
NurseProfile, NurseProfile,
NurseProfileServiceRow,
NurseReviewSnippet,
NurseSearchFilters, NurseSearchFilters,
NurseSearchResult, NurseSearchResult,
SearchApi, SearchApi,
@@ -27,23 +28,44 @@ interface NurseSearchResultDto {
totalCompletedBookings: number; totalCompletedBookings: number;
cityId: number; cityId: number;
districtId: number | null; districtId: number | null;
/** REQ-012 — the C2 card identity, now denormalized into the index row. */
nurseName: string | null;
avatarUrl: string | null;
distanceKm: number | null;
} }
/** The INO-membership credential type code (see b6 verification). */ /** The b6/b7 aggregated `NursePublicProfileDto` (REQ-012) — the C3 profile payload. */
const INO_MEMBERSHIP_CODE = 'ino_membership'; interface NursePublicProfileDto {
nurseId: number;
nurseName: string;
avatarUrl: string | null;
bio: string;
yearsExperience: number;
averageRating: number;
totalReviews: number;
totalCompletedBookings: number;
isVerified: boolean;
inoMembership: boolean;
attributeChips: string[];
services: {
variantId: number;
displayName: string;
priceIrr: string;
priceUnit: PriceUnit;
sessionCount: number | null;
}[];
latestReview: { rating: number; body: string | null; authorMasked: string | null; createdAt: string } | null;
}
/** /**
* Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6 trust badge). Routes are * Real HTTP implementation of the `SearchApi` seam (b7 `search/nurses`, b6/b7 public profile). Routes are
* action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and * action-style + snake_case; query params are snake_case per the contract; JSON fields are camelCase and
* `clientFetch` returns the raw envelope, so we `unwrap()`. * `clientFetch` returns the raw envelope, so we `unwrap()`.
* *
* NOT the primary implementation this phase (`USE_SEARCH_MOCK = true`): b7's index row omits the nurse * The PRIMARY implementation once `USE_SEARCH_MOCK = false` (refinement-phase-4). REQ-012 delivered the
* **display name, avatar, and distance** the C2 card renders, and there is **no** aggregated * discovery enrichment the C2 card + C3 profile need: `nurseName`/`avatarUrl`/`distanceKm` are now
* nurse-profile endpoint (name/bio/specialties/full services list/latest review) for C3 — only the b6 * denormalized onto `NurseSearchResultDto`, and `GET nurses/{id}/profile` aggregates identity + bio +
* trust badge is public. Both gaps are filed in * specialties + the full services list + the latest review. This client maps both 1:1.
* `dev/shared-working-context/frontend/requests/for-backend.md`. This client maps everything b7/b6
* currently provide (leaving the missing fields blank) so the swap is a single config flip once the
* backend lands the join + profile route.
*/ */
export const searchClientApi: SearchApi = { export const searchClientApi: SearchApi = {
searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => { searchNurses: async (filters: NurseSearchFilters): Promise<Paginated<NurseSearchResult>> => {
@@ -70,16 +92,15 @@ export const searchClientApi: SearchApi = {
nurseId: dto.nurseId, nurseId: dto.nurseId,
variantId: dto.variantId, variantId: dto.variantId,
serviceCategoryId: dto.serviceCategoryId, serviceCategoryId: dto.serviceCategoryId,
// Gap (filed): b7 does not yet join the nurse's name/avatar; the card falls back to a label. // REQ-012 — identity denormalized onto the index row; card falls back to a label only when null.
nurseName: '', nurseName: dto.nurseName ?? '',
avatarUrl: null, avatarUrl: dto.avatarUrl,
// Every returned row is searchable by the index invariant. // Every returned row is searchable by the index invariant.
isVerified: true, isVerified: true,
averageRating: dto.averageRating, averageRating: dto.averageRating,
totalReviews: dto.totalReviews, totalReviews: dto.totalReviews,
totalCompletedBookings: dto.totalCompletedBookings, totalCompletedBookings: dto.totalCompletedBookings,
// Gap (filed): no geo-distance in the index row yet. distanceKm: dto.distanceKm,
distanceKm: null,
priceFromIrr: dto.price, priceFromIrr: dto.price,
priceUnit: dto.priceUnit, priceUnit: dto.priceUnit,
nurseGender: dto.nurseGender, nurseGender: dto.nurseGender,
@@ -90,26 +111,43 @@ export const searchClientApi: SearchApi = {
}, },
getNurseProfile: async (nurseId: number): Promise<NurseProfile> => { getNurseProfile: async (nurseId: number): Promise<NurseProfile> => {
// Only the public trust badge is available today; the aggregated profile (name/bio/specialties/ const dto = unwrap(
// services list/latest review) is filed for the backend. Compose what b6 exposes; leave the rest blank. await clientFetch<ApiEnvelope<NursePublicProfileDto>>(`${NURSES_BASE}/${nurseId}/profile`),
const badge = unwrap(
await clientFetch<ApiEnvelope<TrustBadge>>(`${NURSES_BASE}/${nurseId}/trust_badge`),
); );
const services: NurseProfileServiceRow[] = dto.services.map((s) => ({
variantId: s.variantId,
displayName: s.displayName,
priceIrr: s.priceIrr,
priceUnit: s.priceUnit,
sessionCount: s.sessionCount,
}));
const latestReview: NurseReviewSnippet | null = dto.latestReview
? {
rating: dto.latestReview.rating,
body: dto.latestReview.body ?? '',
authorMasked: dto.latestReview.authorMasked ?? '',
createdAt: dto.latestReview.createdAt,
}
: null;
return { return {
nurseId: badge.nurseId, nurseId: dto.nurseId,
nurseName: '', nurseName: dto.nurseName,
avatarUrl: null, avatarUrl: dto.avatarUrl,
bio: null, bio: dto.bio || null,
yearsExperience: null, yearsExperience: dto.yearsExperience,
averageRating: 0, averageRating: dto.averageRating,
totalReviews: 0, totalReviews: dto.totalReviews,
totalCompletedBookings: 0, totalCompletedBookings: dto.totalCompletedBookings,
isVerified: badge.isVerified, isVerified: dto.isVerified,
inoMembership: badge.credentialTypes.includes(INO_MEMBERSHIP_CODE), inoMembership: dto.inoMembership,
attributeChips: badge.credentialTypes, attributeChips: dto.attributeChips,
services: [], services,
latestReview: null, latestReview,
// Not carried by the public profile DTO; the same-gender intent for booking comes from the C1
// filter carried through the query string, not this field. Unused by the C3 page.
nurseGender: 'female', nurseGender: 'female',
}; };
}, },
+1 -1
View File
@@ -6,7 +6,7 @@
* mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills * mock supplies real-shaped fixtures so C1/C2/C3 demo end-to-end. Flip to false once the backend fills
* the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`). * the gap — no hook/component changes (see `dev/shared-working-context/reports/frontend-phase-6-report.md`).
*/ */
export const USE_SEARCH_MOCK = true; export const USE_SEARCH_MOCK = false;
/** /**
* Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache * Results are read-heavy and change slowly, so a revisit (or a filter **revert**) serves from cache
@@ -5,7 +5,7 @@
* reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no * reachable. Flip to false to use the live `api/v1/nurse_service_areas/*` routes — no
* hook/component changes (see dev/shared-working-context/reports/mocks-registry.md). * hook/component changes (see dev/shared-working-context/reports/mocks-registry.md).
*/ */
export const USE_SERVICE_AREAS_MOCK = true; export const USE_SERVICE_AREAS_MOCK = false;
/** Service areas change only on mutation; keep them warm across screen visits. */ /** Service areas change only on mutation; keep them warm across screen visits. */
export const SERVICE_AREAS_STALE_TIME = 60_000; export const SERVICE_AREAS_STALE_TIME = 60_000;
+21 -7
View File
@@ -21,7 +21,7 @@ import type {
const API = '/api/v1'; const API = '/api/v1';
/** Wire `TicketSummaryDto` (camelCase, per api-conventions). */ /** Wire `TicketSummaryDto` (camelCase). REQ-028 (delivered) added `lastMessageAt`/`unreadCount`. */
interface TicketSummaryWire { interface TicketSummaryWire {
id: number; id: number;
referenceCode: string; referenceCode: string;
@@ -31,6 +31,8 @@ interface TicketSummaryWire {
bookingId: number | null; bookingId: number | null;
refundId: number | null; refundId: number | null;
createdAt: string; createdAt: string;
lastMessageAt: string | null;
unreadCount: number;
} }
/** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */ /** Wire `TicketMessageDto`. `isInternal` is present on the DTO but is `false` in the user view (server-stripped). */
@@ -67,6 +69,9 @@ function mapSummary(w: TicketSummaryWire): TicketSummary {
bookingId: w.bookingId, bookingId: w.bookingId,
refundId: w.refundId, refundId: w.refundId,
createdAt: w.createdAt, createdAt: w.createdAt,
// REQ-028 (delivered): the inbox unread badge + last-activity sort now come off the wire.
lastMessageAt: w.lastMessageAt,
unreadCount: w.unreadCount,
}; };
} }
@@ -162,9 +167,11 @@ function mapAdminThread(w: TicketThreadWire, viewerUserId?: number): AdminTicket
* - `openTicket` → `POST /tickets`. * - `openTicket` → `POST /tickets`.
* - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`). * - `postMessage` → `POST /tickets/{id}/messages` (a non-staff caller never sets `isInternal`).
* *
* NOT the primary implementation this phase (`USE_TICKETS_MOCK = true`) — see `constants.ts`. The wire * PRIMARY once `USE_TICKETS_MOCK = false` (refinement-phase-4; REQ-028 delivered): the summary now carries
* summary has no `unreadCount`/`lastMessageAt` (REQ-028), so those stay undefined here (the inbox degrades). * `unreadCount`/`lastMessageAt` (inbox badge + last-activity sort) and the message post sends the optimistic
* `clientMessageId` is client-only (optimistic reconcile) — not sent (the server has no field for it yet). * `clientMessageId` (server dedupes + echoes it back). The user list still filters only by `Status`; the
* "jump to the existing coordination ticket" by-booking lookup is a minor follow-up (REQ-028 #3 —
* `GET /tickets?BookingId=` is served, but no client method targets it yet).
*/ */
export const ticketsClientApi: TicketsApi = { export const ticketsClientApi: TicketsApi = {
listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => { listMyTickets: async (params: TicketListParams): Promise<Paginated<TicketSummary>> => {
@@ -202,7 +209,9 @@ export const ticketsClientApi: TicketsApi = {
unwrap( unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, { await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: body.body }), // REQ-028 (delivered): send the optimistic `clientMessageId` so the server dedupes a retried send
// and echoes it back on `PostMessageResult` for reconciliation.
body: JSON.stringify({ body: body.body, clientMessageId: body.clientMessageId }),
}), }),
), ),
@@ -233,12 +242,17 @@ export const ticketsClientApi: TicketsApi = {
return mapAdminThread(wire, viewerUserId); return mapAdminThread(wire, viewerUserId);
}, },
// Staff post — may set `isInternal` (the one caller allowed to). `clientMessageId` stays client-only. // Staff post — may set `isInternal` (the one caller allowed to). REQ-028: send `clientMessageId` too.
postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> => postAdminMessage: async (ticketId: number, body: PostAdminMessageRequest): Promise<PostMessageResult> =>
unwrap( unwrap(
await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, { await clientFetch<ApiEnvelope<PostMessageResult>>(`${API}/tickets/${ticketId}/messages`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: body.body, isInternal: body.isInternal }), body: JSON.stringify({
body: body.body,
isInternal: body.isInternal,
clientMessageId: body.clientMessageId,
}),
}), }),
), ),
}; };
+1 -1
View File
@@ -12,7 +12,7 @@
* ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam * ticket reachable. Flip to `false` once the upstreams are real — no hook/component change (only the seam
* selection in `apis/index.ts`). * selection in `apis/index.ts`).
*/ */
export const USE_TICKETS_MOCK = true; export const USE_TICKETS_MOCK = false;
/** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */ /** Inbox page size (api-conventions `pageSize`, default 50 / max 100). */
export const TICKETS_PAGE_SIZE = 20; export const TICKETS_PAGE_SIZE = 20;
+2 -2
View File
@@ -35,8 +35,8 @@ server — mirror that, don't invent a new envelope.
the client picks by locale. the client picks by locale.
## Pagination (mandatory on lists) ## Pagination (mandatory on lists)
- Query params: `page` (1-based) + `page_size` (cap it server-side, e.g. ≤100). Response payload carries - Query params: `page` (1-based) + `pageSize` (cap it server-side, e.g. ≤100). Response payload carries
`items` + `total` (+ `page`/`page_size`). Document the default and max `page_size` per endpoint. `items` + `total` (+ `page`/`pageSize`). Document the default and max `pageSize` per endpoint.
## Idempotency (money & side-effecting POSTs) ## Idempotency (money & side-effecting POSTs)
- Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook - Where stated, the client sends an idempotency key (header or body field) and the server dedups. Webhook
+1 -1
View File
@@ -15,7 +15,7 @@
### `<HTTP> api/v1/<controller>/<action>` ### `<HTTP> api/v1/<controller>/<action>`
- **Purpose:** … - **Purpose:** …
- **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no - **Auth:** none | authenticated | policy/role … · **Rate-limited:** yes/no · **Idempotency key:** yes/no
- **Path/query params:** `name` (type) — meaning; pagination `page`/`page_size` (default/max) for lists. - **Path/query params:** `name` (type) — meaning; pagination `page`/`pageSize` (default/max) for lists.
- **Request body:** - **Request body:**
```json ```json
{ "field": "example" } { "field": "example" }
+14
View File
@@ -148,3 +148,17 @@ customer's repayment schedule — `installment_count` is informational (default
## Changelog ## Changelog
- b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert). - b12 — initial contract (eligibility, initiate, customer/admin status, webhook, admin verify/settle/revert).
---
## Refinement phase 3 additions (REQ-022/023/024)
- `balinyaar` added to the `provider_code` enum (in-house plan; identical net-of-fee mechanics, resolves to the
same adapter). The set is now `snapppay|digipay|tara|torobpay|balinyaar`.
- `POST checkout_bnpl/eligibility` accepts optional `{ nationalId, mobile, consent }` (consent required when the
KYC inputs are present; a supplied mobile drives the provider inquiry, else the account mobile).
- `GET api/v1/checkout_bnpl/by_request/{bookingRequestId}` (owner-scoped) → `BnplOrderStatusDto`; `bookingId` on
the settled order was already present on the DTO.
- **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` + `wallet_installments` — b12 deliberately does not model
the customer repayment schedule / per-installment status, and there is no installment ledger to serve them from.
Keep the D1/D2/D4/D5 plan visualization mocked until a provider-schedule integration (or a schedule table) lands.
+18
View File
@@ -146,3 +146,21 @@
## Changelog ## Changelog
- b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire). - b8 — initial contract (create/accept/reject/cancel + role-scoped list + single get + admin expire).
---
## Refinement phase 3 additions (REQ-013/014/016/017)
- **`BookingRequestDto`** gains `variantPrice` (IRR digit-string — the chosen variant's *display rate*, not
an engagement total; the request stays money-free), `nurseAvatarUrl` (nullable), and `bookingId`
(nullable — the booking created once the request is `converted`, for the confirmation deep-link).
- **`BookingRequestListItemDto`** gains `variantLabel` (self-describing inbox row) and `patientAge`
(nullable coarse triage age).
- **`GET api/v1/booking_requests/checkout_summary/{id}`** (owner-scoped) — the C6 money breakdown:
`{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit, sessionCount,
requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt, serviceCostIrr, commissionIrr,
vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, nursePayoutAmount }`. All IRR
digit-strings, computed server-side. **Canonical rates:** `platform_fee_rate = 0.15`, `vat_rate = 0.10`.
VAT is **carved out of the commission** so `serviceCostIrr + commissionIrr + vatIrr = totalIrr = gross`
(the captured amount); `commissionIrr` is the commission **net of VAT**, and the raw b10 amounts
(`grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`) are surfaced alongside.
+3 -3
View File
@@ -38,7 +38,7 @@ ISO-8601. Enums cross as their stable string codes.
(customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes (customer own / nurse assigned / admin all). The **nurse** view omits `addressSnapshotJson`. Never includes
care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak). care-instruction clinical fields. **Failure:** `401`, `404` (not found / not a party → no leak).
### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&page_size=` ### `GET api/v1/bookings/list?role=customer|nurse|all&status=&page=&pageSize=`
- **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise). - **Purpose:** role-scoped "My bookings" (paginated, projected). `role=all` is admin-only (`403` otherwise).
**Success:** `PagedResult<BookingListItem>`. **Success:** `PagedResult<BookingListItem>`.
@@ -76,7 +76,7 @@ ISO-8601. Enums cross as their stable string codes.
`payout_eligible_at`, and — when all sessions are settled — completes the booking + sets `payout_eligible_at`, and — when all sessions are settled — completes the booking + sets
`dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able. `dispute_window_ends_at`. **Failure:** `400` no open check-in, `409` not checkout-able.
### `GET api/v1/booking_sessions/today?date=&page=&page_size=` ### `GET api/v1/booking_sessions/today?date=&page=&pageSize=`
- **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse, - **Purpose:** the nurse's sessions for a day (default all), with check-in/out CTA state. **Auth:** nurse,
tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`. tenancy-scoped. **Success:** `PagedResult<BookingSessionListItem>`.
@@ -87,7 +87,7 @@ ISO-8601. Enums cross as their stable string codes.
- **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the - **Purpose:** cancel a single un-started session · **Rate-limited:** yes. Snapshots the policy + computes the
session's refundable share. **Failure:** `409` if the session already started. session's refundable share. **Failure:** `409` if the session already started.
### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&page_size=` ### `GET api/v1/admin_evv/list?type=mismatch|no_show&page=&pageSize=`
- **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:** - **Purpose:** admin EVV-review queue. **Auth:** admin policy · **Rate-limited:** yes. **Success:**
`PagedResult<AdminEvvItem>`. `PagedResult<AdminEvvItem>`.
+3 -3
View File
@@ -38,8 +38,8 @@
## Public catalog browse — `CatalogController` (no auth) ## Public catalog browse — `CatalogController` (no auth)
### `GET api/v1/catalog/categories?page=&page_size=` ### `GET api/v1/catalog/categories?page=&pageSize=`
- Active categories ordered by `sortOrder`, **paginated** (default `page_size` 50, max 100). Cached. `data`: - Active categories ordered by `sortOrder`, **paginated** (default `pageSize` 50, max 100). Cached. `data`:
`PagedResult<ServiceCategoryDto>`. `PagedResult<ServiceCategoryDto>`.
### `GET api/v1/catalog/option_groups?category_id={id}` ### `GET api/v1/catalog/option_groups?category_id={id}`
@@ -89,7 +89,7 @@ Every write **invalidates the catalog cache**. Both labels required (`nameFa`/`n
### `POST api/v1/nurse_variants/set_active/{id}` ### `POST api/v1/nurse_variants/set_active/{id}`
- **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned. - **Body:** `{ isActive }`. Deactivate/reactivate — **never hard-delete**. `data`: `true`. `404` if not owned.
### `GET api/v1/nurse_variants/list?page=&page_size=` ### `GET api/v1/nurse_variants/list?page=&pageSize=`
- The nurse's own offerings — **active and inactive**, active-first, paginated. `data`: - The nurse's own offerings — **active and inactive**, active-first, paginated. `data`:
`PagedResult<VariantDto>`. `PagedResult<VariantDto>`.
+7 -7
View File
@@ -8,7 +8,7 @@
**Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts) **Status:** live as of backend-phase-1 · **Frontend consumer:** frontend-phase-f14 (notification center) / frontend-phase-f15 (admin config/holidays/audit/alerts)
All responses are the standard `OperationResult``ApiResult` envelope (camelCase body, snake_case URLs). All responses are the standard `OperationResult``ApiResult` envelope (camelCase body, snake_case URLs).
Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `page_size` Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-based) + `pageSize`
(default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`. (default 50, max 100) — bound from the query string; derive exact casing from `swagger.v1.json`.
## Enums used ## Enums used
@@ -25,7 +25,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
### `GET api/v1/platform_config/get_platform_configs` ### `GET api/v1/platform_config/get_platform_configs`
- **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no. - **Purpose:** list config rows. **Auth:** admin (DynamicPermission). **Rate-limited:** no.
- **Query:** `page`, `page_size`. - **Query:** `page`, `pageSize`.
- **200 `data`:** `PagedResult<PlatformConfigDto>``{ items:[{ key, value, dataType, description }], total, page, pageSize }`. - **200 `data`:** `PagedResult<PlatformConfigDto>``{ items:[{ key, value, dataType, description }], total, page, pageSize }`.
### `POST api/v1/platform_config/update_platform_config` ### `POST api/v1/platform_config/update_platform_config`
@@ -36,7 +36,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
### `GET api/v1/platform_config/get_config_change_history` ### `GET api/v1/platform_config/get_config_change_history`
- **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin. - **Purpose:** the audited change history for one key (from the append-only trail). **Auth:** admin.
- **Query:** `key` (required), `page`, `page_size`. - **Query:** `key` (required), `page`, `pageSize`.
- **200 `data`:** `PagedResult<ConfigChangeDto>``{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. - **200 `data`:** `PagedResult<ConfigChangeDto>``{ items:[{ id, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first.
--- ---
@@ -44,7 +44,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`) ## Admin — Holidays (`holidays` controller, `[Authorize(DynamicPermission)]`)
### `GET api/v1/holidays/get_holidays` ### `GET api/v1/holidays/get_holidays`
- **Query:** `from` (date, optional), `to` (date, optional), `page`, `page_size`. - **Query:** `from` (date, optional), `to` (date, optional), `page`, `pageSize`.
- **200 `data`:** `PagedResult<HolidayDto>``{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date. - **200 `data`:** `PagedResult<HolidayDto>``{ items:[{ id, holidayDate, nameFa, type, isBankClosed }], … }`, by date.
### `POST api/v1/holidays/upsert_holiday` ### `POST api/v1/holidays/upsert_holiday`
@@ -60,7 +60,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
### `GET api/v1/audit/get_audit_trail` ### `GET api/v1/audit/get_audit_trail`
- **Purpose:** the immutable trail for one entity. **Auth:** admin. - **Purpose:** the immutable trail for one entity. **Auth:** admin.
- **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `page_size`. - **Query:** `entity_type` (e.g. `PlatformConfig`), `entity_id` (string), `page`, `pageSize`.
- **200 `data`:** `PagedResult<AuditLogDto>``{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows. - **200 `data`:** `PagedResult<AuditLogDto>``{ items:[{ id, entityType, entityId, action, changedFieldsJson, actorUserId, occurredAt }], … }`, newest first. **Notes:** read-only; there is no write/update/delete endpoint for audit rows.
--- ---
@@ -68,7 +68,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing) ## Admin — Support alerts (`support_alerts` controller, `[Authorize(DynamicPermission)]`, never user-facing)
### `GET api/v1/support_alerts/get_support_alerts` ### `GET api/v1/support_alerts/get_support_alerts`
- **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `page_size`. - **Query:** `type?`, `status?`, `owner_user_id?`, `page`, `pageSize`.
- **200 `data`:** `PagedResult<SupportAlertDto>``{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`. - **200 `data`:** `PagedResult<SupportAlertDto>``{ items:[{ id, type, severity, status, entityType, entityId, bookingId, reviewId, ownerUserId, resolutionNote, resolvedAt, createdAt }], … }`.
### `POST api/v1/support_alerts/assign_support_alert` ### `POST api/v1/support_alerts/assign_support_alert`
@@ -84,7 +84,7 @@ Lists carry `{ items, total, page, pageSize }`. Pagination inputs are `page` (1-
Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id. Every endpoint is scoped to the signed-in caller (`ICurrentUser`) — never a body-supplied user id.
### `GET api/v1/notifications/get_notifications` ### `GET api/v1/notifications/get_notifications`
- **Query:** `page`, `page_size`. - **Query:** `page`, `pageSize`.
- **200 `data`:** `PagedResult<NotificationDto>``{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first. - **200 `data`:** `PagedResult<NotificationDto>``{ items:[{ id, type, title, body, dataJson, isRead, readAt, createdAt }], … }`, **unread-first** then newest-first.
### `GET api/v1/notifications/get_unread_count` ### `GET api/v1/notifications/get_unread_count`
+12 -2
View File
@@ -72,7 +72,7 @@ Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `
### `DELETE api/v1/nurse_service_areas/remove/{id}` ### `DELETE api/v1/nurse_service_areas/remove/{id}`
- Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked). - Soft-removes the nurse's own area. `data`: `true`. `404` if not owned/absent (existence not leaked).
### `GET api/v1/nurse_service_areas/list?page=&page_size=` ### `GET api/v1/nurse_service_areas/list?page=&pageSize=`
- The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`. - The nurse's own areas, whole-city first, paginated. `data`: `PagedResult<NurseServiceAreaDto>`.
## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler) ## Customer addresses — `CustomerAddressesController` (authenticated; customer-scoped in handler)
@@ -97,7 +97,7 @@ Every write **invalidates the geo cache**. Names required (`nameFa`/`nameEn`); `
### `DELETE api/v1/customer_addresses/delete/{id}` ### `DELETE api/v1/customer_addresses/delete/{id}`
- Soft-deletes the owned address. `data`: `true`. `404` if not owned. - Soft-deletes the owned address. `data`: `true`. `404` if not owned.
### `GET api/v1/customer_addresses/list?page=&page_size=` ### `GET api/v1/customer_addresses/list?page=&pageSize=`
- The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`: - The customer's own addresses, **primary first**, paginated, with PII **decrypted for the owner**. `data`:
`PagedResult<CustomerAddressDto>`. `PagedResult<CustomerAddressDto>`.
@@ -123,3 +123,13 @@ Tehran city id `101`, Tehran districts `1001…1022`; other cities have no distr
## Changelog ## Changelog
- b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer - b4 — initial contract: public geo lookups, admin geo CRUD + set_active, nurse service areas, customer
addresses; `IGeocoder` seam; `409` conflict added to the envelope. addresses; `IGeocoder` seam; `409` conflict added to the envelope.
---
## Refinement phase 3 additions (REQ-008/009)
- **`CustomerAddressDto`** gains `provinceId` (joined from `cities.province_id`) so the edit form can
prefill the province → city cascade from a server-loaded address.
- **`customer_addresses/create` + `update/{id}`** now accept optional `latitude`/`longitude` (both or
neither). When present, the user's dropped pin is stored (`geocode_source = user_pin`, preferred for the
EVV distance check); when absent the server geocodes as before (`geocode_source = geocoder`).
+11
View File
@@ -128,3 +128,14 @@
## Changelog ## Changelog
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection). - b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
---
## Refinement phase 3 additions (REQ-002/003)
- **`RequestOtpResult`** gains `codeLength` (6) and `expiresInSeconds` (60) so the OTP box count + expiry
hint are contract-driven.
- **`verify_otp` failures** now carry a stable machine `code` on the envelope: `otp_invalid` (wrong **or**
expired — collapsed for anti-enumeration) and `otp_locked` with `data: { retryAfterSeconds }` on lockout.
The coded-error envelope is `{ isSuccess: false, statusCode: 400, message, code, data? }` (the optional
`code` is omitted from every other response).
@@ -85,3 +85,16 @@ segments are snake_case; responses use the standard `OperationResult`→`ApiResu
## Changelog ## Changelog
- b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry). - b3 — initial contract (nurse/customer profiles, patients, nurse bank accounts + ownership inquiry).
---
## Refinement phase 3 additions (REQ-005/006/007)
- **`PatientDto` + create/update** gain `relation` (`parent|spouse|child|self`, nullable) and `conditions`
(`string[]` of stable codes; empty, never null). Stored as a nullable code + a JSON array column.
- **`NurseProfileDto`** and **`CustomerProfileDto`** gain `avatarUrl` (nullable). `CustomerProfileDto` also
gains `preferredLanguage` (nullable); the customer `upsert` body now accepts `firstName`/`lastName`
(persisted on the base `users` row) and `preferredLanguage`.
- **Avatar upload (multipart):** `POST api/v1/nurse_profiles/avatar` and
`POST api/v1/customer_profiles/avatar``multipart/form-data` field `file` (JPEG/PNG/WebP, ≤ 5 MB),
stored via `IObjectStorage`, returns `{ url }` and persists it on the profile.
@@ -173,3 +173,23 @@ rebuild). All are `[Authorize(DynamicPermission)]` (admin role passes; other sta
| Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 | | Verification queue / refunds / payouts / moderation / config / holidays | their own phase routes | b6/b11/b13/b14/b1 |
`support_alerts` are internal-only and must never appear in a user-facing response or join. `support_alerts` are internal-only and must never appear in a user-facing response or join.
---
## Refinement phase 3 additions (REQ-029/030/031/032/033/034/035/036/037)
**Delivered:**
- **REQ-029** `PlatformConfigDto` gains `updatedAt` + `updatedBy` (from the entity audit fields).
- **REQ-030** `GET audit/get_audit_trail` filters also by `actorId`, `action`, `from`, `to` (all optional; `entityType`/
`entityId` now optional too). **Query params bind camelCase** (`actorId`/`from`/`to`), not `actor_id`.
- **REQ-037** `tagCodes: string[]` on `ModerationQueueItemDto`. **REQ-033** `totalIrr` on `InvoiceDto`
(= platform commission + BNPL commission + VAT).
- **REQ-032** activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }`. **Route casing
pinned:** the admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an
intentional b15 divergence from the `snake_case` convention; the frontend's kebab-case client is CORRECT.
**Deferred (admin-console polish, documented in the tracker):** REQ-031 (RBAC `admin_roles/list|grant|revoke`),
REQ-032 `centers/me` + split portal reads (need the user↔center admin association REQ-038 deferred), REQ-033
center-scoped invoice list, REQ-034 verification nurse-queue/signed-url/whole-approve, REQ-035 refund admin
preview+approve/reject (the customer preview REQ-020 IS delivered), REQ-036 payout admin preview/holidayShifted/
transfer-reference.
+13
View File
@@ -0,0 +1,13 @@
---
## Refinement phase 3 additions (REQ-028)
- **`TicketSummaryDto`** gains `lastMessageAt` (last non-internal activity) + `unreadCount` (the caller's unread
non-internal messages from others; 0 on the admin queue). Unread is computed against the participant's
`last_read_at`, **stamped when the participant fetches the user-facing thread**.
- **`GET /tickets`** gains a `bookingId` query filter (jump to a booking's coordination ticket).
- **`POST /tickets/{id}/messages`** accepts an optional `clientMessageId` — a retried send with the same key is
deduplicated (returns the original) and the key is echoed on `PostMessageResult`.
- **Message author = role label, not a name** (confirmed intentional, privacy): the DTO carries `senderId`; the
client derives the author label from the participant role. No raw identity/name is exposed.
+8
View File
@@ -69,3 +69,11 @@ DEBIT escrow_held gross_price_irr (e.g. 23300000)
- **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s. - **The checkout shows gross + commission/VAT breakdown only** — never the internal `account_type`s.
- **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a - **Payment is idempotent end-to-end**: a retried `initiate` (same `Idempotency-Key`) reuses the attempt; a
replayed webhook is a no-op; a repeat `initiate` after capture is a `409`. replayed webhook is a no-op; a repeat `initiate` after capture is a `409`.
---
## Refinement phase 3 additions (REQ-018)
- **Invoice auto-issue on capture/settle:** the commission invoice is now issued automatically when a card
capture (`ConfirmPaymentAndPostLedger`) or a BNPL settle first creates the booking — idempotent per
booking — so a paying customer's `GET api/v1/invoices/{bookingId}` resolves right away (was admin-only).
+14 -1
View File
@@ -81,7 +81,7 @@ The response envelope is the standard `{ data, … }`; the shapes below are the
## Shared shapes ## Shared shapes
- `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool). - `EligibleNurseEarningsDto`: `nurseId` (long), `nurseName` (string?), `bookingCount` (int), `grossEarningsIrr` (string), `clawbackAppliedIrr` (string), `netAmountIrr` (string), `hasVerifiedPrimaryIban` (bool).
- `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime). - `PayoutBatchDto`: `id` (long), `periodStart`/`periodEnd`/`processingDate` (date), `totalAmount` (string), `payoutCount` (int), `status` (`PayoutBatchStatus`), `initiatedByAdminId` (int?, **null = system-initiated / scheduled batch** — refinement-phase-7), `processedAt` (datetime?), `failureNotes` (string?), `createdAt` (datetime).
- `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`). - `PayoutDto`: `id` (long), `nurseId` (long), `nurseName` (string?), `maskedIban` (string, last-4 only), `grossEarningsIrr`/`clawbackAppliedIrr`/`netAmountIrr`/`amount` (string), `bookingCount` (int), `status` (`PayoutStatus`), `transferReference` (string?), `paidAt` (datetime?), `failureReason` (string?), `bookings` (`PayoutBookingLinkDto[]`).
- `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string). - `PayoutBookingLinkDto`: `bookingId` (long), `sessionId` (long?), `payoutAmountIrr` (string).
- `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int). - `PayoutBatchDetailDto`: `batch` (`PayoutBatchDto`), `payouts` (`PayoutDto[]`), `total` (int), `page` (int), `pageSize` (int).
@@ -97,3 +97,16 @@ The response envelope is the standard `{ data, … }`; the shapes below are the
## Changelog ## Changelog
- b13 — initial contract. - b13 — initial contract.
---
## Refinement phase 3 additions (REQ-025 — nurse earnings)
- **`GET api/v1/nurse_payouts/earnings_balance`** → `{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr,
clawbackOutstandingIrr, netPayableBalanceIrr }`. `netPayableBalanceIrr` is the **ledger-derived, SIGNED**
nurse_payable balance (may be negative = "owed back"; never clamped); `paidTotalIrr` is lifetime, not in the net.
- **`GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=`** → `PagedResult<NurseEarningsItem>`; `state`
(`pending|eligible|paid|clawback_applied`) is **derived server-side** from `bookings.status` +
`dispute_window_ends_at < now` + the payout link + any clawback. Filterable by `state`.
- **`GET api/v1/nurse_payouts/{id}`** → nurse-scoped payout detail (batch window + covered bookings).
- **`NursePayoutHistoryDto`** gains `failureReason`.
+49 -4
View File
@@ -65,10 +65,29 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created). Post-payout: `clawbackId` is set (a `pending` `nurse_clawbacks` row + a support alert were created).
- **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin · - **Failure cases:** `400` invalid amount/legs or missing percentage · `401` unauth · `403` non-admin ·
`404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused. `404` no captured payment for the booking · `409` **`Σ refunded > captured`** (over-refund) · `400` channel refused.
- **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; posts the balanced ledger reversal via - **Notes:** whole money-path runs under `lock(booking:{id}:refund)`; the refund row is persisted (`approved`)
b10's helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is **before** the external channel executes (crash-window fix), then the balanced ledger reversal posts via b10's
deferred to reconciliation for BNPL/manual. `ticketId` is required only when `refund_ticket_required` config is helper; the `refund_payable ↔ escrow_held` clearing posts immediately for a succeeded card refund and is
on (off until b15). Notifies the customer. **deferred to reconciliation for BNPL/manual** (settled later via `confirm_settlement`, below). `ticketId` is
optional — one is auto-opened when omitted (b15), so a refund is always ticket-anchored. Notifies the customer.
### `POST api/v1/admin_refunds/{id}/confirm_settlement`
- **Purpose:** reconciliation confirmed the customer cash-back for a `processing` BNPL/manual refund — transitions
it `processing → succeeded`, stamps the settled instant, and posts the deferred `refund_payable ↔ escrow_held`
clearing in the same commit. (Also reached automatically by the BNPL provider cash-back callback.)
- **Auth:** admin · **Rate-limited:** yes (sensitive) · **Idempotent:** a replay against an already-`succeeded`
refund is a no-op success (the clearing never posts twice).
- **Request body:** none (id in the route).
- **Success `200` (`data`):** `RefundSettlement` — `{ "refundId": 7, "bookingId": 42, "status": "succeeded",
"completedAt": "2026-08-12T10:00:00Z" }`.
- **Failure:** `404` refund not found · `409` refund not in `processing` (e.g. still `approved`, already `failed`).
### `POST api/v1/admin_refunds/{id}/mark_failed`
- **Purpose:** reconciliation reported the BNPL/manual customer cash-back did **not** land — transitions the
`processing` refund to `failed`. No ledger moves (the clearing was never posted for a processing refund).
- **Auth:** admin · **Rate-limited:** yes · **Idempotent:** a replay against an already-`failed` refund is a no-op.
- **Request body:** `{ "reason": "bank_rejected" }` (optional).
- **Success `200` (`data`):** `RefundSettlement` (as above, `status: "failed"`). **Failure:** `404` · `409` not `processing`.
### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=` ### `GET api/v1/admin_refunds?booking_id=&status=&page=&pageSize=`
- **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100). - **Purpose:** admin refund worklist — projected + paginated (`page` default 1, `pageSize` default 20 / max 100).
@@ -125,3 +144,29 @@ ISO-8601; `expected_customer_refund_eta` is a **date** (`"2026-08-24"`).
## Changelog ## Changelog
- b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice). - b11 — initial contract (create refund, list refunds, write-off clawback, issue invoice, refund status, get invoice).
- refinement-phase-6 — added `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` (the BNPL/manual
`processing → succeeded/failed` settlement, `RefundSettlement` shape), so the deferred `refund_payable ↔
escrow_held` clearing is now reachable. Refunds are persisted before the channel call (crash-window fix). The
`refund_ticket_required` gate was retired (a refund ticket is always auto-opened). Forward-dep FKs added on
`refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`, `invoices.partner_center_id`.
---
## Refinement phase 3 additions (REQ-019/020/021 — customer refunds)
- **`POST api/v1/bookings/{id}/cancel`** (customer) — cancels the booking (freezing the policy snapshot) AND
opens its refund in one call → `RefundStatusDto`. Body `{ reasonCategory, reasonNotes?, sessionIds? }`
(MVP cancels all un-started sessions; `sessionIds` is accepted for forward-compat).
- **`GET api/v1/bookings/{id}/cancellation_policy`** (customer) — pre-cancel disclosure: resolves the
applicable policy by **current** lead time + per-session refundability →
`{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage, refundAmountIrr,
feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr, appliesTo, leadTimeLabel,
refundChannel, expectedCustomerRefundEta (null in preview), sessions: [{ bookingSessionId, sessionIndex,
scheduledDate, refundable, reasonCode }] }`. `refundAmountIrr + feeAmountIrr = refundableAmountIrr`.
- **`GET api/v1/refunds/by_booking/{bookingId}`** (customer) — the booking's latest refund status (404 if none).
- **`RefundStatusDto`** gains `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`, `refundPercentageApplied`,
`cancellationPolicyCode`, `createdAt`, `completedAt` (the fee-leg transparency split).
- **Canonical `cancellation_policy_code` set** (seeded, stable — the frontend's `free_24h`/`partial_under_24h`/
`customer_no_show` were invented): **`standard_24h`** (customer ≥24h → full refund), **`standard_inside_24h`**
(customer <24h → partial), **`nurse_no_show`** (nurse-initiated → full refund + penalty), **`admin_cancellation`**
(admin → full refund). Per-session `reasonCode`: **`un_started`** when refundable, else the blocking session status.
+16
View File
@@ -119,3 +119,19 @@ access rule is enforced in the handler, not just the route policy.
- **Failure cases:** `401`; `403` no clinical access; `404` patient not found. - **Failure cases:** `401`; `403` no clinical access; `404` patient not found.
- **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole - **Notes:** The record is **patient-scoped, not booking-scoped** — a new nurse taking over reads the whole
history (not just their own booking's notes). history (not just their own booking's notes).
---
## Refinement phase 3 additions (REQ-026/027)
- **`GET api/v1/bookings/{bookingId}/review_eligibility`** → `{ canReview, reason?:
not_completed|already_reviewed|not_owner|not_found }`.
- **`GET api/v1/bookings/{bookingId}/my_review`** → `{ moderationStatus:
pending_moderation|published|hidden|rejected|none, rating?, body?, tagCodes[], createdAt? }`. Masked-author
omission on the public list is **intentional** (privacy).
- **Family-owned care plan (new entity `usr.PatientCarePlans`):** `GET/PUT api/v1/patients/{patientId}/care_record`
→ `{ patientId, medications:[{id,name,dosage?,frequency,timingNote?}], routine:[{id,label,timeOfDay?,note?}],
tasks:[{id,label,done}] }`. Read = owner/nurse-with-booking/admin; write = owning customer only.
- **`GET api/v1/patients/{patientId}/record_access`** → `{ canView, canEdit, canAppendNote, deniedReason? }`
(always 200; non-leaking `not_found`/`not_authorized`).
- **Structured `taskResults`** (`[{ label, done }]`) added to the visit-note write body + the history DTO.
+14 -2
View File
@@ -49,7 +49,7 @@
- `nurse_gender` (`male`|`female`, optional) — the same-gender facet. - `nurse_gender` (`male`|`female`, optional) — the same-gender facet.
- `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`. - `min_price` / `max_price` (long IRR, optional) — inclusive range over the copied `price`.
- `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`). - `price_unit` (enum, optional) — compare like-for-like listings (e.g. only `per_day`).
- `page` (int, default 1), `page_size` (int, default 50, max 100). - `page` (int, default 1), `pageSize` (int, default 50, max 100).
- **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):** - **Success `200` payload (`data` = `PagedResultOfNurseSearchResultDto`):**
```json ```json
{ {
@@ -74,7 +74,7 @@
} }
``` ```
- **Failure cases:** `400``service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`, - **Failure cases:** `400``service_category_id`/`city_id` missing or ≤ 0, `nurse_gender` not `male`/`female`,
`min_price > max_price`, invalid `price_unit`, or `page_size > 100`. `min_price > max_price`, invalid `price_unit`, or `pageSize > 100`.
- **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers - **Notes:** returns only `is_searchable = 1` rows. `districtId = null` in a result row means the nurse covers
the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan. the whole city. No entity is hydrated — the read is a projected, paginated, `AsNoTracking` index scan.
@@ -103,3 +103,15 @@
## Changelog ## Changelog
- b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`. - b7 — initial contract: public `search/nurses`, admin `admin_search/rebuild_index`.
---
## Refinement phase 3 additions (REQ-012)
- **`NurseSearchResultDto`** gains `nurseName` + `avatarUrl` (denormalized onto `nurse_search_index`, so no
per-row join) and `distanceKm` (nullable — the covering index carries no coordinate, so it is null today).
- **`GET api/v1/nurses/{id}/profile`** (public) — the aggregated discovery detail:
`{ nurseId, nurseName, avatarUrl, bio, yearsExperience, averageRating, totalReviews,
totalCompletedBookings, isVerified, inoMembership, attributeChips[], services: [{ variantId, displayName,
priceIrr, priceUnit, sessionCount? }], latestReview?: { rating, body, authorMasked (null by design),
createdAt } }`. No encrypted credential number is ever exposed.
+14 -2
View File
@@ -124,9 +124,9 @@
## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`) ## Admin review queue — `AdminVerificationsController` (`[Authorize(DynamicPermission)]`, rate-limited `sensitive`)
### `GET api/v1/admin_verifications?status=&page=&page_size=` ### `GET api/v1/admin_verifications?status=&page=&pageSize=`
- **Purpose:** the review queue — one row per step awaiting attention. - **Purpose:** the review queue — one row per step awaiting attention.
- **Params:** `status` (default `in_review`) + pagination `page`/`page_size`. - **Params:** `status` (default `in_review`) + pagination `page`/`pageSize`.
- **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**. - **`data`:** `PagedResult<AdminPendingStepDto>`. Documents carry **signed GET URLs**.
### `GET api/v1/admin_verifications/{nurseVerificationId}` ### `GET api/v1/admin_verifications/{nurseVerificationId}`
@@ -255,3 +255,15 @@ GET /api/v1/nurses/{nurseId}/trust_badge -> { isVerified: true, approvedAt, cr
`verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums; `verification_status` / `verification_step_status` / `credential_type` / `verification_method` enums;
transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor transactional `is_verified` flip; encrypted-never-serialized `credential_number`; three new mocked vendor
seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred. seams (`IShahkarVerifier`, `IIdentityKycProvider`, `ICredentialVerifier`). Scheduled expiry cron deferred.
---
## Refinement phase 3 additions (REQ-011)
- **`VerificationStepDto`** gains `isRequired` (mirrors the step-type catalog; an optional step never blocks
bookability).
- **`POST api/v1/nurse_verification/credential_details`** (nurse) — captures the structured credential
fields collected with the uploads: `{ inoNumber (required), specialties: string[], licenseNumber?,
issuingAuthority?, holderName?, issuedAt?, expiresAt? }` → `VerificationStatusDto`. Upserts an
`ino_membership` (and, if a license number is sent, `moh_competency_license`) `nurse_credentials` row
(unverified — admin still decides) and persists `specialties` on the profile. The INO number is encrypted.
File diff suppressed because it is too large Load Diff
+37 -7
View File
@@ -75,9 +75,11 @@ Development environment, so this never affects a deployed build.
dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj
``` ```
On boot the API applies all EF migrations and seeds roles + an admin user + a sandbox payment gateway On boot the API applies all EF migrations and seeds roles against the (empty) local DB, then listens on
against the (empty) local DB, then listens on **`https://localhost:5002`** — Swagger at **`https://localhost:5002`** — Swagger at `https://localhost:5002/swagger`. In **Development** it also seeds a
`https://localhost:5002/swagger`. sandbox payment gateway and the demo world (below). It does **not** seed the old `admin`/`qw123321` account
anymore (refinement-phase-5); a break-glass admin is created only if you set `Seed:AdminUsername` /
`Seed:AdminPassword` (see below), and the day-to-day admin path is the phone-OTP demo admins.
In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified In **Development** it additionally runs the **demo-world seeder** (Refinement Phase 1): verified/unverified
demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo demo nurses with priced variants + Tehran coverage (and therefore real `nurse_search_index` rows), plus demo
@@ -110,7 +112,24 @@ endpoint works). Use them to see the real path populated:
| `09120000003` | nurse | مریم احمدی (female) | **unverified** — not discoverable in search | | `09120000003` | nurse | مریم احمدی (female) | **unverified** — not discoverable in search |
| `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address | | `09120000010` | customer | سارا محمدی (female) | 2 patients, 1 Tehran address |
| `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address | | `09120000011` | customer | رضا حسینی (male) | 1 patient, 1 Tehran address |
| `admin` / `qw123321` | admin | seeded admin (username+password) | backoffice | | `09120000020` | admin (`super_admin`) | نگار مدیری (female) | full backoffice — **lands on `/admin`**, sees every console incl. RBAC |
| `09120000021` | admin (`finance`) | کامران مالی (male) | scoped backoffice — lands on `/admin`, sidebar shows only the money consoles (`useAdminCapabilities` gating) |
> The old username+password `admin`/`qw123321` account is **no longer auto-seeded** (refinement-phase-5 —
> no committed credential). To bootstrap a break-glass username+password admin, set both secrets before boot,
> then log in via the API (not the web UI, which is phone-OTP only):
> ```bash
> cd server/src/API/Baya.Web.Api
> dotnet user-secrets set "Seed:AdminUsername" "admin"
> dotnet user-secrets set "Seed:AdminPassword" "<a-strong-password>"
> ```
The **phone-OTP admins** (`09120000020` / `09120000021`, refinement-phase-2) are how you reach the `/admin`
console through the same web login flow as everyone else — admin sub-roles are server-granted, never
self-selectable via `me/select_role`. Log in with either phone exactly like a nurse/customer; role
hydration routes you to `/admin`. To reach the **nurse** app, log in as a verified nurse phone
(`09120000001`); a fresh customer can also become a nurse in-app (SelectRole → `me/select_role`) and is
then routed to `/nurse` after the next `/me`.
Prove search works without the frontend: open Swagger → Prove search works without the frontend: open Swagger →
`GET /api/v1/search/nurses?service_category_id=1&city_id=101` returns the two verified nurses' variants; `GET /api/v1/search/nurses?service_category_id=1&city_id=101` returns the two verified nurses' variants;
@@ -129,7 +148,8 @@ Prove search works without the frontend: open Swagger →
`{ "data": { "phone": "09120000001", "code": "123456" }, ... }`. `{ "data": { "phone": "09120000001", "code": "123456" }, ... }`.
This endpoint returns **404 outside Development** and is superseded by real SMS in This endpoint returns **404 outside Development** and is superseded by real SMS in
[Refinement Phase 8](refinement-phase-8-external-rails.md). [Refinement Phase 8](refinement-phase-8-external-rails.md).
4. Enter the code and submit → you land on the customer home. 4. Enter the code and submit → role hydration routes you to the app for your role: a customer to the family
home (`/`), a nurse to `/nurse`, an admin to `/admin` (refinement-phase-2).
5. **Verify in DevTools → Network:** `POST /api/v1/auth/request_otp`, `POST /api/v1/auth/verify_otp`, and 5. **Verify in DevTools → Network:** `POST /api/v1/auth/request_otp`, `POST /api/v1/auth/verify_otp`, and
`GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the `GET /api/v1/me` all return **200** with the `ApiResult` envelope, and there is **no CORS error** in the
console. That is the first real authenticated request between the two projects. console. That is the first real authenticated request between the two projects.
@@ -138,8 +158,17 @@ Prove search works without the frontend: open Swagger →
## Good to know ## Good to know
- **The API speaks HTTP/2** (Kestrel `Protocols: Http2`, for gRPC). Browsers negotiate h2-over-TLS - **The API speaks HTTP/1.1 and HTTP/2** (Kestrel `Protocols: Http1AndHttp2`, refinement-phase-5 — the
automatically, so `fetch` just works; for `curl` add `--http2`. previous HTTP/2-only default broke non-TLS HTTP/1.1 hops). Over TLS the client negotiates h2 via ALPN, so
gRPC and `fetch` both work; plain-HTTP hops fall back to HTTP/1.1.
- **Secrets fail fast.** On a fresh clone with no user-secrets the API refuses to start with
`Refusing to start: required secret configuration is missing…` — set the connection-string user-secret
(step 3) and boot again. Deployed environments must additionally supply real `IdentitySettings` JWE keys
and `Seams:FieldEncryption` keys (Development uses dev-only defaults from `appsettings.Development.json`).
- **Enable the secret-scan pre-commit hook** once per clone so a stray credential can't be committed:
`git config core.hooksPath .githooks` (see [`.githooks/README.md`](../../../.githooks/README.md)).
- **Behind a reverse proxy**, list its address in `ForwardedHeaders:KnownProxies` (or a CIDR in
`:KnownNetworks`) so the rate limiter partitions on the real client IP, not the proxy's.
- **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock - **Only `auth` is real by default.** 21 of 22 client service domains default to an in-browser mock
(`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4. (`USE_*_MOCK = true`); the home, search, bookings, etc. are fake in-memory data until Refinement Phase 4.
- **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the - **The DB self-migrates + self-seeds**, so pointing at an empty local instance is enough — including the
@@ -173,6 +202,7 @@ world, wipe the volume (`docker compose down -v`) and boot again.
| Symptom | Fix | | Symptom | Fix |
| --- | --- | | --- | --- |
| Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). | | Browser: `net::ERR_CERT_AUTHORITY_INVALID` on `:5002` | Run `dotnet dev-certs https --trust` (setup step 1). |
| API startup: `Refusing to start: required secret configuration is missing…` | The connection-string user-secret isn't set (or still the placeholder). Do setup step 3. |
| API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. | | API startup: `Login failed for user 'sa'` / connect timeout | DB not up or wrong password — check `docker compose ps` and that the user-secrets password matches `docker-compose.yml`. |
| Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. | | Console: `...has been blocked by CORS policy` | `UseCors` missing/mis-ordered, or the browser origin isn't in `Cors:AllowedOrigins`. It must sit after `UseRouting` and before the rate limiter. |
| `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with `<UserSecretsId>`). | | `dotnet user-secrets` errors with "could not find UserSecretsId" | Run it from `server/src/API/Baya.Web.Api` (the project with `<UserSecretsId>`). |
+47 -21
View File
@@ -9,6 +9,22 @@ is invented; "not needed" claims are backed by the absence of the package/code.
(app DB + log DB); everything else (18 seams) is an in-process mock, so "deployment" today is one container (app DB + log DB); everything else (18 seams) is an in-process mock, so "deployment" today is one container
+ one database — and the table below is the roadmap of what must exist as each seam goes real. + one database — and the table below is the roadmap of what must exist as each seam goes real.
> **Refinement-phase-8 update (2026-07-13):** services **814** and **16** below now have a **real HTTP adapter
> shipped behind their seam**, config-selected by a per-rail `Seams:*:Provider` selector (mock stays the default).
> "Depends via" now points at a real client, not just a planned one — provisioning the vendor account + credential
> and flipping the selector turns each on, no code change. What is still genuinely absent (no adapter): **Redis**
> (5), **Elasticsearch** (17), and the LLM **review-moderation classifier** (15, optional). MoH/INO/eNamad (16)
> stay **manual by design**. See the mocks-registry refinement-phase-8 banner for the provider tokens per rail.
>
> **Refinement-phase-9 update (2026-07-13) — observability (service 4):** the two overlapping metric stacks were
> consolidated onto **one OpenTelemetry stack** — prometheus-net was removed; metrics are scraped at `/metrics` via
> the OTel Prometheus exporter, and **distributed tracing** (ASP.NET Core + EF Core) was added, exporting **OTLP
> only when `OpenTelemetry:Otlp:Endpoint` is configured** (an MVP with Prometheus alone is unchanged). A request's
> `ApiResult.requestId` is its W3C trace id (support ↔ trace 1:1). Health checks split into `/healthz/live`
> (process) vs `/healthz/ready` (app DB + log DB [deployed] + an object-storage write probe); `/HealthCheck` stays
> as the aggregate. An **OTLP collector** (Grafana Tempo / Jaeger / OTEL Collector) becomes the optional new
> observability service when trace export is turned on. Elasticsearch (17) is still deferred.
## Service inventory ## Service inventory
| # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row | | # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row |
@@ -16,10 +32,10 @@ is invented; "not needed" claims are backed by the absence of the package/code.
| 1 | **SQL Server** (app DB `Baya`) | System of record — 12 schemas (`usr ops geo catalog verif search booking payments payouts reviews messaging partner`) | EF Core; `ConnectionStrings:SqlServer` | **Required now** | — | | 1 | **SQL Server** (app DB `Baya`) | System of record — 12 schemas (`usr ops geo catalog verif search booking payments payouts reviews messaging partner`) | EF Core; `ConnectionStrings:SqlServer` | **Required now** | — |
| 2 | **SQL Server** (log DB `Baya_Logs`) | Serilog sink in deployed envs (Warning+, auto-created `log.LogEvents`) | `ConnectionStrings:logDb` | **Required now** (deployed) | — | | 2 | **SQL Server** (log DB `Baya_Logs`) | Serilog sink in deployed envs (Warning+, auto-created `log.LogEvents`) | `ConnectionStrings:logDb` | **Required now** (deployed) | — |
| 3 | **Reverse proxy / TLS** (nginx·caddy·traefik) | TLS termination, HTTP/1.1+2, forwarded headers | Kestrel config; JWE bearer | **Required now** | — | | 3 | **Reverse proxy / TLS** (nginx·caddy·traefik) | TLS termination, HTTP/1.1+2, forwarded headers | Kestrel config; JWE bearer | **Required now** | — |
| 4 | **Prometheus** (+ Grafana) | Scrapes `/metrics`; health forwarded to gauges | `UseMetricServer` + OTel exporter | **Recommended now** | — | | 4 | **Prometheus** (+ Grafana) · optional **OTLP collector** | Scrapes `/metrics` (one OTel stack, refinement-phase-9); traces export OTLP when configured | OTel metrics + tracing; `OpenTelemetry:Otlp:Endpoint` | **Recommended now** (collector optional) | — |
| 5 | **Redis** | `ICacheService` + `IDistributedLock` (money-path mutex) | `Seams:*` (keys TBD; none today) | Before >1 API instance | rows 14, 42 | | 5 | **Redis** | `ICacheService` + `IDistributedLock` (money-path mutex) | `Seams:*` (keys TBD; none today) | Before >1 API instance | rows 14, 42 |
| 6 | **MinIO / S3 / ArvanCloud** | `IObjectStorage` — verification docs, avatars (REQ-006), invoice PDFs | `Seams:ObjectStorage:*` | Before real verification | row 13 | | 6 | **MinIO / S3 / ArvanCloud** | `IObjectStorage` — verification docs, avatars (REQ-006), invoice PDFs | `Seams:ObjectStorage:*` | Before real verification | row 13 |
| 7 | **Job scheduler** (Hangfire/Quartz, in-app on SQL) | The deferred crons: payout batch, expiry scan, no-show, Moadian poll | hosted services (no interface exists) | Before unattended ops | row 26 | | 7 | **Job scheduler** — in-process, SQL only (refinement-phase-7 **done**) | The recurring crons: booking-expiry, notification-retention, credential-expiry scan, no-show sweep, weekly payout-batch generation (Moadian/refund-settlement poll = Phase 8) | `RecurringJobSchedulerHostedService` + `IRecurringJob`s | **Done for single instance** (no new infra) | row 26 |
| 8 | **SMS gateway** (Kavenegar·Ghasedak·SMS.ir) | `ISmsSender` — OTP delivery (login is impossible without it) | `Seams:Sms:*` (to be added) | **Launch-critical** | row 12 | | 8 | **SMS gateway** (Kavenegar·Ghasedak·SMS.ir) | `ISmsSender` — OTP delivery (login is impossible without it) | `Seams:Sms:*` (to be added) | **Launch-critical** | row 12 |
| 9 | **PSP / IPG + Shaparak** (ZarinPal·Sadad·Vandar·Jibit) | `IPaymentProvider` + `IWebhookVerifier` + `ISettlementSplitProvider` (تسهیم) | encrypted `payment_gateways.config_json` | Real payments | rows 3941 | | 9 | **PSP / IPG + Shaparak** (ZarinPal·Sadad·Vandar·Jibit) | `IPaymentProvider` + `IWebhookVerifier` + `ISettlementSplitProvider` (تسهیم) | encrypted `payment_gateways.config_json` | Real payments | rows 3941 |
| 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 4647 | | 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 4647 |
@@ -126,8 +142,9 @@ flowchart LR
Both DBs fit one instance; `Baya_Logs` can move later. Both DBs fit one instance; `Baya_Logs` can move later.
- **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb` — **rotate + externalize first** - **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb` — **rotate + externalize first**
(plan §1.1; live `sa` credentials are committed today). (plan §1.1; live `sa` credentials are committed today).
- **Health/readiness:** the app's only health check (`/HealthCheck`, - **Health/readiness (refinement-phase-9 — §7.2 landed):** split `/healthz/live` (process) vs `/healthz/ready`
`Monitoring/Configurations/HealthCheckConfigurations.cs:17`); `logDb` has none (plan §7.2). Boot runs (app DB + `logDb` [deployed] + object-storage write probe); `/HealthCheck` stays as the aggregate
(`Monitoring/Configurations/HealthCheckConfigurations.cs`). Boot runs
`MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node `MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node
boot races (plan §4.3). boot races (plan §4.3).
@@ -142,13 +159,16 @@ flowchart LR
- **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5 - **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5
lands. lands.
### 4 · Prometheus (+ Grafana) ### 4 · Prometheus (+ Grafana) · optional OTLP collector
- **Evidence:** `/metrics` via prometheus-net `UseMetricServer` + OTel `AddPrometheusExporter` (two stacks — - **Evidence (refinement-phase-9 — §7.1 landed):** **one** OpenTelemetry stack — metrics scraped at `/metrics` via
consolidate, plan §7.1) at `Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11` and `UseOpenTelemetryPrometheusScrapingEndpoint()` (`Monitoring/Configurations/PrometheusMetricsConfigurations.cs`),
`OpenTelemetryConfigurations.cs:21`; health forwarded (`HealthCheckConfigurations.cs:18`). plus `WithTracing` (ASP.NET Core + EF Core) exporting **OTLP only when `OpenTelemetry:Otlp:Endpoint` is set**
- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. No tracing backend exists yet (metrics-only); (`OpenTelemetryConfigurations.cs`). The duplicate prometheus-net stack (`UseMetricServer`/`UseHttpMetrics`/
an OTLP collector becomes relevant with plan §7.1. `ForwardToPrometheus` + packages) was removed. A request's `ApiResult.requestId` is its W3C trace id.
- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. A **tracing backend / OTLP collector** (Grafana
Tempo · Jaeger · OpenTelemetry Collector) is the optional new service — set `OpenTelemetry:Otlp:Endpoint` at it to
turn trace + metric export on; Prometheus-scrape-only is an acceptable MVP.
### 5 · Redis ### 5 · Redis
@@ -262,19 +282,25 @@ flowchart LR
## Deployment notes (from the code, not aspiration) ## Deployment notes (from the code, not aspiration)
1. **Boot = migrate + seed.** Every non-Testing start applies EF migrations and seeds roles, the 1. **Boot migrate (refinement-phase-7).** DDL is a separate deploy step — `dotnet run -- migrate` applies
`admin`/`qw123321` user, and an **active sandbox ZarinPal gateway** (`Program.cs:99-104`). Until plan migrations + idempotent seeders then exits. **Development** boot still migrates + seeds (incl. the
§1.3/§1.4/§4.3 land: single-instance start-up, DDL-privileged login, and clean up the seeded credentials Development-only sandbox gateway + demo world) for convenience; **deployed** boot only *checks* the schema is
per environment. current (`EnsureSchemaUpToDateAsync`, fail-fast on a pending migration) and seeds roles/break-glass admin. So
multi-instance boots no longer race on DDL and the runtime login needs no permanent DDL rights (plan §1.3/§1.4
also landed — no committed `admin`/`qw123321`; sandbox gateway is Development-only).
2. **Environment files:** `appsettings.json``appsettings.Development.json` (byte-identical); **no 2. **Environment files:** `appsettings.json``appsettings.Development.json` (byte-identical); **no
Production/Staging file exists.** All non-secret env differences ride on ~14 `Seams:*` groups whose Production/Staging file exists.** All non-secret env differences ride on ~14 `Seams:*` groups whose
defaults live in code (`SeamOptions.cs`), not in config files. defaults live in code (`SeamOptions.cs`), not in config files.
3. **HTTP posture:** HTTP/2-only Kestrel default (plan §1.5), gRPC plugin + reflection always on 3. **HTTP posture:** mixed `Http1AndHttp2` Kestrel (refinement-phase-5), so gRPC shares the listener via ALPN;
(plan §7.5), TLS required for JWE sanity. gRPC **reflection is Development-only** (refinement-phase-9 §7.5); TLS required for JWE sanity.
4. **Single-instance constraints today:** in-memory cache, in-proc money lock, in-proc sweeps, per-instance 4. **Single-instance constraints today:** in-memory cache, in-proc money lock, the in-proc recurring-job
rate-limit buckets. Scaling past one instance requires plan §4.2 (Redis) + §4.3 (migrations) first — the scheduler (refinement-phase-7 — its per-tick lock is that same in-proc seam), per-instance rate-limit buckets.
DB uniques keep money *correct* either way, but locks/cache/limits silently degrade. §4.3 (migrations split from boot) **landed**; scaling past one instance still requires §4.2 (Redis for the
5. **Logs:** deployed envs write Warning+ to `Baya_Logs` only (Information dropped — plan §7.3); dev writes shared cache + the cross-instance lock the scheduler/money path use) first — the DB uniques keep money
console + `logs/log.json`. *correct* either way, but locks/cache/limits/scheduler-de-dup silently degrade.
5. **Logs (refinement-phase-9 — §7.3 landed):** deployed envs write **Information+** to `Baya_Logs` (framework
categories held at Warning); **no PII/secrets** (the OTP code is no longer logged in any env). The dead
Elasticsearch sink + package were removed; log-table retention is an ops/DBA task (or ship logs to the OTLP
collector). Dev writes console + `logs/log.json`.
6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase; 6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase;
snake_case routes. snake_case routes.
@@ -12,6 +12,43 @@ One block per completed backend phase. Newest at the top. Backend lane writes he
- **Notes for frontend:** <anything load-bearing> - **Notes for frontend:** <anything load-bearing>
--> -->
## refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later — 2026-07-13
- **Shipped:** **one OpenTelemetry stack** (metrics scraped at `/metrics` + **tracing** ASP.NET Core/EF, opt-in
OTLP via `OpenTelemetry:Otlp:Endpoint`; prometheus-net removed) — `ApiResult.requestId` = W3C trace id.
**Health split** `/healthz/live` vs `/healthz/ready` (app DB + logDb[deployed] + object-storage write probe);
`/HealthCheck` aggregate kept. **Prod logs Information+** with **no PII** (OTP code no longer logged) + dead
Elasticsearch sink/package removed. **`AuditLogRetentionJob`** (`IRecurringJob`, two-tier legal retention).
**`TicketMessage.Body` encrypted at rest** (`IFieldEncryptor`; column → nvarchar(max)). **gRPC reflection
Development-only.** Docs reconciled (mocks-registry stale rows pruned; deferrals 9.79.11 recorded with pull-
triggers). Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` (Body widen + 3 config seed rows).
- **Contracts:** none changed — no wire/shape change (observability + docs + at-rest encryption only).
- **Mocked:** none new. Deferred (recorded, not gaps): Elasticsearch `INurseSearch`, SMS/push
`INotificationDispatcher`, analytics pipeline, holiday feed, 8 product tables — each with a written pull-trigger.
- **Gate:** build clean (0 new warnings) / **407 tests pass** (402 prior + 5 new: audit-retention, ticket-body
encryption, liveness).
- **Handoff:** backend/handoff/after-refinement-phase-9.md
- **Notes for frontend:** no client-facing change. Ticket message bodies are now encrypted at rest server-side
(the thread read still returns plaintext — unchanged wire). A request's `requestId` is a real trace id useful
for support correlation.
## refinement-phase-7 — Unattended ops: scheduler, locking & multi-instance readiness — 2026-07-13
- **Shipped:** one in-process **`RecurringJobSchedulerHostedService`** + the **`IRecurringJob`** seam
(`Persistence/Services/Scheduling/`) replacing the two `PeriodicTimer` hosted services and scheduling the
previously admin-manual crons — `booking_request_expiry`, `notification_retention`, `verification_expiry_scan`,
`no_show_sweep`, `weekly_payout_generation` — each reading its seeded cadence key; admin triggers stay overrides.
Payout **generation** is scheduled (system-initiated `draft`); **processing** stays admin-only
(`NursePayoutBatch.InitiatedByAdminId` now nullable = system; `SystemInitiated` flag is scheduler-only,
controller-neutralized). **Migrations split from boot:** `dotnet run -- migrate` one-shot + deployed-boot schema
*check* (`EnsureSchemaUpToDateAsync`); Dev keeps migrate-on-boot. **No Redis/Hangfire added** — documented as the
>1-instance scale-out gate; the scheduler's per-tick `IDistributedLock` is that swap point.
- **Contracts:** `PayoutBatchDto.initiatedByAdminId` nullable — `dev/contracts/domains/payouts.md` + openapi
snapshot refreshed (yes).
- **Mocked:** none new. Redis = scale gate; Moadian/refund-settlement poll = Phase 8 jobs (see mocks-registry).
- **Gate:** build clean (0 new warnings) / **402 tests pass** (396 prior + 6 new scheduling tests).
- **Handoff:** backend/handoff/after-refinement-phase-7.md
- **Notes for frontend:** admin payout batch `initiatedByAdminId` can be `null` (system/scheduled batch) — render
a "system"/"scheduled" label rather than assuming an admin id.
## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13 ## refinement-phase-1 — Database: local-dev story, demo seed & migration hygiene — 2026-07-13
- **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder** - **Shipped (no migration, no endpoint, no contract change):** Development-gated **demo-world seeder**
`Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in `Persistence/Services/Seeding/DemoWorldSeeder.cs` + `DemoWorldDefinitions.cs`, scoped-registered in
@@ -0,0 +1,31 @@
# After refinement-phase-6 — Money-path correctness completion
**Track:** backend (money path). Gate: `dotnet build` 0 new warnings · `dotnet test` 396 pass (+13). Migration
`RefinementPhase6MoneyFks` scaffolded (additive: 3 FK sets + invoice index + delete of a dead config seed row).
## What's now live (new endpoints — admin)
- **`POST api/v1/admin_refunds/{id}/confirm_settlement`** — settle a `processing` BNPL/manual refund
(`processing → succeeded`, posts the deferred `refund_payable ↔ escrow_held` clearing). Idempotent.
- **`POST api/v1/admin_refunds/{id}/mark_failed`** — fail a `processing` refund (no ledger). Body
`{ "reason": "..." }` (optional). Idempotent.
- Both return `RefundSettlement` `{ refundId, bookingId, status, completedAt }`. Contract:
`dev/contracts/domains/refunds-invoices.md`; snapshot `swagger.v1.json` refreshed.
## What changed under the hood (no client-visible shape change)
- The **BNPL provider cash-back callback** now auto-settles the matching `processing` refund (a
`refund/revert completed|confirmed|settled` / `cashback` event) — so a real BNPL revert reaches `succeeded`
without an admin click.
- **Refund create** persists the row (`approved`) before the external channel call (crash-window fix) — an
interrupted refund is now a reconcilable `approved` row, not a lost execution.
- **Forward-dep FKs** added: `refunds.ticket_id`, `nurse_clawbacks.original_payout_id`/`recovered_in_payout_id`,
`invoices.partner_center_id` (+ index). All nullable, `NO ACTION`. No behavior change; integrity backstop only.
- **Audit:** `Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification` are now `IAuditable`
→ admin decisions leave an `audit_logs` diff row (IBAN redacted).
- **Retired** the orphaned `refund_ticket_required` config key (a refund ticket is always auto-opened).
## Frontend notes
- The customer refund-status flow is **unchanged** — a BNPL refund still shows `processing` with the ETA, and now
actually flips to `succeeded` once settled (via admin confirm or the provider callback). No new client work
required for the customer side.
- If/when an admin refund console surfaces settlement, the two new endpoints are the actions (staff-gated,
`sensitive` rate policy).
@@ -0,0 +1,38 @@
# After refinement-phase-7 — Unattended operation (scheduler, locking, migrations-from-boot)
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
Almost nothing user-facing — this is infrastructure. One wire delta:
- **`PayoutBatchDto.initiatedByAdminId` is now nullable.** `null` = a **system-initiated / scheduled** payout
batch (the weekly cron generated it, no human initiator). Admin payout UIs should render a "system"/"scheduled"
label instead of assuming an admin id. Contract + `swagger.v1.json` updated.
## What the platform now does on its own
The four previously admin-click-only sweeps run on schedule (each reading its `platform_configs` cadence key):
credential-expiry scan, EVV no-show sweep, and **weekly payout-batch generation** — plus the two re-homed sweeps
(booking-request expiry, notification retention). **Admin manual triggers are unchanged and remain overrides.**
- **Payout generation only.** The cron opens a `draft` batch; **processing (money movement) is still an explicit
admin action** (`POST admin_payouts/batches/{id}/process`). Do not build a client flow that auto-processes.
## For the next backend phase (Phase 8 — external rails)
- **Register new crons via the seam, not a new host.** Implement `IRecurringJob`
(`Persistence/Services/Scheduling/`) + one `services.AddSingleton<IRecurringJob, YourJob>()` in
`AddPersistenceServices`. Phase 8 owns the **Moadian reconciliation poll** and the **refund-settlement
reconciliation** this way (each reads/adds its own cadence key). The scheduler already provides the per-tick
scope, the `scheduler:{name}` lock, and error isolation.
- **Jobs must stay idempotent** — a retry (or a second instance once the lock is Redis-backed) must never
double-pay/double-post; the DB uniques/state-machines are the backstop.
## Ops / deployment
- **DDL is a deploy step now:** run `dotnet run -- migrate` (applies migrations + idempotent seeders, then exits)
before starting the API in a deployed environment. A normal deployed boot only *checks* the schema and **fails
fast** if a migration is pending. Development still migrates + seeds on boot.
- **Redis is the >1-instance gate** (shared cache + the cross-instance scheduler/money lock). Single-instance MVP
does not need it; the in-proc seams are correct for one instance. Elasticsearch is never MVP.
@@ -0,0 +1,56 @@
# After refinement-phase-8 — External rails go real (config-selected vendor adapters)
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
**Nothing user-facing changed by default** — the mocks stay the default registration, so every existing flow
behaves exactly as before. This phase makes each vendor rail *swappable to real by config*, not on by default.
One **new endpoint** (backend-to-backend, not for the browser): `POST /api/v1/webhooks/payouts/{provider}` — the
async PAYA/SATNA reconciliation callback (signature-authenticated, anonymous, `webhook` rate policy). It flips a
`submitted` payout to `paid`/`failed`. No client calls it.
## How a rail goes real (ops)
Set the rail's **`Seams:{rail}:Provider`** + its credentials (user-secrets/env) and restart — no code change,
no deploy of new binaries. Provider tokens (mock stays default; a typo falls closed to mock):
| Rail | Key | Real value | Also needs |
| --- | --- | --- | --- |
| SMS (**launch-critical**) | `Seams:Sms:Provider` | `kavenegar` | `Seams:Sms:{ApiKey,SenderLine,OtpTemplate}` |
| Shahkar / e-KYC / شبا | `Seams:{Shahkar,IdentityKyc,BankOwnership}:Provider` | `finnotech` | `Seams:Finnotech:{BaseUrl,ClientId,AccessToken}` |
| Geocoding | `Seams:Geocoding:Provider` | `neshan` | `Seams:Geocoding:{ApiKey}` |
| Object storage | `Seams:ObjectStorage:Provider` | `s3` | `Seams:ObjectStorage:{ServiceUrl,Bucket,Region,AccessKey,SecretKey}` |
| Card PSP (+ HMAC webhook + تسهیم) | `Seams:Payments:Provider` | `zarinpal` | `Seams:Payments:{MerchantId,CallbackUrl,WebhookSigningSecrets}` |
| BNPL | `Seams:Bnpl:Provider` | `real` | `Seams:Bnpl:Providers:{snapppay,digipay}:*` (creds via gateway config) |
| Payout rail | `Seams:BankTransfer:Provider` | `jibit` | `Seams:BankTransfer:{ApiKey,SourceSettlementAccount}` + webhook secret |
| Moadian | `Seams:Moadian:Provider` | `moadian` | `Seams:Moadian:{MemoryId,AccessToken}` + signing cert |
**Two hard rules baked in:**
- **SMS real ⇒ the OTP is never logged.** The Development OTP-in-logs bridge (`GET dev/last_otp`) runs **only while
the mock SMS sender is selected**. Once `Seams:Sms:Provider=kavenegar`, the code only leaves the process over the
SMS wire.
- **Money callbacks fail closed.** The payout reconciliation + PSP webhook verify a per-provider HMAC over the raw
body; an invalid signature mutates nothing. The confirm path still re-verifies the amount server-side.
## Behavioural notes the next phase should know
- **Payout rail is async now (when real).** A real `JibitBankTransferProvider` accepts a transfer as `submitted`;
the ledger posts only when the reconciliation callback confirms `paid`. The `ExecutePayoutBatch` handler already
handled this (`MarkSubmitted` first, ledger on `paid`) — it was unchanged.
- **`bookings/convert` is Dev/Testing only.** `IPaymentCaptureSimulator` is out of the production registration
(prod = fail-closed `DisabledPaymentCaptureSimulator`). Production converts via the b10 payment **webhook confirm**
calling `ConvertRequestToBooking` directly — do not build a client convert flow.
- **`balinyaar` BNPL = in-house.** In real BNPL mode `provider_code=balinyaar` resolves to the deterministic
net-of-fee model (no external API); `tara`/`torobpay` are unbuilt and rejected cleanly.
- **New cron:** `MoadianReconciliationJob` (6 h) walks `pending/submitted` invoices to `registered` — registered
the phase-7 way (`IRecurringJob` + one `AddSingleton`), no migration.
## Follow-ups carried forward
- Per-code BNPL revert (the b11 refund path uses the SnappPay default); SMS.ir/Ghasedak adapters; Finnotech/Moadian
token exchange + Moadian signing cert; the **refund-settlement poll** (BNPL `processing → succeeded`, pairs with
Moadian — the confirm command exists, the poll job is the remaining wiring); a dedicated **center-settlement
payout** (deferred per 6.6 — MoR centers settle via a تسهیم split leg, non-MoR have no separate money path).
- **Redis** stays the >1-instance gate; **Elasticsearch** is never MVP.
@@ -0,0 +1,43 @@
# After refinement-phase-9 — Observability, ops hardening, docs honesty & scale-later
**For the frontend / next backend phase. Backend-owned; frontend reads.**
## What changed for a client
**Nothing user-facing.** No route, envelope, shape, or enum changed. Two things worth knowing:
- **`ApiResult.requestId` is now a real W3C trace id** (the request's OpenTelemetry trace). It's the id to quote in
a support ticket — it maps 1:1 to the server-side trace once an OTLP collector is wired.
- **Ticket message bodies are encrypted at rest** server-side. The thread read still returns **plaintext** (the wire
is unchanged); the change is purely storage-side (the refund/dispute paper trail is no longer plaintext in the DB).
## What the platform now does / exposes (ops)
- **One OpenTelemetry stack.** Metrics scrape at `/metrics`; distributed **tracing** (ASP.NET Core + EF Core) is
wired. **OTLP export is opt-in** — set `OpenTelemetry:Otlp:Endpoint` (Grafana Tempo / Jaeger / OTEL Collector) to
turn trace + metric export on. Prometheus-scrape-only is an acceptable MVP; prometheus-net was removed.
- **Health endpoints:** `/healthz/live` (process only — safe liveness), `/healthz/ready` (app DB + log DB [deployed]
+ an object-storage write probe — pull an instance out of rotation when a dependency is down), `/HealthCheck`
(aggregate, kept for compat). Point the orchestrator's liveness probe at `/healthz/live`, readiness at
`/healthz/ready`.
- **Prod logs are Information+ with no PII/secrets.** The OTP code is no longer logged in any environment. Log-table
retention on `Baya_Logs` is an ops/DBA task (or ship logs to the OTLP collector).
- **Audit-log retention** runs as a scheduled `IRecurringJob` — two-tier (financial/verification rows kept ~7 yr,
everyday rows ~2 yr) via the `audit_retention_*` config keys.
- **gRPC reflection is Development-only** (the plugin itself is unchanged; it shares the mixed-protocol listener).
## For the next backend phase / deploy
- **Turn tracing on in deployed envs** by provisioning an OTLP collector and setting `OpenTelemetry:Otlp:Endpoint`.
- **When Redis lands (>1 instance)**, add a `redis` readiness check (tagged `ready`) to `ConfigureHealthChecks`.
- **Register any new retention/cron via `IRecurringJob`** (unchanged from phase 7).
## Deferred — recorded, NOT gaps (each has a written pull-trigger; see the phase report)
- **Elasticsearch `INurseSearch` backend + outbox feeder** — pull when SQL search shows strain. SQL search is the
real MVP (`Search:Backend=sql`; any other value fails fast).
- **SMS/push channels of `INotificationDispatcher`** — pull when the notification UX demands out-of-app reach.
In-app notifications are real now.
- **Analytics warehouse/stream, holiday-calendar feed, 8 deferred product tables** (`organizations`,
`organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability
slots, customer national-ID KYC, geo bulk import) — each a pure additive step when product pulls it.
@@ -12,6 +12,23 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no) - **Requests filed:** frontend/requests/for-backend.md (yes/no)
--> -->
## refinement-phase-2 — Auth & role-aware navigation ("only customer side" fix) — 2026-07-13
- **Shipped:** the resolved-vs-pending role fix. `useRoleHydration()` (`services/auth`, `loading|error|ready`
over `useMe`) + `RoleGuard` (wraps every private shell; tested) + `AuthAccountError`. Shells now: neutral
splash while `/me` loads (never the customer shell), explicit `/me`-failed recovery (never a silent customer
fallback), and role-mismatch **redirect** to `resolveRoleDestination` + `guard_denied` toast. `(customer)`/
`nurse`/`admin` guard `expected={APP_ROLES.*}`; `partner` is hydration-only (self-gates via
useMyPartnerCenter). i18n `auth.guard_denied`/`account_error_*` (en+fa). Backend (a little): 2 phone-OTP
admins added to the demo seeder (`09120000020` super_admin / `09120000021` finance) so `/admin` is reachable
via phone-OTP + `useAdminCapabilities` gating is demonstrable.
- **Consumes:** the real b2 auth (`/me`, `me/select_role`) — no new contract. `USE_AUTH_MOCK` stays false.
- **Mocked client-side:** none new. Partner login-routing deferred (`/partner` reachable by direct nav via the
existing partnerCenter mock).
- **Gate:** npm run check green · RoleGuard.test.tsx 8/8 · en/fa in sync · server build 0 errors ·
DemoWorldSeederTests 4/4.
- **Requests filed:** yes — **REQ-004 resolved** (client owns active-role); **REQ-038 filed** (a `/me`
partner-center-admin signal for partner login-routing).
## frontend-phase-15-b15 — Admin backoffice & partner-center consoles — 2026-07-10 — **MVP COMPLETE** ## frontend-phase-15-b15 — Admin backoffice & partner-center consoles — 2026-07-10 — **MVP COMPLETE**
- **Shipped:** the internal **operational cockpit** — the role-gated admin backoffice (desktop sidebar shell) + - **Shipped:** the internal **operational cockpit** — the role-gated admin backoffice (desktop sidebar shell) +
the separately-scoped **partner-center portal**. Two new domains: **`services/admin`** (config / holidays / the separately-scoped **partner-center portal**. Two new domains: **`services/admin`** (config / holidays /
@@ -29,7 +29,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
pattern every later frontend phase inherits. pattern every later frontend phase inherits.
- **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }` - **Proposed shape:** `{ isSuccess: boolean, statusCode: number, message?: string, requestId?: string, data?: T }`
and `data: { items: T[], total: number, page: number, pageSize: number }` for lists. and `data: { items: T[], total: number, page: number, pageSize: number }` for lists.
- **Status:** open - **Status:** confirmed in refinement-phase-3 — the `ApiResult` envelope (payload under `data`, camelCase body, integer `statusCode`) and `PagedResult` `{ items, total, page, pageSize }` are the intended shapes for all endpoints. **Note:** the list query param binds camelCase **`pageSize`** (case-insensitive); the `page_size` doc occurrences were swept (REQ-010).
## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02 ## REQ-002 — OTP length + expiry in RequestOtpResult — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`. - **Need:** Add `codeLength` (int) and `expiresInSeconds` (int) to `RequestOtpResult`.
@@ -38,7 +38,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
(`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing (`OTP_CODE_LENGTH = 6`, inferred from the live 6-digit verify example, not the 4-box wireframe). Surfacing
the length makes the box count contract-driven; the expiry lets us show "code expires in …". the length makes the box count contract-driven; the expiry lets us show "code expires in …".
- **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }` - **Proposed shape:** `{ otpSent: boolean, resendAvailableInSeconds: number, codeLength: number, expiresInSeconds: number }`
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02 ## REQ-003 — Machine-readable error codes for verify_otp failures — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code - **Need:** A stable `code` on the 400 envelope for verify_otp that distinguishes wrong code vs expired code
@@ -50,7 +50,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough otherwise degrades to a generic "incorrect or expired" message. A stable machine code (kept generic enough
to avoid account enumeration) would let the UI render the precise state + the unlock countdown. to avoid account enumeration) would let the UI render the precise state + the unlock countdown.
- **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }` - **Proposed shape:** `{ isSuccess: false, statusCode: 400, message: "…", code: "otp_locked", data: { retryAfterSeconds: 60 } }`
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-005 — Patient `relation` + `conditions` fields — filed by frontend-phase-2-b3 — 2026-07-02 ## REQ-005 — Patient `relation` + `conditions` fields — filed by frontend-phase-2-b3 — 2026-07-02
- **Need:** Add two fields to `PatientDto` and the `patients/create` + `patients/update` bodies: - **Need:** Add two fields to `PatientDto` and the `patients/create` + `patients/update` bodies:
@@ -62,7 +62,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
and drops them on the real path. Adding the columns lets the client flip the flag to the live endpoints. and drops them on the real path. Adding the columns lets the client flip the flag to the live endpoints.
- **Proposed shape:** `PatientDto { …, relation: string|null, conditions: string[] }`; same fields accepted on - **Proposed shape:** `PatientDto { …, relation: string|null, conditions: string[] }`; same fields accepted on
create/update. Enum for `relation`; `conditions` a stable code list (could also be a normalized child table). create/update. Enum for `relation`; `conditions` a stable code list (could also be a normalized child table).
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-006 — Avatar / object-storage upload route (nurse & customer) — filed by frontend-phase-2-b3 — 2026-07-02 ## REQ-006 — Avatar / object-storage upload route (nurse & customer) — filed by frontend-phase-2-b3 — 2026-07-02
- **Need:** A multipart image-upload endpoint backed by `IObjectStorage` that returns a stored URL, plus an - **Need:** A multipart image-upload endpoint backed by `IObjectStorage` that returns a stored URL, plus an
@@ -72,7 +72,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
has no avatar field or upload route, and the client fetch layer is JSON-only (can't send multipart). The has no avatar field or upload route, and the client fetch layer is JSON-only (can't send multipart). The
client mocks this behind the `services/profiles` seam (`uploadAvatar` returns an object URL). The real client mocks this behind the `services/profiles` seam (`uploadAvatar` returns an object URL). The real
`profilesClientApi.uploadAvatar` throws `501` until this lands. `profilesClientApi.uploadAvatar` throws `501` until this lands.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-007 — Customer name + preferred-language update — filed by frontend-phase-2-b3 — 2026-07-02 ## REQ-007 — Customer name + preferred-language update — filed by frontend-phase-2-b3 — 2026-07-02
- **Need:** Either add `firstName`/`lastName`/`preferredLanguage` to the `customer_profiles/upsert` body + - **Need:** Either add `firstName`/`lastName`/`preferredLanguage` to the `customer_profiles/upsert` body +
@@ -83,7 +83,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
contact. Absent a wire field/endpoint, the client augments name/language behind the `services/profiles` seam contact. Absent a wire field/endpoint, the client augments name/language behind the `services/profiles` seam
(mock-persisted; the real upsert sends only the emergency contact). Confirm the intended home for these so the (mock-persisted; the real upsert sends only the emergency contact). Confirm the intended home for these so the
client stops augmenting. client stops augmenting.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02 ## REQ-004 — Confirm multi-role disambiguation (activeRole?) — filed by frontend-phase-1-b2 — 2026-07-02
- **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a - **Need:** Confirm whether `MeResult` will gain an `activeRole` (the user's currently-selected actor) for a
@@ -93,7 +93,12 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp app. If the backend intends to persist a "current role", the router should prefer it. Also note: verify_otp
returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`), returns `roles` but no user `id` (only `/me` has it) — fine for now (context id is hydrated from `/me`),
flagging in case that changes. flagging in case that changes.
- **Status:** open - **Resolution (refinement-phase-2, 2026-07-13):** **The client owns the active-role choice**`MeResult`
will *not* gain an `activeRole`. A dual customer+nurse session is disambiguated by the client-carried
intended role (the A1 vs B1 login switch), defaulting to the family app; `RoleGuard` lets a dual-role user
move freely between shells. No server change needed. If the backend ever wants to persist a "current role",
file a new REQ and the router will prefer it. **Confirmed the `/me` `id`-hydration note still holds.**
- **Status:** resolved (client owns it; no backend change)
## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02 ## REQ-008 — Accept the client-picked map pin on address create/update — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional - **Need:** Let `customer_addresses/create` and `customer_addresses/update/{id}` accept optional
@@ -107,7 +112,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store - **Proposed shape:** create/update body gains `latitude?: number, longitude?: number`; when both present, store
them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already them (and mark the geocode source as "user-pin"); when absent, geocode as today. `CustomerAddressDto` already
returns `latitude`/`longitude`. returns `latitude`/`longitude`.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02 ## REQ-009 — Add `provinceId` to `CustomerAddressDto` — filed by frontend-phase-3-b4 — 2026-07-02
- **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`). - **Need:** Add `provinceId` (long) to `CustomerAddressDto` (the province that owns the address's `cityId`).
@@ -119,7 +124,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
the server** can't prefill the province until this lands. `cityId` still implies the province server-side — the server** can't prefill the province until this lands. `cityId` still implies the province server-side —
this is purely to prefill the client cascade. this is purely to prefill the client cascade.
- **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`). - **Proposed shape:** `CustomerAddressDto { …, provinceId: long }` (join from `cities.province_id`).
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05 ## REQ-010 — Confirm/align the list pagination query-param name (catalog + all lists) — filed by frontend-phase-4-b5 — 2026-07-05
- **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The - **Need:** Confirm the exact query-param name the paginated list endpoints bind for page size. The
@@ -131,7 +136,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match; the server truly binds `pageSize`, please update the `page_size` occurrences in the contract docs to match;
if it binds `page_size`, tell us and we'll switch the client (one line per list call). if it binds `page_size`, tell us and we'll switch the client (one line per list call).
- **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`. - **Proposed shape:** list query = `?page={1-based}&pageSize={≤100}`; response `data` = `{ items, total, page, pageSize }`.
- **Status:** open - **Status:** delivered in refinement-phase-3 — server binds **`pageSize`**; the `page_size` occurrences in `dev/contracts/domains/*.md` + `conventions/api-conventions.md` were swept to `pageSize`. (One generated swagger endpoint still shows a `page_size` query name with `x-originalName: pageSize`; it binds `pageSize` case-insensitively.)
## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09 ## REQ-011 — Nurse-facing endpoint for structured professional-credential details — filed by frontend-phase-5-b6 — 2026-07-09
- **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the - **Need:** A nurse-facing command to submit the **structured** credential fields B5 collects alongside the
@@ -149,7 +154,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
`VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields. `VerificationStatusDto`. Alternatively, extend the manual-step `documents` confirm body with these fields.
- **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every** - **Also (minor):** the contract's `VerificationStepDto` has no `isRequired` — the client treats **every**
seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`. seeded step as required (the "X از Y" meter Y = `steps.length`). Confirm that holds, or add `isRequired`.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09 ## REQ-012 — Search result + nurse-profile enrichment for discovery (C2/C3) — filed by frontend-phase-6-b7 — 2026-07-09
- **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose: - **Need:** Two extra read surfaces the discovery UI renders but b7/b6/b5 don't yet expose:
@@ -170,7 +175,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
When both land, the swap is a single config flip (no hook/component change). When both land, the swap is a single config flip (no hook/component change).
- **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add - **Proposed shape:** enrich `NurseSearchResultDto` with `{ nurseName, avatarUrl, distanceKm? }`; add
`GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings. `GET api/v1/nurses/{id}/profile` returning the object above. `price`/`priceIrr` stay IRR digit-strings.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09 ## REQ-013 — Variant price on `BookingRequestDto` — filed by frontend-phase-7-b8 — 2026-07-09
- **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's - **Need:** Add the variant's **price** (IRR digit-string) to `BookingRequestDto` (and ideally the nurse's
@@ -183,7 +188,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
lets the summary price the service once the domain flips to the real endpoint. lets the summary price the service once the domain flips to the real endpoint.
- **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`. - **Proposed shape:** `BookingRequestDto { …, variantPrice: string (IRR digits), nurseAvatarUrl?: string }`.
(Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.) (Money-free rule intact — this is the *rate* of the chosen variant for display, not an engagement total.)
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09 ## REQ-014 — Enrich the nurse-inbox list item (variant label + patient age) — filed by frontend-phase-7-b8 — 2026-07-09
- **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to - **Need:** Add `variantLabel` (and optionally the patient's **age/age-band**) to
@@ -194,7 +199,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing the nurse must open the detail (`get/{id}`, which *does* carry `variantLabel`) to see the service. Surfacing
`variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage. `variantLabel` on the row makes the inbox self-describing; a coarse age is a nice-to-have for triage.
- **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`. - **Proposed shape:** `BookingRequestListItemDto { …, variantLabel: string, patientAge?: int }`.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10 ## REQ-015 — Confirm the booking/session/EVV enum codes + `checkInAddressMatch` tri-state — filed by frontend-phase-8-b9 — 2026-07-10
- **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate: - **Need:** Two confirmations so the f8 `services/bookings/types.ts` client unions stay wire-accurate:
@@ -213,7 +218,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes; - **Why:** f8 renders the status timeline, per-session chips, and the EVV banner strictly off these codes;
a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now), a casing/int drift or a `false`-vs-`null` conflation would mislabel a visit. Low-risk (mock-primary now),
but worth locking before f9/f13 consume the same shapes. but worth locking before f9/f13 consume the same shapes.
- **Status:** open - **Status:** confirmed in refinement-phase-3 — booking/session/EVV statuses serialize as the exact snake_case string codes the client unions expect (verified in code); `checkInAddressMatch` is `bool?` = `null` when GPS was absent **or** the frozen address has no resolvable coordinate, `false` = advisory out-of-range (never a block), `true` = in range.
## REQ-016 — Checkout summary for C6 (served gross/commission/VAT breakdown) — filed by frontend-phase-9-b10 — 2026-07-10 ## REQ-016 — Checkout summary for C6 (served gross/commission/VAT breakdown) — filed by frontend-phase-9-b10 — 2026-07-10
- **Need:** A customer-facing read that serves the C6 «خلاصه و پرداخت» money rows for an - **Need:** A customer-facing read that serves the C6 «خلاصه و پرداخت» money rows for an
@@ -232,7 +237,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
serviceCostIrr, commissionIrr, vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr, serviceCostIrr, commissionIrr, vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr,
nursePayoutAmount }` — the client's real `paymentClientApi.getCheckoutSummary` already targets this nursePayoutAmount }` — the client's real `paymentClientApi.getCheckoutSummary` already targets this
slug and unwraps this exact shape (`client/src/services/payment/types.ts: CheckoutSummaryDto`). slug and unwraps this exact shape (`client/src/services/payment/types.ts: CheckoutSummaryDto`).
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-017 — Client-readable payment outcome + `bookingId` on a converted request — filed by frontend-phase-9-b10 — 2026-07-10 ## REQ-017 — Client-readable payment outcome + `bookingId` on a converted request — filed by frontend-phase-9-b10 — 2026-07-10
- **Need:** After the gateway redirect returns, the client needs to learn (a) the payment transaction's - **Need:** After the gateway redirect returns, the client needs to learn (a) the payment transaction's
@@ -251,7 +256,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Proposed shape:** add `bookingId: long?` to `BookingRequestDto` (null until converted) **and/or** - **Proposed shape:** add `bookingId: long?` to `BookingRequestDto` (null until converted) **and/or**
`GET api/v1/bookings/{bookingRequestId}/payments/latest` → `{ transactionId, status, `GET api/v1/bookings/{bookingRequestId}/payments/latest` → `{ transactionId, status,
gatewayReferenceCode, bookingId? }`. gatewayReferenceCode, bookingId? }`.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-018 — Customer invoice availability after capture (auto-issue or owner-issue) — filed by frontend-phase-9-b10 — 2026-07-10 ## REQ-018 — Customer invoice availability after capture (auto-issue or owner-issue) — filed by frontend-phase-9-b10 — 2026-07-10
- **Need:** Make the b11 invoice reachable by the paying customer right after capture: auto-issue the - **Need:** Make the b11 invoice reachable by the paying customer right after capture: auto-issue the
@@ -262,7 +267,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
admin acts. The UI handles the 404 as a "فاکتور هنوز صادر نشده است" state (and the mock auto-issues at admin acts. The UI handles the 404 as a "فاکتور هنوز صادر نشده است" state (and the mock auto-issues at
capture to demo the full flow), but on the real rails every fresh payment would land on that empty capture to demo the full flow), but on the real rails every fresh payment would land on that empty
state. state.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10 ## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g. - **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g.
@@ -281,7 +286,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the
balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the
customer surface just needs to *create* the cancellation request and read the resulting refund. customer surface just needs to *create* the cancellation request and read the resulting refund.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10 ## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the - **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the
@@ -303,7 +308,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
set so the client maps the real codes. set so the client maps the real codes.
- **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set - **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set
(`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys. (`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10 ## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** Two additions to the customer refund surface: - **Need:** Two additions to the customer refund surface:
@@ -322,7 +327,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
commission on a revert) is **nullable** on the refund shape and reconciled from the provider response — commission on a revert) is **nullable** on the refund shape and reconciled from the provider response —
the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure
as opaque/never customer-facing. as opaque/never customer-facing.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-022 — BNPL provider/plan options + repayment schedule (D1/D2/D4) — filed by frontend-phase-11-b12 — 2026-07-10 ## REQ-022 — BNPL provider/plan options + repayment schedule (D1/D2/D4) — filed by frontend-phase-11-b12 — 2026-07-10
- **Need:** Two customer-facing reads the installment checkout renders that b12 serves **nothing** for: - **Need:** Two customer-facing reads the installment checkout renders that b12 serves **nothing** for:
@@ -344,7 +349,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Note on provider set:** the wireframe includes an **in-house `balinyaar`** plan not in the b12 - **Note on provider set:** the wireframe includes an **in-house `balinyaar`** plan not in the b12
`provider_code` enum (`snapppay|digipay|tara|torobpay`). Please add `balinyaar` (or state how the in-house `provider_code` enum (`snapppay|digipay|tara|torobpay`). Please add `balinyaar` (or state how the in-house
plan is modelled) so `providerCode` stays a closed set. plan is modelled) so `providerCode` stays a closed set.
- **Status:** open - **Status:** partially delivered in refinement-phase-3 — `balinyaar` added to the `provider_code` enum. **DEFERRED:** `checkout_bnpl/options/{id}` + `schedule` (per-plan monthly/down-payment split + due-dated repayment table) — b12 deliberately does not model the customer repayment schedule and there is no installment ledger to serve it from; keep D1/D2/D4 mocked until a provider-schedule integration or schedule table lands.
## REQ-023 — BNPL eligibility should accept the D3 credit-check inputs (national ID / mobile / consent) — filed by frontend-phase-11-b12 — 2026-07-10 ## REQ-023 — BNPL eligibility should accept the D3 credit-check inputs (national ID / mobile / consent) — filed by frontend-phase-11-b12 — 2026-07-10
- **Need:** Either extend `POST api/v1/checkout_bnpl/eligibility` to accept `{ nationalId, mobile, consent }` - **Need:** Either extend `POST api/v1/checkout_bnpl/eligibility` to accept `{ nationalId, mobile, consent }`
@@ -354,7 +359,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
the extra fields today (ignored server-side until the KYC step exists) and the mock uses them for the the extra fields today (ignored server-side until the KYC step exists) and the mock uses them for the
deterministic declined-path demo. The response already carries `eligibilityStatus` + `creditCeilingIrr`, deterministic declined-path demo. The response already carries `eligibilityStatus` + `creditCeilingIrr`,
which D3 renders — only the request inputs are the gap. which D3 renders — only the request inputs are the gap.
- **Status:** open - **Status:** delivered in refinement-phase-3 — `checkout_bnpl/eligibility` now accepts `{ nationalId, mobile, consent }` (consent required when the KYC inputs are present; supplied mobile drives the inquiry, else the account mobile). Mock still uses only the mobile until the real KYC step exists.
## REQ-024 — BNPL provider-reported installment status for the Wallet (D5) + customer bookingId link — filed by frontend-phase-11-b12 — 2026-07-10 ## REQ-024 — BNPL provider-reported installment status for the Wallet (D5) + customer bookingId link — filed by frontend-phase-11-b12 — 2026-07-10
- **Need:** Two additions for the Wallet installment view and the confirmation deep-link: - **Need:** Two additions for the Wallet installment view and the confirmation deep-link:
@@ -378,7 +383,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
surface, and b12 models none of the per-installment schedule/status. The client mocks the whole D5 read surface, and b12 models none of the per-installment schedule/status. The client mocks the whole D5 read
behind the `services/bnpl` seam (seeded plan + a plan pushed on each settled checkout). When it lands the behind the `services/bnpl` seam (seeded plan + a plan pushed on each settled checkout). When it lands the
swap is one config flip. swap is one config flip.
- **Status:** open - **Status:** partially delivered in refinement-phase-3 — (2) `bookingId` on the settled order **already present** on `BnplOrderStatusDto` (confirmed); (3) `GET checkout_bnpl/by_request/{bookingRequestId}` added (owner-scoped). **DEFERRED:** (1) `checkout_bnpl/wallet_installments` — per-installment provider-reported status Balinyaar does not own/track (no installment ledger in b12); needs provider integration. Keep D5 mocked.
## REQ-025 — Nurse-read earnings surface: four-bucket balance + per-booking earnings list + nurse payout detail — filed by frontend-phase-12-b13 — 2026-07-10 ## REQ-025 — Nurse-read earnings surface: four-bucket balance + per-booking earnings list + nurse payout detail — filed by frontend-phase-12-b13 — 2026-07-10
- **Need:** b13 serves the nurse exactly one endpoint (`GET api/v1/nurse_payouts/history``NursePayoutHistoryDto`). - **Need:** b13 serves the nurse exactly one endpoint (`GET api/v1/nurse_payouts/history``NursePayoutHistoryDto`).
@@ -412,7 +417,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
- **Note (money invariants the server owns):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`; - **Note (money invariants the server owns):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
`net_amount = gross_earnings clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its `net_amount = gross_earnings clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its
`gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted). `gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted).
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-026 — Review eligibility + my-review-for-booking reads (+ masked author confirmation) — filed by frontend-phase-13-b14 — 2026-07-10 ## REQ-026 — Review eligibility + my-review-for-booking reads (+ masked author confirmation) — filed by frontend-phase-13-b14 — 2026-07-10
- **Need:** Three customer-facing additions the leave-a-review flow renders that b14 does not serve: - **Need:** Three customer-facing additions the leave-a-review flow renders that b14 does not serve:
@@ -432,7 +437,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
reads the shared f8 bookings store for completed-booking eligibility, tracks the submission for the under-review reads the shared f8 bookings store for completed-booking eligibility, tracks the submission for the under-review
state, and seeds a published list per nurse. The real `reviewsClientApi` maps `getNurseReviews`/`createReview` state, and seeds a published list per nurse. The real `reviewsClientApi` maps `getNurseReviews`/`createReview`
1:1 and targets the two proposed slugs for the gaps — one config flip when they land. 1:1 and targets the two proposed slugs for the gaps — one config flip when they land.
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-027 — Family-owned care record (medications/routine/tasks) + record access + structured task results — filed by frontend-phase-13-b14 — 2026-07-10 ## REQ-027 — Family-owned care record (medications/routine/tasks) + record access + structured task results — filed by frontend-phase-13-b14 — 2026-07-10
- **Need:** The b14 `care_records` GET/POST serve the **nurse-authored visit-note history** (سوابق) — that half - **Need:** The b14 `care_records` GET/POST serve the **nurse-authored visit-note history** (سوابق) — that half
@@ -455,7 +460,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
methods target the proposed slugs (REQ-027) and the domain is **mock-primary** (`USE_PATIENT_RECORDS_MOCK = true`) methods target the proposed slugs (REQ-027) and the domain is **mock-primary** (`USE_PATIENT_RECORDS_MOCK = true`)
until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether until they land. **Note:** the wireframe's four-tab E2 record is not in the data model — please confirm whether
the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking). the family-owned record is a real MVP entity or a future addition (the client treats it as forward-looking).
- **Status:** open - **Status:** delivered in refinement-phase-3
## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10 ## REQ-028 — Ticket inbox enrichment (unread + last-activity), message author name, by-booking lookup, optimistic idempotency — filed by frontend-phase-14-b15 — 2026-07-10
- **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message - **Need:** Four additions the f14 messaging UI renders that the b15 `TicketSummaryDto`/`TicketThreadDto`/message
@@ -487,7 +492,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
optimistic composer render these; the domain is mock-primary precisely because (1)/(3) aren't served and the optimistic composer render these; the domain is mock-primary precisely because (1)/(3) aren't served and the
linked bookings are themselves mock-primary. When they land the swap is a single `USE_TICKETS_MOCK = false` flip linked bookings are themselves mock-primary. When they land the swap is a single `USE_TICKETS_MOCK = false` flip
(no hook/component change) — `ticketsClientApi` already maps the live routes. (no hook/component change) — `ticketsClientApi` already maps the live routes.
- **Status:** open - **Status:** delivered in refinement-phase-3 — (1) `unreadCount` + `lastMessageAt` on `TicketSummaryDto` (unread = non-internal messages from others after the caller's `last_read_at`, stamped when the participant fetches the thread; admin queue = 0). (3) `bookingId` query param on `GET /tickets`. (4) optional `clientMessageId` on `POST /tickets/{id}/messages` (deduped, echoed on `PostMessageResult`). (2) **Confirmed:** the role-label approach is intended — no raw identity/name is added (privacy).
## REQ-029 — Config `updatedAt`/`updatedBy` on `PlatformConfigDto` — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-029 — Config `updatedAt`/`updatedBy` on `PlatformConfigDto` — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the f15 config editor shows each row's last-changed meta ("updated {date} by {actor}"), but the b1 - **Need:** the f15 config editor shows each row's last-changed meta ("updated {date} by {actor}"), but the b1
@@ -496,14 +501,14 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
latest on the row without opening the drawer). Mock supplies both; the real row degrades gracefully without. latest on the row without opening the drawer). Mock supplies both; the real row degrades gracefully without.
- **Why:** finance needs the effective value + who last touched it at a glance. `services/admin` is mock-primary - **Why:** finance needs the effective value + who last touched it at a glance. `services/admin` is mock-primary
(`USE_ADMIN_MOCK = true`); `adminClientApi.listConfigs` maps the live route 1:1 and leaves these undefined. (`USE_ADMIN_MOCK = true`); `adminClientApi.listConfigs` maps the live route 1:1 and leaves these undefined.
- **Status:** open - **Status:** delivered in refinement-phase-3 — `updatedAt` + `updatedBy` on `PlatformConfigDto` (from the entity's audit fields; falls back to creation).
## REQ-030 — Audit-trail filters: actor / action / date-range — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-030 — Audit-trail filters: actor / action / date-range — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** `GET audit/get_audit_trail` filters only by `entity_type` + `entity_id`. The f15 audit viewer offers - **Need:** `GET audit/get_audit_trail` filters only by `entity_type` + `entity_id`. The f15 audit viewer offers
actor, action, and from/to date filters. Proposed: add `actor_id`, `action`, `from`, `to` query params (the mock actor, action, and from/to date filters. Proposed: add `actor_id`, `action`, `from`, `to` query params (the mock
honours all four). Until then the real client passes only the supported two and the rest degrade. honours all four). Until then the real client passes only the supported two and the rest degrade.
- **Why:** ops audits by actor and by time window, not only by a single entity. Mock-primary. - **Why:** ops audits by actor and by time window, not only by a single entity. Mock-primary.
- **Status:** open - **Status:** delivered in refinement-phase-3 — `GET audit/get_audit_trail` now also filters by `actorId`, `action`, `from`, `to` (all optional; `entityType`/`entityId` are now optional too). **Note:** query params bind camelCase (`actorId`/`from`/`to`), like `pageSize` — not `actor_id`.
## REQ-031 — RBAC role grant/revoke/list endpoints — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-031 — RBAC role grant/revoke/list endpoints — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the b15 contract exposes no role-management endpoints. The (optional, **DEFERRED-IF-MISSING**) admin - **Need:** the b15 contract exposes no role-management endpoints. The (optional, **DEFERRED-IF-MISSING**) admin
@@ -512,7 +517,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
{ userId, role }`, where `role` is one of `super_admin|admin|support|finance|moderation`. The screen is built { userId, role }`, where `role` is one of `super_admin|admin|support|finance|moderation`. The screen is built
against the mock and flagged DEFERRED-IF-MISSING; swap `USE_ADMIN_MOCK=false` once the routes land. against the mock and flagged DEFERRED-IF-MISSING; swap `USE_ADMIN_MOCK=false` once the routes land.
- **Why:** to manage which users hold which admin scopes. Not on the testable acceptance path. - **Why:** to manage which users hold which admin scopes. Not on the testable acceptance path.
- **Status:** open - **Status:** deferred in refinement-phase-3 — the RBAC `admin_roles/list|grant|revoke` console. The admin sub-role vocabulary + phone-OTP admins are seeded (refinement-phase-2), but a full grant/revoke management surface is admin-console tooling not on the frontend acceptance path (flagged DEFERRED-IF-MISSING); keep `USE_ADMIN_MOCK` for `/admin/roles`. To deliver: 3 endpoints over `user_roles` (grant/revoke audited) + a `RoleGrant[]` read.
## REQ-032 — Partner-portal split reads + activate/suspend + IBAN write-then-masked — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-032 — Partner-portal split reads + activate/suspend + IBAN write-then-masked — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the b15 contract has admin partner-center CRUD/verify/sponsor + a single `GET /centers/{id}/dashboard` - **Need:** the b15 contract has admin partner-center CRUD/verify/sponsor + a single `GET /centers/{id}/dashboard`
@@ -524,7 +529,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
full `settlementIban`, GET returns only `settlementIbanMasked` last-4). Also the admin **roster read** full `settlementIban`, GET returns only `settlementIbanMasked` last-4). Also the admin **roster read**
(`GET admin/partner-centers/{id}/nurses`). `services/partnerCenter` is mock-primary (`USE_PARTNER_MOCK`). (`GET admin/partner-centers/{id}/nurses`). `services/partnerCenter` is mock-primary (`USE_PARTNER_MOCK`).
- **Why:** the portal (separate authz scope, own-center tenancy) + the admin management screens render these. - **Why:** the portal (separate authz scope, own-center tenancy) + the admin management screens render these.
- **Status:** open - **Status:** partially delivered in refinement-phase-3 — (3) activate/suspend toggle `POST admin/partner-centers/{id}/set-active { isActive }` added; (4) **confirmed** the write-then-masked IBAN contract (PATCH accepts full `settlementIban`; reads return only masked last-4). **Route casing confirmed:** the b15 admin partner-center routes are **kebab-case** (`admin/partner-centers`, `.../set-active`) — an intentional b15 divergence, so the frontend's kebab-case guess is CORRECT (no change needed). **DEFERRED:** (1) `centers/me` + (2) the split portal reads (`centers/me/nurses|bookings|settlement`) — these need the user↔partner-center admin association that REQ-038 deferred; `/partner` stays reachable by direct nav + the partnerCenter mock until that seed + `/me` signal land.
## REQ-033 — Partner settlement: per-booking commission invoices + invoice `total` — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-033 — Partner settlement: per-booking commission invoices + invoice `total` — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the merchant-of-record settlement view lists per-booking **commission invoices** (b11 `Invoice` - **Need:** the merchant-of-record settlement view lists per-booking **commission invoices** (b11 `Invoice`
@@ -534,7 +539,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
Proposed: serve `totalIrr` on the invoice (= commission + bnplCommission + vat), plus a center-scoped list. Proposed: serve `totalIrr` on the invoice (= commission + bnplCommission + vat), plus a center-scoped list.
- **Why:** the settlement/invoice view (rendered only when `is_merchant_of_record`) needs a reconciling total. - **Why:** the settlement/invoice view (rendered only when `is_merchant_of_record`) needs a reconciling total.
VAT stays on the commission line only; the rate is config-driven (`vat_rate`), never hardcoded. VAT stays on the commission line only; the rate is config-driven (`vat_rate`), never hardcoded.
- **Status:** open - **Status:** partially delivered in refinement-phase-3 — `totalIrr` (= platform commission + BNPL commission + VAT) added to `InvoiceDto`. **DEFERRED:** the center-scoped invoice list (depends on the partner portal split reads, REQ-032).
## REQ-034 — Verification admin: nurse-level queue + on-demand document URL + whole-verification approve/reject — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-034 — Verification admin: nurse-level queue + on-demand document URL + whole-verification approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** three gaps in the b6 admin surface for the f15 review queue: (1) `GET admin_verifications` returns - **Need:** three gaps in the b6 admin surface for the f15 review queue: (1) `GET admin_verifications` returns
@@ -547,7 +552,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
Reject action. `services/verification` is mock-primary. Reject action. `services/verification` is mock-primary.
- **Why:** the queue + per-nurse case + signed-URL document viewer render these. The client never writes - **Why:** the queue + per-nurse case + signed-URL document viewer render these. The client never writes
`is_verified` — the server flips it transactionally (§5). `is_verified` — the server flips it transactionally (§5).
- **Status:** open - **Status:** deferred in refinement-phase-3 — verification admin polish (nurse-grouped queue, on-demand signed document URL, explicit whole-verification approve/reject). The per-step admin surface + the transactional `is_verified` flip already exist (b6); these are ergonomic refinements to the admin queue, not on the frontend acceptance path.
## REQ-035 — Refund preview + explicit approve/reject — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-035 — Refund preview + explicit approve/reject — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** b11 `POST admin_refunds` **creates and executes** in one call, so there is no way to render a - **Need:** b11 `POST admin_refunds` **creates and executes** in one call, so there is no way to render a
@@ -558,7 +563,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
(today the single POST both creates + executes). `services/refunds` admin methods are mock-primary; (today the single POST both creates + executes). `services/refunds` admin methods are mock-primary;
`initiateRefund` maps the live `POST admin_refunds`. `initiateRefund` maps the live `POST admin_refunds`.
- **Why:** the ticket-linked refund panel shows the preview, then initiate → (retry on provider failure) / reject. - **Why:** the ticket-linked refund panel shows the preview, then initiate → (retry on provider failure) / reject.
- **Status:** open - **Status:** deferred in refinement-phase-3 — refund admin preview + explicit approve/reject (the single `POST admin_refunds` creates+executes today). The customer preview (REQ-020) IS delivered and serves the same decomposition; the admin-side preview/approve/reject split is admin-console tooling.
## REQ-036 — Payout single preview endpoint + `holidayShifted` flag + record-transfer-reference — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-036 — Payout single preview endpoint + `holidayShifted` flag + record-transfer-reference — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the f15 payout dashboard wants (1) a **single preview** call returning eligible + skipped + the - **Need:** the f15 payout dashboard wants (1) a **single preview** call returning eligible + skipped + the
@@ -571,7 +576,7 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
(b13 has `mark_failed` but no reconcile-reference write). `services/payouts` admin methods are mock-primary; the (b13 has `mark_failed` but no reconcile-reference write). `services/payouts` admin methods are mock-primary; the
run/retry map the live routes with the `Idempotency-Key` header. run/retry map the live routes with the `Idempotency-Key` header.
- **Why:** preview → run (idempotency-keyed, no double-pay) → detail with per-nurse retry + transfer-ref reconcile. - **Why:** preview → run (idempotency-keyed, no double-pay) → detail with per-nurse retry + transfer-ref reconcile.
- **Status:** open - **Status:** deferred in refinement-phase-3 — payout admin single-preview endpoint + `holidayShifted` flag + record-transfer-reference route. The eligible/skipped data is already returned by the batch generate/`GET admin_payouts/eligible`; a consolidated dry-run preview + reconcile-reference write are admin-console refinements.
## REQ-037 — Moderation queue `tagCodes` on `ModerationQueueItemDto` — filed by frontend-phase-15-b15 — 2026-07-10 ## REQ-037 — Moderation queue `tagCodes` on `ModerationQueueItemDto` — filed by frontend-phase-15-b15 — 2026-07-10
- **Need:** the f15 moderation queue renders each review's tag chips, but `ModerationQueueItemDto` (b14) doesn't - **Need:** the f15 moderation queue renders each review's tag chips, but `ModerationQueueItemDto` (b14) doesn't
@@ -579,4 +584,20 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
DTO. The client defaults to `[]` meanwhile. `services/reviews` moderation methods are mock-primary; `moderateReview` DTO. The client defaults to `[]` meanwhile. `services/reviews` moderation methods are mock-primary; `moderateReview`
maps the live `PATCH reviews/{id}/status` and the queue maps `GET admin/reviews/moderation_queue`. maps the live `PATCH reviews/{id}/status` and the queue maps `GET admin/reviews/moderation_queue`.
- **Why:** moderators see the tags a review carries before publishing/hiding. - **Why:** moderators see the tags a review carries before publishing/hiding.
- **Status:** delivered in refinement-phase-3
## REQ-038 — Signal on `/me` that the caller administers a partner center (partner auto-routing) — filed by refinement-phase-2 — 2026-07-13
- **Need:** add a boolean/id on `MeResult` — e.g. `administersPartnerCenterId: number | null` (or a plain
`isPartnerCenterAdmin: bool`) — indicating the signed-in user is the admin of a `partner_center`.
- **Why:** the partner portal (`/partner`) is a **separate authz scope** — a center admin is not a Balinyaar
admin, and partner-ness is **not** derivable from `me.roles`. So `resolveRoleDestination` (the login role
router) can't route a partner admin to `/partner` on login the way it routes customer/nurse/admin. Today
`/partner` is only reachable by **direct navigation** (in dev the `services/partnerCenter` mock resolves a
center for `useMyPartnerCenter`, so the shell renders instead of access-denied); there is no seeded real
partner-center↔user association and no `/me`-level signal, so login→`/partner` can't be delivered. With this
field the router gains a partner branch and the demo seed can associate a phone user with a seeded center.
- **Proposed shape:** `MeResult.administersPartnerCenterId?: number | null`; when non-null,
`resolveRoleDestination` routes to `ROUTES.PARTNER`. Pair with a Development seed (a partner-center + a
`demo_partner_*` phone user linked as its admin) so the actor is reachable end-to-end like the others.
- **Status:** open (partner login-routing deferred; `/partner` reachable by direct nav + the partnerCenter mock)
- **Status:** open - **Status:** open
@@ -146,7 +146,7 @@ A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
- **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en - **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en
`cta_pay` arrow points → (fa keeps ←); fa `error_body` matches the app's «بارگذاری … ممکن نشد» pattern; `cta_pay` arrow points → (fa keeps ←); fa `error_body` matches the app's «بارگذاری … ممکن نشد» pattern;
the C6 service-cost row carries the quantity (`row_service_cost_with_count`); the invoice issuer line the C6 service-cost row carries the quantity (`row_service_cost_with_count`); the invoice issuer line
uses the product spelling «بالین‌یار» (note: fa `common.brand` reads «بلینیار» — a pre-existing uses the product spelling «بالین‌یار» (note: fa `common.brand` reads «بالین یار» — a pre-existing
wordmark/product-spelling divergence worth a product decision). wordmark/product-spelling divergence worth a product decision).
- **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background* - **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background*
and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to
@@ -5,41 +5,71 @@ exact steps to make each one real. Backend lane owns this file; every phase that
seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to seam updates its row. This is the checklist the team works through to go from "MVP with mocks" to
"production with real providers". "production with real providers".
Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live. Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢 real integration live · 🟢◐ **real
adapter shipped, config-selected (mock remains the default/fallback)** — the refinement-phase-8 state.
> **Refinement-phase-9 — docs honesty + observability (2026-07-13).** §7.6: pruned the **stale duplicate 🔴 rows**
> (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/
> `ILicenseVerificationService`) that the detailed rows below already correct; the recurring-jobs row is the real
> in-process scheduler; `IPaymentCaptureSimulator` now reflects its 6.4 prod removal. **No seam was un-mocked** — the
> deferred **Elasticsearch `INurseSearch` backend** (row) and the **SMS/push channels of `INotificationDispatcher`**
> (row) stay explicitly out of MVP with the pull-triggers in their "Make it real →" columns (SQL search / in-app
> notifications are the real MVP). Also removed the unused `Serilog.Sinks.Elasticsearch` package (dead sink block);
> tracing/metrics unified on OpenTelemetry; audit-log retention is now a scheduled `IRecurringJob`; `TicketMessage.Body`
> is encrypted at rest (§9.5). See `backend-phase-9-report.md` (refinement) for the full observability + deferral list.
> **Refinement-phase-8 — external rails go real (2026-07-13).** Every vendor rail below now has a **real HTTP
> adapter behind the same seam**, config-selected via a per-rail **`Seams:*:Provider`** selector (default = the
> mock, so an unconfigured environment is unchanged; a typo falls closed to the mock). Selecting a real provider
> swaps the adapter by a **registration change only — no handler changed**. All adapters are built on
> `HttpClient` + `System.Text.Json` + BCL crypto (**zero new NuGet packages**); credentials come from `Seams:*`
> (user-secrets/env). `dotnet build` 0 new warnings · `dotnet test` **402 pass**. The rails made real, with their
> provider token and adapter (`CrossCutting/Seams/Real/`):
>
> | Seam | `Seams:*:Provider` | Real adapter | Notes |
> | --- | --- | --- | --- |
> | `ISmsSender` | `Sms:Provider=kavenegar` | `KavenegarSmsSender` | **launch-critical.** OTP via verify/lookup template; the Dev OTP-in-logs bridge is **disabled** when a real provider is selected (OTP never logged). |
> | `IShahkarVerifier` | `Shahkar:Provider=finnotech` | `FinnotechShahkarVerifier` | shared `Seams:Finnotech` creds; شاهکار can't distinguish shared-SIM from mismatch (reported as plain mismatch). |
> | `IIdentityKycProvider` | `IdentityKyc:Provider=finnotech` | `FinnotechIdentityKycProvider` | nid+name inquiry; liveness extends the same adapter. |
> | `IBankAccountOwnershipVerifier` | `BankOwnership:Provider=finnotech` | `FinnotechBankAccountOwnershipVerifier` | استعلام شبا owner↔nid; fails closed (no nid returned = no match) — it is the first-payout gate. |
> | `IGeocoder` | `Geocoding:Provider=neshan` | `NeshanGeocoder` | outage degrades to the null-pin state, never blocks saving an address. |
> | `IObjectStorage` | `ObjectStorage:Provider=s3` | `S3ObjectStorage` | MinIO/S3/ArvanCloud; **manual AWS SigV4** (no SDK) — presigned GET = the real b6 signed-URL contract. |
> | `IPaymentProvider` | `Payments:Provider=zarinpal` | `ZarinPalPaymentProvider` | v4 request/verify/refund; mandatory server-side verify. |
> | `IWebhookVerifier` | (with `Payments:Provider`) | `HmacWebhookVerifier` | per-provider HMAC over the raw body; no-secret ⇒ the server-side verify re-check is the guard. |
> | `ISettlementSplitProvider` | (with `Payments:Provider`) | `ProviderSettlementSplitProvider` | تسهیم split-by-ratio to registered IBANs. |
> | `IBnplProvider`/`IBnplProviderResolver` | `Bnpl:Provider=real` | `SnappPayBnplProvider` + `DigipayBnplProvider` + `ConfiguredBnplProviderResolver` | one adapter per code; **`balinyaar` = in-house (no external API), resolves to the net-of-fee model**; `tara`/`torobpay` → null (unbuilt). |
> | `ICurrencyNormalizer` | `Currency:TomanToIrrMultiplier` | `MockCurrencyNormalizer` (config-driven = **the real impl**) | conversion only at the adapter boundary. |
> | `IBankTransferProvider` | `BankTransfer:Provider=jibit` | `JibitBankTransferProvider` | **async rail** — accepts as `submitted`; the reconciliation callback (`POST webhooks/payouts/{provider}`, `ReconcilePayoutBatchCommand`, HMAC-verified) flips `submitted → paid/failed`. |
> | `IMoadianClient` | `Moadian:Provider=moadian` | `MoadianClient` | submit + the `MoadianReconciliationJob` (`IRecurringJob`, 6 h) walks `pending/submitted → registered`. |
> | `IPaymentCaptureSimulator` | — | `DisabledPaymentCaptureSimulator` (prod) / `MockPaymentCaptureSimulator` (Dev/Testing) | **6.4:** removed from prod; the `bookings/convert` path is a Dev/Testing affordance (b10's webhook confirm is the real conversion). |
> | `ICredentialVerifier`, `ILicenseVerificationService` | — | mock (unchanged) | **5.6: manual = intended MVP** — MoH/INO/eNamad have no public B2B API; the manual admin review is the mechanism, not debt. |
| Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status | | Seam (interface) | Introduced in | What it fakes | Config keys | Make it real → | Status |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 | | `ISmsSender` | backend-phase-2 | OTP/SMS delivery — `LoggingSmsSender` (`Baya.Infrastructure.CrossCutting/Seams/`) logs the OTP code (phone shown as last-4 only) and returns success; registered singleton in `AddCrossCuttingSeams`. **refinement-phase-0:** in **Development only**, `DevCapturingSmsSender` decorates it (via `AddDevelopmentOtpCapture`, called from `Program.cs` inside `IsDevelopment()`) to also capture the code in `DevOtpStore` for the `GET /api/v1/dev/last_otp/{phone}` bring-up helper — not wired / 404 outside Development | none today; real client will need `Seams:Sms:ApiKey` + `Seams:Sms:SenderLine` (+ gateway base URL) | 1) pick a gateway (Kavenegar/Ghasedak/SMS.ir), add its client package to `Directory.Packages.props`; 2) implement `ISmsSender.SendOtpAsync`/`SendAsync` against it (template/pattern-based OTP send); 3) bind the new `Seams:Sms` options; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 5) keep the per-phone resend window + `otp` rate-limit policy exactly as-is; test with a real SIM | 🟡 |
| `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 | | `IObjectStorage` | backend-phase-0/6 | File storage — local-disk store under a scratch root (`LocalDiskObjectStorage`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:ObjectStorage:RootPath` (default: temp dir) | Point at MinIO/S3/ArvanCloud; presigned upload/download; bucket + creds | 🟡 |
| `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme | 🟡 | | `ICacheService` | backend-phase-0 | Caching — in-memory `IMemoryCache` (`MemoryCacheService`, `Baya.Infrastructure.CrossCutting/Seams/`) | _none_ | Swap to Redis (`StackExchange.Redis`); keep key/TTL scheme. **refinement-phase-7: this is the >1-instance scale-out gate** — a single-instance MVP intentionally keeps the in-proc cache (its generation-token invalidation is process-local); add Redis only when a second API instance runs | 🟡 (in-proc is correct single-instance) |
| `IDistributedLock` | backend-phase-10 | Money-path locks — no-op/in-proc | _tbd_ | Redis lock (RedLock); DB constraint remains the backstop | 🔴 |
| `INurseSearch` | backend-phase-7 | Search — SQL over `nurse_search_index` | _tbd_ | Elasticsearch index + feeder; reimplement the interface | 🔴 |
| `IPaymentProvider` | backend-phase-10 | Card PSP/IPG — deterministic success | _tbd_ | ZarinPal/Sadad/Vandar/Jibit + Shaparak; merchant/terminal/تسهیم | 🔴 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم split — accepts any balanced legs | _tbd_ | Provider split-by-ratio to registered Shebas | 🔴 |
| `IWebhookVerifier` | backend-phase-10 | Callback auth — always valid | _tbd_ | Per-provider HMAC/signature + server-side re-verify | 🔴 |
| `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 | | `IBnplProvider` | backend-phase-12 | BNPL — `MockBnplProvider` drives the full state machine (eligible→settled→reverted), settle returns `order commission%` | `Seams:Bnpl:{CommissionRate,SettlementInstant,CreditCeilingIrr,NotEligibleMobile,ForceFailure,ReverseProviderCommission}` | SnappPay/Digipay OAuth + verb set; encrypted creds in `payment_gateways.config_json` | 🟡 |
| `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 | | `IBnplProviderResolver` | backend-phase-12 | Per-`provider_code` selection — maps every known code to the one mock | _none_ | One concrete adapter per code; resolver returns the right one | 🟡 |
| `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 | | `ICurrencyNormalizer` | backend-phase-12 | Toman↔IRR — ×10 at the boundary | `Seams:Currency:TomanToIrrMultiplier` (default `10`) | Config-driven per provider boundary | 🟡 |
| `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout rail — `MockBankTransferProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call, no money moves**: `SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference` and settles every row `Paid` (collapsing the real `submitted → paid` reconciliation); it **honours** the PAYA/SATNA `method` the handler chose by the `payout_satna_threshold_irr` config and echoes it. A config switch forces deterministic failures so `partially_failed`/retry are testable: `ForceFailure` fails the whole batch, `FailIban` fails one destination. `GetPayoutStatusAsync` echoes `Paid`. Registered singleton in `AddCrossCuttingSeams` | `Seams:BankTransfer:ForceFailure` (default `false`), `Seams:BankTransfer:FailIban` (default empty) | 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to `Directory.Packages.props`; 2) add `Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}`; 3) implement `SubmitPayoutBatchAsync` to register the batch against the registered **source settlement account** and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's **verified Sheba** (the b3 `matched_national_id` gate), honouring batch caps/minimums; 4) implement the async **reconciliation callback** that flips a payout `submitted → paid/failed` (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + `nurse_payout_booking_links` UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry | 🟡 | | `IBankTransferProvider` | backend-phase-13 | PAYA/SATNA payout rail — `MockBankTransferProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call, no money moves**: `SubmitPayoutBatchAsync(batchId, instructions, idempotencyKey)` returns a deterministic `externalBatchRef` + a per-instruction `transfer_reference` and settles every row `Paid` (collapsing the real `submitted → paid` reconciliation); it **honours** the PAYA/SATNA `method` the handler chose by the `payout_satna_threshold_irr` config and echoes it. A config switch forces deterministic failures so `partially_failed`/retry are testable: `ForceFailure` fails the whole batch, `FailIban` fails one destination. `GetPayoutStatusAsync` echoes `Paid`. Registered singleton in `AddCrossCuttingSeams` | `Seams:BankTransfer:ForceFailure` (default `false`), `Seams:BankTransfer:FailIban` (default empty) | 1) pick a transferor (Jibit/Vandar/Sadad payout API), add its client package to `Directory.Packages.props`; 2) add `Seams:BankTransfer:{ApiKey,BaseUrl,SourceSettlementAccount}`; 3) implement `SubmitPayoutBatchAsync` to register the batch against the registered **source settlement account** and route each transfer PAYA (batch, low-value) vs SATNA (real-time, above the threshold) to each nurse's **verified Sheba** (the b3 `matched_national_id` gate), honouring batch caps/minimums; 4) implement the async **reconciliation callback** that flips a payout `submitted → paid/failed` (the mock collapses this — the real rail is async); 5) swap the registration (config-selected) — the payout status machine + `nurse_payout_booking_links` UNIQUE remain the irreversible-transfer backstop; 6) test PAYA/SATNA selection, whole-batch + single-row failure → retry | 🟡 |
| `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 | | `IHolidayCalendar` | backend-phase-1 | Bank holidays — reads the seeded `ops.IranianHolidays` table; lookups cached (`HolidayCalendarService`, `Persistence/Services/Holidays/`); Iranian banking weekend = Friday | _none_ | Add a sync job/feed that maintains the (partly lunar-Hijri) calendar table; the read interface stays | 🟡 |
| `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 | | `IAnalyticsSink` | backend-phase-1 | Behavioural events — inserts an `ops.SystemEvents` row, fire-and-forget (`AnalyticsSink`, `Persistence/Services/Analytics/`) | _none_ | Pipe to a warehouse/stream (e.g. Kafka→ClickHouse); keep fire-and-forget semantics | 🟡 |
| `IJobScheduler` (retention + booking expiry) | backend-phase-1 | Scheduling — in-process interval `BackgroundService`s: `PurgeOldReadNotifications` daily (`NotificationRetentionHostedService`, `Persistence/Services/Notifications/`) and **b8** `BookingRequestExpiryHostedService` (`Persistence/Services/Booking/`) running the idempotent booking-request expiry sweep every minute | _none_ | Swap to Hangfire/Quartz; register **both** jobs there; keep the purge predicate (`is_read=1 AND age>90d`) and the booking-expiry command | 🟡 | | Recurring jobs (in-process scheduler) | backend-phase-1 · **re-homed refinement-phase-7** | Scheduling — **REAL** single in-process scheduler `RecurringJobSchedulerHostedService` (`Persistence/Services/Scheduling/`) drives every `IRecurringJob` on its own (config-read) cadence: `notification_retention` (24 h) + `booking_request_expiry` (1 min) + `verification_expiry_scan` (`verification_expiry_scan_cadence_hours`) + `no_show_sweep` (`no_show_scan_cadence_hours`) + `weekly_payout_generation` (`nurse_payout_interval_days`, system-initiated draft only — processing stays admin). Each tick runs under `IDistributedLock(scheduler:{name})`; dormant under `Testing`. Admin manual triggers remain overrides. **No new infra** (SQL Server only). | the cadence keys above (via `IPlatformConfig`) | This is the intended MVP shape — **not** debt. Hangfire/Quartz only buys durable/cross-restart scheduling and is the >1-instance option alongside Redis (§7.2) — swap by re-registering the jobs behind it. **Phase 8** adds the Moadian reconciliation + refund-settlement poll as new `IRecurringJob`s here. | 🟢 real (single-instance) |
| `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 | | `IShahkarVerifier` | backend-phase-6 | شاهکار phone↔national-id binding — `MockShahkarVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic result + fake vendor ref + `external_response_json`: matches every pair except the configured shared-SIM phone (→ the explicit shared-SIM failure state, which the handler turns into a `shared_sim` support alert) and the mismatch national id (→ plain mismatch); registered singleton in `AddCrossCuttingSeams`. No real Shahkar call | `Seams:Shahkar:SharedSimPhone` (default `09120000000`), `Seams:Shahkar:MismatchNationalId` (default `1111111111`) | 1) pick a Finnotech / KYC Shahkar-bridge vendor, add its client package to `Directory.Packages.props`; 2) add `Seams:Shahkar:{ApiKey,BaseUrl}` options; 3) implement `MatchAsync(phone, nationalId)` against the real استعلام شاهکار, mapping to `ShahkarMatchResult` and persisting the raw response into the step's `external_response_json`; 4) keep shared-SIM as the explicit handled failure (`IsSharedSim=true`); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match / shared-SIM / mismatch + that a phone change re-runs it (`shahkar_verified_at` resets upstream on phone change) | 🟡 |
| `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 | | `IIdentityKycProvider` | backend-phase-6 | Identity KYC (national-id validity + name match + liveness) — `MockIdentityKycProvider` (`.../Seams/`) passes any well-formed 10-digit national id except the configured fail id, returning a matched name + fake vendor ref + `external_response_json`; on pass the handler populates `users.national_id` + `national_id_verified_at`. No real OCR/liveness; registered singleton | `Seams:IdentityKyc:FailNationalId` (default `0000000000`), `Seams:IdentityKyc:MatchedName` (default `Verified Nurse`) | 1) pick an Iranian e-KYC vendor (Finnotech / U-ID / Jibbit / Farashensa / Verify / Kavoshak), add its client package to `Directory.Packages.props`; 2) add `Seams:IdentityKyc:{ApiKey,BaseUrl}` options; 3) implement `VerifyAsync(nationalId, livenessPayload)` → national-id validity + name match + photo/video liveness against ثبت احوال, mapping to `IdentityKycResult` and persisting `external_response_json`; 4) swap the registration (config-selected) — handlers unchanged; 5) test pass/fail by national id + that `national_id` is populated **only** on pass | 🟡 |
| `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 | | `ICredentialVerifier` | backend-phase-6 | MoH پروانه صلاحیت حرفه‌ای / INO / عدم سوء پیشینه verification — `MockCredentialVerifier` (`.../Seams/`) **is** the manual-admin default: every call returns `RequiresManualReview` with `verification_method=manual` (an admin verifies the uploaded document against the official portal in `AdminReviewStep`). No portal call; registered singleton. There is **no public B2B API** for MoH/INO, so this stays manual until one appears | _none_ | 1) when an MoH/INO portal or API becomes available, implement `VerifyAsync(credentialType, credentialNumber)` to return `Verified`/`Failed` with `verification_method=portal|api` (+ `external_response_json`); 2) swap the registration (config-selected) for those credential types — the manual path stays the fallback; 3) the structured `nurse_credentials` registry already stores number/authority/expiry so cross-check + renewal survive the swap. **MoH/INO have no public B2B API today** | 🟡 |
| `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 | | `IBankAccountOwnershipVerifier` | backend-phase-3 | استعلام شبا IBAN-owner ↔ national-id inquiry — `MockBankAccountOwnershipVerifier` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic fake: every IBAN matches (`matched_national_id=true`, echoes a holder name + `MOCK-SHEBA-{sha}` vendor ref) except the configured mismatch IBAN which returns `false`; registered singleton in `AddCrossCuttingSeams`. No real bank/KYC call, no money moves | `Seams:BankOwnership:MismatchIban` (default `IR000000000000000000000000`), `Seams:BankOwnership:MatchedHolderName`, `Seams:BankOwnership:MismatchHolderName` | 1) pick a Finnotech / banking-bridge استعلام شبا provider, add its client package to `Directory.Packages.props`; 2) add `Seams:BankOwnership:{ApiKey,BaseUrl}` options; 3) implement `VerifyOwnershipAsync(iban, nurseNationalId)` against the real Sheba-owner inquiry, mapping to `OwnershipInquiryResult`; 4) persist the real `ownership_vendor_ref` (+ raw response if a column is added); 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test match/mismatch + that the b13 first-payout gate honours `matched_national_id=true` | 🟡 |
| `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 | | `IGeocoder` | backend-phase-4 | Address→lat/lng — `MockGeocoder` (`Baya.Infrastructure.CrossCutting/Seams/`) returns deterministic `decimal` coordinates jittered (FNV-1a, ~±5 km) around the known city centroid (unknown city → Iran centroid) plus `formatted_address` + `confidence`; **no network call**. A global switch or a per-address marker forces the null-coordinate ("no map pin") path; registered singleton in `AddCrossCuttingSeams` | `Seams:Geocoding:ReturnNullCoordinates` (default `false`), `Seams:Geocoding:LowConfidenceMarker` (default `NO_GEO`), `Seams:Geocoding:ResolvedConfidence` (default `0.9`) | 1) pick Neshan (or Google) geocoding, add its client package to `Directory.Packages.props`; 2) add `Seams:Geocoding:{ApiKey,BaseUrl}` options; 3) implement `IGeocoder.GeocodeAsync(addressText, cityName, districtName?)` against it, mapping to `(lat, lng, formatted_address, confidence)` with `decimal` coords; 4) add rate-limit/retry; 5) swap the registration in `AddCrossCuttingSeams` (config-selected) — handlers unchanged; 6) test a known Tehran address resolves within expected bounds | 🟡 |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoice — leaves ref pending | _tbd_ | Real مودیان submission → 22-digit ref | 🔴 |
| `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 | | `IReviewModerationService` | backend-phase-14 | AI review pre-screen — `MockReviewModerationService` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `ScreenAsync(reviewText)` returns a `ModerationVerdict(Decision, Reason)` — a banned-word substring hit → `Reject` (`banned_word:{w}`); otherwise clean text → a human-review `Flag` by default (so the publish gate holds), or `Approve` when `AutoApproveClean` is set. The `SubmitReview` handler maps the verdict to the initial status (`Approve`→published, `Reject`→hidden, else pending) — **decision authority stays with `ModerateReviewCommand` (human override)**. Registered singleton in `AddCrossCuttingSeams` | `Seams:ReviewModeration:AutoApproveClean` (default `false`), `Seams:ReviewModeration:BannedWords` (default `scam,fraud,کلاهبردار`) | 1) pick a text classifier / LLM moderation endpoint, add its client package to `Directory.Packages.props`; 2) add `Seams:ReviewModeration:{ApiKey,BaseUrl}` options; 3) implement `ScreenAsync(reviewText)` → map the provider's toxicity/spam scores to `Approve`/`Flag`/`Reject` + a reason; 4) swap the registration in `AddCrossCuttingSeams` (config-selected) — `SubmitReviewCommand`/`ModerateReviewCommand` unchanged, and the human moderation path always overrides; 5) test clean/flagged/rejected dispositions + that the publish gate still holds for a `Flag` | 🟡 |
| `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 | | `IFieldEncryptor` | backend-phase-0 | PII encryption — AES-256-CBC + HMAC hash from a local symmetric key (`SymmetricFieldEncryptor`, `Baya.Infrastructure.CrossCutting/Seams/`) | `Seams:FieldEncryption:Key`, `Seams:FieldEncryption:HashKey` | KMS / column encryption / Key Vault / HSM | 🟡 |
| `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 | | `INotificationDispatcher` | backend-phase-0/**1** | Notification channels — **in-app write is now real** (`InAppNotificationDispatcher`, `Persistence/Services/Notifications/`, writes an `ops.Notifications` row); b0 log stub removed. SMS/push channels still deferred (no-op) behind the same seam | _none_ | Add SMS (`ISmsSender`) / push (FCM) channels; polling → Redis pub/sub or SignalR later | 🟡 |
| `ILicenseVerificationService` | backend-phase-15 | eNamad / MoH establishment-permit — manual approve | _tbd_ | Real registry/API | 🔴 | | `IPaymentCaptureSimulator` | backend-phase-9 → **removed from prod refinement-phase-8 §6.4** | The **temporary conversion trigger** that stood in for b10's real card capture. **Prod now gets the fail-closed `DisabledPaymentCaptureSimulator`**; only Dev/Testing re-register the succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts via the b10 webhook confirm calling `ConvertRequestToBooking` directly). Registered in `AddCrossCuttingSeams` (prod) + re-registered in Dev/Testing from `Program.cs` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | Nothing further for prod — the real conversion trigger is the b10 webhook confirm. The Dev/Testing mock stays as a test affordance; drop it only if/when `bookings/convert` is retired | 🟢 (prod fail-closed; Dev/Testing mock is a test affordance) |
| `IPaymentCaptureSimulator` | backend-phase-9 | The **temporary conversion trigger** standing in for b10's real card capture. `MockPaymentCaptureSimulator` (`Baya.Infrastructure.CrossCutting/Seams/`) returns a deterministic *succeeded* capture (a fake `gateway_reference` + a configurable `psp_fee_amount`) so `ConvertRequestToBookingCommand` is exercisable now; a config switch forces a *failed* capture (→ no booking is created). **This is the trigger, not a parallel money path** — registered singleton in `AddCrossCuttingSeams` | `Seams:PaymentCapture:ForceFailure` (default `false`), `Seams:PaymentCapture:PspFeeAmount` (default unset) | In b10: 1) build the real card capture (`payment_transactions`, PSP/IPG client, webhook verify); 2) on a real `payment_transactions.succeeded`, call `ConvertRequestToBooking` **directly** (the same conversion command that computes the three-amount split + generates sessions) instead of this seam; 3) remove the `IPaymentCaptureSimulator` registration + `MockPaymentCaptureSimulator`; the conversion/idempotency logic is unchanged | 🟡 |
| `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 | | `INurseSearch` | backend-phase-7 | The search-service seam (read side). **The MVP impl `SqlNurseSearch` (`Persistence/Services/Search/`) is REAL, not a mock** — it reads the maintained `nurse_search_index WHERE is_searchable=1`, applies the category/city/district (NULL=whole-city)/gender/price filters + rating sort + pagination, projected & `AsNoTracking`. Registered by `AddPersistenceServices`, config-selected. Only the DEFERRED Elasticsearch backend is unbuilt | `Search:Backend` (default `sql`; any other value throws until Elastic ships) | 1) add an Elasticsearch client package (`Elastic.Clients.Elasticsearch`) to `Directory.Packages.props`; 2) define the index mapping (the `NurseSearchResultDto` fields + `is_searchable`); 3) implement `ElasticNurseSearch : INurseSearch` (same filters/sort/paging) reading the ES index; 4) build the feeder that consumes the `ISearchIndexMaintainer` change events via an **outbox/CDC** stream into ES (see the next row); 5) point `Search:Backend=elastic` in config — **callers unchanged**; 6) keep the SQL index as the projection/fallback + the reconciliation source (`RebuildAsync`); 7) test filter/sort/paging parity vs `SqlNurseSearch` | 🟢 SQL real; Elastic 🟡 |
| `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 | | `IPaymentProvider` | backend-phase-10 | Card PSP acquirer — `MockPaymentProvider` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `InitPaymentAsync` → a deterministic `gatewayReferenceCode` (`mock-ref-{requestId}-{key}`) + a fake redirect URL; `VerifyAsync` → instant `Succeeded` echoing the expected amount (the server-side re-check); `RefundAsync(ref, amount, idempotencyKey, ct)` → always `Succeeded`, echoes a deterministic refund ref (b11 refunds carry the booking+refund idempotency key so a retry never double-refunds). Registered singleton in `AddCrossCuttingSeams` | none today; real client needs merchant id + terminal/IBAN registration + sandbox flag from `payment_gateways.config_json` (encrypted), not appsettings | 1) pick ZarinPal/Sadad/Vandar/Jibit as an acquirer-with-تسهیم, add its client package to `Directory.Packages.props`; 2) implement `InitPaymentAsync` (open the IPG session, return the Shaparak-routed redirect + reference), `VerifyAsync` (the mandatory server-side `verify` re-check of amount + reference — **never trust the callback alone**), `RefundAsync`; 3) read merchant id/terminal from the encrypted `payment_gateways.config_json`; 4) a config-driven `IProviderRegistry`/factory selects the concrete provider per gateway so a cut-off provider swaps without code change; 5) persist the full gateway response into `gateway_response_json`; 6) swap the registration (config-selected) — handlers unchanged | 🟡 |
| `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 | | `ISettlementSplitProvider` | backend-phase-10 | تسهیم settlement-sharing — `MockSettlementSplitProvider` (`.../Seams/`) records the split intent and returns `Settled` for any legs whose sum is positive; the platform never moves money. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs each beneficiary's registered SHEBA + split-by-ratio config | 1) pick the acquirer's تسهیم API, implement `RegisterSplitAsync(bookingId, legs)` to register the split-by-ratio to each beneficiary's **registered IBAN** (nurse payout + platform commission), honouring the ~100,000 IRR min-amount caveat; 2) resolve each nurse's SHEBA from `nurse_bank_accounts` (the b3 `matched_national_id` gate) and the platform SHEBA from config; 3) `GetSplitStatusAsync` polls the provider; the provider credits IBANs directly — the ledger only mirrors it; 4) swap the registration (config-selected) | 🟡 |
| `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 | | `IWebhookVerifier` | backend-phase-10 | PSP callback signature verify — `MockWebhookVerifier` (`.../Seams/`) treats the signature as valid unless the body carries `Seams:Payments:InvalidSignatureMarker`, and extracts `external_event_id`/`event_type`/`gateway_reference_code` from a small JSON body (so tests can replay duplicates + exercise the invalid-signature path). Registered singleton in `AddCrossCuttingSeams` | `Seams:Payments:InvalidSignatureMarker` (default `INVALID_SIGNATURE`) | 1) implement the per-provider HMAC/signature scheme (verify the raw body against the provider's signing key from the gateway config); 2) where a provider offers no signature, fall back to the mandatory server-side `verify` re-check (amount + reference) via `IPaymentProvider.VerifyAsync`; 3) parse the real provider event shape into `WebhookVerification`; 4) swap the registration (config-selected) — the `HandlePaymentWebhook` upsert-first/no-op-on-duplicate ordering is unchanged | 🟡 |
| `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired | 🟡 | | `IDistributedLock` | backend-phase-10 | Money-path mutex — `InProcessDistributedLock` (`.../Seams/`): a per-key `SemaphoreSlim` so the capture path runs the same acquire/release shape it will with real Redis, **within one process only**. **Not** a cross-instance correctness guarantee — the DB uniques/state-machine are the authoritative backstop. Registered singleton in `AddCrossCuttingSeams` | none today; real client needs a Redis connection string | 1) add `StackExchange.Redis` to `Directory.Packages.props`; 2) implement `AcquireAsync(key)` with a lease/expiry (RedLock-style SET NX PX + a token-checked release), key convention `booking:{id}:payment`; 3) bind `Seams:Payments:Redis` (or reuse the `ICacheService` Redis swap); 4) swap the registration (config-selected) — handlers unchanged, and correctness still rests on the DB uniques if Redis is down/expired. **refinement-phase-7: also the scheduler's per-tick lock** (`scheduler:{job}`) uses this seam — once Redis-backed it serializes recurring-job ticks across instances (idempotency + DB uniques cover a double-run either way). Required only for >1 instance. | 🟡 (in-proc is correct single-instance) |
| `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) | | `ISearchIndexMaintainer` (the "`ISearchIndexWriter`" event shape) | backend-phase-7 | The index-maintenance seam (write side). **The inline SQL path is REAL**`SearchIndexMaintainer` (`Persistence/Services/Search/`) re-derives `nurse_search_index` from source and **stages** it inside the owning source write's unit of work (single `CommitAsync`), invoked from the b3/b4/b5/b6 handlers (`ReindexVariantAsync`/`ReindexNurseAsync`/`FanOutServiceAreaAsync`/`RemoveServiceAreaRowsAsync`/`RebuildAsync`). Only the **outbox/queue routing** for an async Elastic feeder is deferred — the seam is shaped so the same change events can later be emitted to an outbox instead of an inline upsert | _none_ | 1) introduce an `outbox` table + a SaveChanges interceptor that captures each maintainer change as an event row in the same transaction; 2) a background feeder (Hangfire/Quartz or a hosted service) reads the outbox and applies to `ElasticNurseSearch`; 3) keep the inline SQL upsert as the projection/fallback so `RebuildAsync` stays the reconciliation path; 4) test that an outbox replay converges to the same rows as the inline path | 🟡 outbox deferred (inline real) |
| `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 | | `IMoadianClient` | backend-phase-11 | سامانه مودیان e-invoicing — `MockMoadianClient` (`Baya.Infrastructure.CrossCutting/Seams/`), **no external call**: `SubmitAsync` leaves a new invoice `moadian_status = pending` with `moadian_reference_number = null`; a config switch forces a deterministic `registered` result with a fake 22-digit reference so the reconciliation/registered path is testable. Registered singleton in `AddCrossCuttingSeams` | `Seams:Moadian:ForceRegistered` (default `false`) | 1) enroll the platform in سامانه مودیان (memory/economic code + signing certificate); 2) implement `SubmitAsync` to POST the معاملات/invoice (`صورتحساب`) to the مودیان API, sign the payload, map the 22-digit `reference_number`; 3) walk the async `pending → submitted → registered`/`failed` states via a reconciliation callback/poll (**cron deferred/manual today** — a job flips `moadian_status` + fills the ref); 4) swap the registration (config-selected) — the `IssueInvoice` handler is unchanged | 🟡 |
@@ -57,31 +87,51 @@ Status legend: 🔴 not built · 🟡 mocked (seam + fake impl in place) · 🟢
These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so These are in-browser mocks behind a `services/{domain}` interface, selected by a config flag. They exist so
the frontend can build before the backend phase merges, and swap to the real HTTP client in one line. the frontend can build before the backend phase merges, and swap to the real HTTP client in one line.
> **Refinement-phase-4 de-mock (2026-07-13) — 14 domains flipped to REAL** (`USE_*_MOCK = false`), verified
> against the regenerated swagger + `npm run check`/`test:ci` green: **geography, patients, profiles,
> nurse (bank), addresses, serviceAreas, catalog, search, bookingRequests, bookings, payment, reviews,
> tickets, notifications** (auth was already real). The flip was **not** a pure flag flip for most: Phase-3
> delivered fields the `clientApi.ts` mappers were written to null-override, so each mapper was updated to
> consume/send them (search name/avatar/distance + `nurses/{id}/profile`; patient relation/conditions;
> address `provinceId`; booking-request `variantPrice`/`bookingId`; ticket `unreadCount`/`lastMessageAt` +
> `clientMessageId`; review `my_review`; profile `avatarUrl`/`preferredLanguage` + a real **multipart avatar
> upload** — `clientFetch` now passes `FormData` through; the customer profile sources name from `/me`). The
> **payment mock-gateway harness page was deleted**; `EVV_GPS_MODE` auto-selects `off` (real geolocation).
>
> **7 domains stay 🟡 mocked — precondition REQ deferred/unsafe (documented, not forgotten):**
> `verification` (REQ-034 admin queue/doc-URL/approve — nurse flow ready, admin half blocks the shared flag),
> `refunds` (REQ-035 admin preview/approve — customer cancel/policy ready), `payouts` (REQ-036 admin
> preview/holidayShifted/transfer-ref — nurse earnings ready), `admin` (REQ-031 RBAC roles — config/audit/
> holidays/alerts ready), `bnpl` (REQ-022 options/schedule + REQ-024 wallet_installments deferred),
> `partnerCenter` (REQ-032/033 portal split reads + REQ-038 `/me` signal deferred), `patientRecords` (REQ-027
> endpoints exist but the client family-record `id` model is `string` vs the wire's `int` → the customer-edit
> PUT is write-unsafe until the id types are reconciled; the nurse visit-note history half is contract-real).
| Seam (interface) | File | What it fakes | Config flag | Make it real → | Status | | Seam (interface) | File | What it fakes | Config flag | Make it real → | Status |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false``patientsClientApi` is already wired to the b3 `patients/*` routes | 🟡 | | `PatientsApi` | `client/src/services/patients/apis/mockApi.ts` | In-memory patient CRUD (list/get/create/update/soft-archive), **seeded empty** so onboarding + the empty state both demo; persists the client-augmented `relation`/`conditions` the wire `PatientDto` lacks (REQ-005) | `USE_PATIENTS_MOCK` (`services/patients/constants.ts`, default `true`) | Deliver REQ-005 (relation/conditions on `PatientDto` + create/update), then set flag `false``patientsClientApi` is already wired to the b3 `patients/*` routes | 🟢 (real, refinement-phase-4) |
| `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false``profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟡 | | `ProfilesApi` | `client/src/services/profiles/apis/mockApi.ts` | Customer + nurse profile get/upsert and **avatar upload** (echoes an object-URL). Keeps guarded read-only fields (`isVerified=false`, zero aggregates). Augments customer name/language (REQ-007) + nurse `avatarUrl` (REQ-006) the wire DTOs lack | `USE_PROFILES_MOCK` (`services/profiles/constants.ts`, default `true`) | b3 `customer_profiles/*` + `nurse_profiles/*` are live; deliver REQ-006 (avatar route/field) + REQ-007 (customer name/language) then set flag `false``profilesClientApi` is wired (its `uploadAvatar` throws `501` until REQ-006) | 🟢 (real, refinement-phase-4) |
| `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false``nurseBankClientApi` is wired | 🟡 | | `NurseBankAccountsApi` | `client/src/services/nurse/apis/mockApi.ts` | Bank-account list/add/set-primary/verify-ownership. Drives the استعلام شبا **pending→verified/mismatch** transition over 2 list reads (so the poll shows it), single-primary enforcement, masked-IBAN (last-4); the configured mismatch IBAN (`IR000000000000000000000000`, matches backend default) resolves to `matchedNationalId=false` | `USE_NURSE_BANK_MOCK` (`services/nurse/constants.ts`, default `true`) | b3 `nurse_bank_accounts/*` are live (the real `add` resolves the inquiry synchronously — no client poll needed); set flag `false``nurseBankClientApi` is wired | 🟢 (real, refinement-phase-4) |
| `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available | | `AuthApi` | `client/src/services/auth/apis/mockApi.ts` (`authMockApi`) | Phone-OTP login offline: `requestOtp``{otpSent,resendAvailableInSeconds:120}`; `verifyOtp` accepts dev code **`123456`** and locks after 3 wrong tries (`otp_locked`); `getMe`/`selectRole`/`refresh` from a `MOCK_SCENARIO` toggle (`customer`/`nurse_unverified`/`no_role`) to exercise all router branches | `USE_AUTH_MOCK` (`services/auth/constants.ts`, default **false** — b2 is live) + `MOCK_SCENARIO` in `mockApi.ts` | The real `authClientApi` is already wired to the live b2 routes; set `USE_AUTH_MOCK = false` (already the default) — no hook/screen change | 🟢 real by default, 🟡 mock available |
| `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false``geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟡 | | `GeographyApi` | `client/src/services/geography/apis/mockApi.ts` (+ `apis/seed.ts`) | The province→city→district reference hierarchy — a faithful subset of the b4 seed: 8 provinces, Tehran (city 101) with its 22 مناطق (1001…1022), and the white-space cities Mashhad/Isfahan/Shiraz/Tabriz/Ahvaz/Qom/Karaj as whole-city-only. Active-only, `sortOrder`-ordered. `seed.ts` also resolves a saved `cityId`/`districtId` back to names for the addresses & serviceAreas mocks | `USE_GEOGRAPHY_MOCK` (`services/geography/constants.ts`, default `true`) | b4 `geo/{provinces,cities,districts}` are live; set flag `false``geographyClientApi` is wired to the snake_case-param lookups. No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false``addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟡 | | `AddressesApi` | `client/src/services/addresses/apis/mockApi.ts` | Customer address CRUD (list primary-first / create / update / set-primary / soft-delete) with the **exactly-one-primary** invariant enforced in-memory (first address auto-primary; promoting clears the prior; deleting the primary promotes the next). Persists the client-augmented `provinceId` (REQ-009) and the picked `latitude`/`longitude` (REQ-008) the wire DTO/create-body lack | `USE_ADDRESSES_MOCK` (`services/addresses/constants.ts`, default `true`) | b4 `customer_addresses/*` are live; deliver REQ-008 (accept the pin) + REQ-009 (`provinceId` on the DTO), then set flag `false``addressesClientApi` is wired (sends the pin + `pageSize`, echoes `provinceId` locally) | 🟢 (real, refinement-phase-4) |
| `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟡 | | `ServiceAreasApi` | `client/src/services/serviceAreas/apis/mockApi.ts` | Nurse coverage areas (list whole-city-first / add / remove). Enforces `UNIQUE(cityId, districtId)` exactly as the server — a duplicate (incl. a second whole-city row) throws the same **`409`** (`area_duplicate`) so the coverage editor's inline dup handling is demonstrable | `USE_SERVICE_AREAS_MOCK` (`services/serviceAreas/constants.ts`, default `true`) | b4 `nurse_service_areas/*` are live; set flag `false``serviceAreasClientApi` is wired (maps the server 409 to the same inline message). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 | | `AddressMapPicker` (map stand-in) | `client/src/components/geography/AddressMapPicker.tsx` | **Not a real map** — a bounded, tappable/draggable marker canvas (CSS grid, no Neshan/Google tiles, no network) that maps the pointer position to `{ latitude, longitude }` around the chosen city's centroid (`CITY_CENTROIDS`/`IRAN_CENTROID` in `services/geography/constants.ts`). Emits real coordinates for the create/update request | _none (component boundary)_ | Replace the canvas internals with a real map widget (Neshan/Google, inlined per the client CSP) that emits the same `{ latitude, longitude }` via `onChange``AddressForm` and every caller stay unchanged | 🟡 |
| `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟡 | | `CatalogApi` | `client/src/services/catalog/apis/mockApi.ts` (+ `apis/seed.ts`) | The catalog skeleton + nurse pricing layer. **Categories mirror the b5 seed exactly** (5 categories, ids 15, `sortOrder` 04). Seeds representative **option groups/values** the fresh backend does **not** (an admin authors them per category) — incl. required + optional groups and one **cross-category** (`serviceCategoryId=null`) group — so the builder's required-option gate + cross-category rendering demo. Enforces the server's create validation in-memory: `400` missing required dimension / bad price, and the `(nurse, category, option-set)` duplicate **`409`** (via `optionSetSignature`). Variant store seeded **empty** so the offerings empty-state demos; the nurse builds variants live (across price units). `create`/`update`/`set_active`/`list`(active-first, paginated)/`get`. Money stays an **IRR digit-string** end-to-end | `USE_CATALOG_MOCK` (`services/catalog/constants.ts`, default `true`) | b5 `catalog/*` + `nurse_variants/*` are live; set flag `false``catalogClientApi` is wired to the action-style routes (camelCase bodies, `pageSize` pagination per REQ-010, `category_id` snake_case filter). **When swapped, categories will have NO option groups until an admin authors them** (the mock's groups were illustrative). No hook/component change | 🟢 (real, refinement-phase-4) |
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 | | `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false``verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
| `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟡 | | `BookingsApi` | `client/src/services/bookings/apis/mockApi.ts` | The post-payment engagement (b9). Seeds **2 confirmed bookings** (one 3-session multi-day, one single-visit) + `booking_care_instructions` + a per-session **EVV state machine**`checkInVisit` flips the session→`in_progress`/`checked_in` (booking→`in_progress`) and computes the **advisory** `checkInAddressMatch` (haversine vs the seeded address ± `MOCK_EVV_TOLERANCE_METERS`, `null` when GPS was absent); `checkOutVisit` requires an open check-in (**`400 no_open_check_in`** otherwise), completes the session (stamps `payoutEligibleAt`), and completes the booking + opens the dispute window once **all** sessions settle. `getCareInstructions` **404s any viewer but the assigned nurse** (the two-stage-disclosure boundary; the UI `enabled` gate means the customer never even calls it). Money stays IRR digit-strings with `gross = commission + payout` and `Σ visitPayout = payout` | `USE_BOOKINGS_MOCK` (`services/bookings/constants.ts`, default `true`) | b9 `bookings/*` + `booking_sessions/*` are live, but a booking only exists after `bookings/convert` runs on a **paid** request — both upstreams (`bookingRequests` mock, card capture b10) aren't real client-side yet. Once conversion is live, set flag `false``bookingsClientApi` maps the routes 1:1 (+ `bookingsServerApi` for the RSC prefetch). No hook/component change | 🟢 (real, refinement-phase-4) |
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 | | `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟢 (real, refinement-phase-4) |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 | | `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟢 (real, refinement-phase-4) |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🟡 | | Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🗑 removed in refinement-phase-4 (payment flipped real) |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 | | `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 | | `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 | | BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «در حال انتقال به ارائه‌دهنده», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 | | `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |
| `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟡 | | `ReviewsApi` | `client/src/services/reviews/apis/mockApi.ts` | **The f13 moderated-review trust loop.** b14 serves the review **submit** (`POST bookings/{id}/review`), the public **nurse reviews** page (`GET nurses/{id}/reviews`), and the tag rollup — those are mapped 1:1 in `reviewsClientApi`. But there is **no review-eligibility read** and **no my-review-for-booking read** (**REQ-026**), and the whole moderation transition (`pending_moderation → published`) is **admin-only (f15)**. The mock reads a booking from the shared **f8 bookings store** (`mockGetBookingForReview`) to gate eligibility on a **completed/closed** booking (aligns with the new completed seed 5005 / nurse 1 / patient 905), tracks the customer's submission as `pending_moderation` so eligibility flips `already_reviewed` + `getMyReviewForBooking` returns the persistent "under review" state, and seeds a **published list per nurse** (nurse 1 has 7 → the profile tab paginates; nurses 5/6 empty → empty state). The aggregate is **recomputed from the published list** (never a stored sum). A submitted review **never** enters any public list. Dev-only `__mockPublishSubmittedReview(bookingId)` stands in for the deferred (f15) admin queue so a human can watch a review appear on the profile. Money-free | `USE_REVIEWS_MOCK` (`services/reviews/constants.ts`, default `true`) | Deliver **REQ-026** (`review_eligibility` + `my_review` reads; confirm masked-author omission), then set flag `false``reviewsClientApi.getNurseReviews`/`createReview` already map the live b14 routes 1:1 and target the two proposed slugs for the gaps. Moderation UI itself is **f15** (admin). No hook/component change | 🟢 (real, refinement-phase-4) |
| `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 | | `PatientRecordsApi` | `client/src/services/patientRecords/apis/mockApi.ts` | **The f13 continuity-of-care surface.** Two very different things: (1) the **nurse-authored visit-note history** (`getPatientHistory`/`createVisitNote`) is **REAL b14** (`GET`/`POST patients/{id}/care_records`), mapped 1:1 in `patientRecordsClientApi` (the append composes the ticked task checklist into the note `body` since the wire has no structured task field); (2) the **family-owned editable record** (medications/routine/tasks — the داروها/روتین/وظایف tabs) and the **access check** have **NO backend at all** (neither the b14 contract nor `data-model/10-reviews-and-records.md` model them → **REQ-027**). The mock is **patient-scoped** and lazily seeds a coherent default per patient: a default family record (customer edits it), a **multi-nurse continuity history** (two prior notes from *different* nurses, proving the history persists across nurse changes; a nurse append prepends to the same patient's history), and a **foreign-patient access-denied** path (`MOCK_FOREIGN_PATIENT_ID = 8888``canView:false` + a `403` on every read) so the non-leaking access-denied card is demoable. Clinical text is fixture data (never logged) | `USE_PATIENT_RECORDS_MOCK` (`services/patientRecords/constants.ts`, default `true`) | Deliver **REQ-027** (family-owned `care_record` GET/PUT + `record_access` + structured `taskResults`), then set flag `false` — the history/append methods already map the real b14 routes; only the family-record/access methods flip. Confirm whether the family-owned record is a real MVP entity | 🟡 |
| f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟡 | | f8 bookings mock — completed-booking seed 5005 + f13 cross-mock reads | `client/src/services/bookings/apis/mockApi.ts` | **Non-seam additions (mirrors the f10 refunds precedent).** The f8 seeds had **no `completed` booking** (only `confirmed`/`in_progress`/`cancelled`), so f13's review flow needs one: added **booking 5005** (`status: 'completed'`, nurse 1, patient 905, one completed EVV session) so the customer can open a completed booking and leave a review. Also added a **cross-mock read helper**`mockGetBookingForReview(id)` (single booking, clone) — imported by the reviews mock to gate eligibility and read the patient/nurse snapshot for a submission (the `listBookings` seam row omits `patientId`/`nurseId`). One-way edge INTO bookings (the bookings mock never imports f13), so no cycle | — (part of `USE_BOOKINGS_MOCK`) | When the bookings flow goes real (b9/b10 conversion live), 5005 stops being a static seed and the cross-mock helpers retire with the reviews/records mocks | 🟢 (real, refinement-phase-4) |
| `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false``ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟡 | | `TicketsApi` | `client/src/services/tickets/apis/mockApi.ts` | **The f14 ticket channel (b15).** b15 serves open/list/thread/message and `ticketsClientApi` maps them 1:1 — but the linked bookings are themselves mock-primary and the wire summary lacks `unreadCount`/`lastMessageAt` (**REQ-028**), so the mock is primary. It seeds 3 tickets (a booking-5001 **coordination** ticket with a **stored internal admin note the user view NEVER returns** — the no-leak demo — plus a support + a closed refund ticket), returns them newest-activity first with a per-ticket unread count that **clears on open**; `openTicket` is **idempotent for `coordination + bookingId`** (so "Get support" from a booking jumps to the existing thread) and prepends a new ticket to the inbox; `postMessage` appends as the current viewer (tracked from the last `getTicket` so an optimistic message reconciles as **mine** in whichever app is open), throws `403` on a **closed** ticket, and throws `500` on the dev sentinel body `'/fail'` (the optimistic failure→retry path). `MOCK_VIEWER_USER_ID` (per-role "me") drives `isMine`; **`isInternal` is never modelled in the user-app types** | `USE_TICKETS_MOCK` (`services/tickets/constants.ts`, default `true`) | Deliver **REQ-028** (`unreadCount`/`lastMessageAt` on the summary + a by-booking user lookup + optional author name + optional `clientMessageId` idempotency) and make the upstream bookings flow real, then set flag `false``ticketsClientApi` already maps the live b15 routes 1:1 (drops any leaked internal message defensively). No hook/component change | 🟢 (real, refinement-phase-4) |
| `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟡 | | `NotificationsApi` | `client/src/services/notifications/apis/mockApi.ts` | **The f14 notification center + polled bell (b1).** The b1 endpoints are live and `notificationsClientApi` maps them 1:1, but a notification only exists once some other backend domain **dispatches** one (`INotificationDispatcher`) — none run client-side while the upstream flows are mock-primary — so there'd be nothing to show. The mock seeds a realistic **unread-first** feed spanning **every deep-link class** (ticket_message/booking_confirmed/refund_processed/payment_captured/payout_paid/review_published + one unknown-type/no-payload row that degrades to no deep-link), each with a snake_case `dataJson` string the list maps through the **real** `parseNotificationData`; `getUnreadCount`/`markRead`/`markAllRead` mutate the in-memory feed. **Dev-only `__mockPushNotification(type,title,dataJson?,body?)`** prepends a fresh **unread** row so a human can watch the bell badge increment within the poll interval (phase §7 step 4). Ids align with the f8 bookings + tickets mocks so a deep-link lands on a real screen | `USE_NOTIFICATIONS_MOCK` (`services/notifications/constants.ts`, default `true`) | When the upstream domains dispatch real notifications, set flag `false``notificationsClientApi` already maps the live b1 `notifications/*` routes 1:1 (`page`/`pageSize`, `{count}`, `{notificationId}`). No hook/component change | 🟢 (real, refinement-phase-4) |
| `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 | | `AdminApi` | `client/src/services/admin/apis/mockApi.ts` | **The f15 backoffice-owned data (b1 + b15).** Fixtures engineered to exercise every console state: **one config per `data_type`** (decimal/int/bool/json/string — so the typed inputs + the 01 rate validation are all reachable) with a **change-history** trail; **holidays** with bank-closed days; a **paged audit log** with `changedFields` diffs (one row `<redacted>` for a PII field); a **support-alert** list spanning **every** `type` (`low_rating`/`evv_no_show`/`evv_location_mismatch`/`verification_expired`/`shared_sim`/`payment_anomaly`/`fraud_signal`/`nurse_clawback`/`emergency`) and all three statuses so the worklist filters are testable; and **RBAC** grants. Mutations mutate the in-memory arrays (a config save writes a history row; assign/resolve advance an alert; grant/revoke flip a role). Timestamps relative to `now` | `USE_ADMIN_MOCK` (`services/admin/constants.ts`, default `true`) | b1 config/holiday/audit/support-alert routes are live and `adminClientApi` maps them 1:1 — deliver **REQ-029** (config `updatedAt`/`updatedBy`) + **REQ-030** (audit actor/action/date filters) + **REQ-031** (the RBAC `admin_roles/*` endpoints, which don't exist yet), then set flag `false`. No hook/component change | 🟡 |
| `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 | | `PartnerCenterApi` | `client/src/services/partnerCenter/apis/mockApi.ts` | **The f15 partner centers (b15) — admin management + the center-scoped portal.** Returns **center #1 = merchant-of-record** (the settlement/invoice view renders) **and** #2 = non-MoR (the "settlement runs through Balinyaar" state) **and** a **draft** #3 (unverified banner); sponsored nurses (verified + unverified), sponsored bookings, and commission invoices whose **platform commission + BNPL commission + VAT = total** (VAT on the commission line only) with a fake 22-digit `moadianReferenceNumber` + a stub PDF url. `settlementIbanMasked` is **last-4 only** (write-then-masked: create/edit submit a full IBAN, only last-4 ever returns). Admin CRUD/verify/set-active/assign-nurse + the portal "my center" reads all mutate/read the in-memory world; "my center" resolves to `MOCK_MY_CENTER_ID` (=1, MoR) | `USE_PARTNER_MOCK` (`services/partnerCenter/constants.ts`, default `true`) + `MOCK_MY_CENTER_ID` | b15 admin partner-center CRUD/verify/sponsor are live; deliver **REQ-032** (portal split reads `centers/me[/nurses|/bookings|/settlement]` + the activate/suspend toggle + confirm the write-then-masked IBAN) + **REQ-033** (center-scoped invoice list + invoice `totalIrr`), then set flag `false``partnerCenterClientApi` maps the live admin routes and targets the proposed portal slugs. No hook/component change | 🟡 |
| Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟡 | | Admin-endpoint additions to existing domain mocks (`verification`/`refunds`/`payouts`/`reviews`/`tickets`) | the same `apis/mockApi.ts` files (+ their `clientApi.ts`) | **The f15 staff lens over prior domains** — new admin methods added behind the existing seams (no new seam, no hook/component change on swap). **verification:** a nurse-level review queue (`pending`/`in_review`, one with an expiring credential) + a per-nurse case whose manual credential steps carry a document, and `getDocumentSignedUrl` that returns a **fresh short-lived URL each call** (sentinel `documentId 9999` throws → viewer error/re-request path); `decideStep`/`approve`/`reject` re-aggregate. **refunds:** a `getRefundPreview` with the fee/payout split reconciling to the rial per booking (a normal card, a BNPL w/ ETA, a post-payout w/ clawback notice, and a provider-decline **sentinel that fails then retries succeeds**). **payouts:** batches spanning `completed`/`partially_failed`/`processing` (one holiday-shifted), a preview w/ eligible + skipped(no-IBAN) + clawback line + holiday-shifted date, an **idempotency-keyed** run/retry (same key → same result, never double-pays), a `failed` payout to retry, and record-transfer-reference. **reviews:** a moderation queue incl. a low-rating flagged review; `moderateReview` returns a plausible recomputed aggregate. **tickets:** a global admin queue + a thread that **includes** the seeded internal note (the no-leak *inverse* demo) + `postAdminMessage` w/ `isInternal`; a refund-linked ticket (bookingId+refundId) so the RefundPanel opens from it | the owning domain's flag (`USE_VERIFICATION_MOCK` / `USE_REFUNDS_MOCK` / `USE_PAYOUTS_MOCK` / `USE_REVIEWS_MOCK` / `USE_TICKETS_MOCK`, all default `true`) | Deliver the per-domain admin gaps — **REQ-034** (verification nurse-queue + on-demand doc URL + whole-verification approve/reject), **REQ-035** (refund preview + explicit approve/reject), **REQ-036** (payout single-preview + `holidayShifted` + record-transfer-reference), **REQ-037** (moderation `tagCodes`) — then flip the owning domain's flag. The real `clientApi` methods already map the live admin routes 1:1 and target the proposed slugs for the gaps | 🟢 (real, refinement-phase-4) |
@@ -0,0 +1,85 @@
# Refinement Phase 2 — Auth & role-aware navigation (the "only customer side" fix) — Report (2026-07-13)
## The symptom, and the actual root cause
"There are nurse and admin pages, but running the frontend only ever shows the customer side." Auth was
already the one real domain (`USE_AUTH_MOCK = false`); the app only *looked* customer-only because of two
things, now fixed:
1. **Role hydration conflated "loading" with "no role."** A fresh `/me` in-flight fell through
`useActorRole()`'s `DEFAULT_ROLE = customer` fallback, so a nurse/admin was shown the customer shell for a
beat — or forever, if `/me` failed. **This was the core bug.**
2. **The admin console was unreachable through the web login.** Admin sub-roles are server-granted (never
self-selectable via `me/select_role`), and no *phone* user held one — the only admin was the
username/password `admin`/`qw123321` the phone-OTP frontend can't use.
## What was built
### Frontend (client/ — the bulk)
- **`useRoleHydration()`** (`services/auth/hooks/useRoleHydration.ts`) — a discriminated
`loading | error | ready` over `useMe`. This is the resolved-vs-pending distinction the phase demands:
`ready` only once `/me` resolves (carrying the collapsed `appRoles`); `error` only when `/me` has **no**
data (a background refetch that fails while a cached identity exists stays `ready` — don't downgrade a known
nurse on a blip). Exported from the `services/auth` barrel.
- **`RoleGuard`** (`components/auth/RoleGuard.tsx`, **tested**) — wraps every private shell. On `loading`
neutral brand `AuthSplash` (never the customer shell as a stand-in); on `error``AuthAccountError` with
retry (never a silent customer fallback); on **role mismatch**`router.replace(resolveRoleDestination(me))`
+ a `guard_denied` toast. Takes `expected?: AppRole`; the partner portal passes none (hydration-only —
partner isn't an `AppRole`, it self-gates via `useMyPartnerCenter`). It is **UX/chrome, not security** — the
server still authorizes every endpoint; a dual customer+nurse session holds both roles and moves freely.
- **`AuthAccountError`** (`components/auth/AuthAccountError.tsx`) — the `/me`-failed recovery card (brand mark
+ warning + retry). Distinct from `RoleRouter`'s login-time error branch (which sends to `/login`).
- **Wired the four shells**`(customer)`/`nurse`/`admin` layouts wrap in `RoleGuard expected={APP_ROLES.*}`;
`partner` wraps in a role-less `RoleGuard`. The guard sits **outside** the shell component so its nav chrome
never renders during load/redirect.
- **Doc hardening**`useActorRole()`'s `DEFAULT_ROLE` fallback is now documented as a last resort (the guard
ensures hydration before a shell renders), never the loading state. No behavior change there (f15's
`useAdminCapabilities` still reads the same session roleCodes).
- **i18n**`auth.guard_denied` / `account_error_title` / `account_error_body` / `account_error_retry` in
both `en.json` + `fa.json`.
### Backend (server/ — a little, per §3.4)
- **Two phone-OTP admins added to the Development demo seeder** (`DemoWorldSeeder` + `DemoWorldDefinitions`):
`09120000020` (`super_admin`) and `09120000021` (`finance`). An admin persona is just a phone user + a
server-granted admin role (no profile) via the existing `CreateUserAsync`; idempotent (phone-guarded) like
every other persona, Development-only. This is the sanctioned path to `/admin` through the normal phone-OTP
login. Seeding **two** roles makes `useAdminCapabilities` gating demonstrable — the `finance` operator's
sidebar shows only the money consoles.
## What's now testable, and exactly how (DoD)
Run the app per the RUNBOOK, then:
1. **Nurse → `/nurse`:** log in as `09120000001` (verified nurse) → nurse shell + dashboard.
2. **Customer → `/`:** log in as `09120000010` → family app. Tap "become a nurse" (SelectRole) → `POST
me/select_role` in Network → after the `/me` refetch you're routed to `/nurse`.
3. **Admin → `/admin`:** log in as `09120000020` → admin console (all consoles incl. RBAC). Log in as
`09120000021``/admin` with only the finance consoles in the sidebar (`useAdminCapabilities`).
4. **Mis-role redirect:** as a pure customer, visit `/nurse` → redirected to `/` with the `guard_denied` toast.
5. **Backend-down resilience:** stop the API, reload a nurse session → `AuthAccountError` (loading→error), **not**
the customer app; restart + retry → recovers to `/nurse`.
Automated: `RoleGuard.test.tsx` (8 cases — loading/error/retry/allowed/dual-role/mismatch-redirect/role-less/
partner-no-expected). `DemoWorldSeederTests` +1 (admins reachable with their granted roles; total 4).
## What's mocked / deferred (honest gaps)
- **Partner login-routing is deferred.** `/partner` is a separate authz scope **not derivable from `me.roles`**,
so `resolveRoleDestination` can't route a partner admin there on login. `/partner` **is** reachable by direct
navigation (the `services/partnerCenter` mock resolves a center for `useMyPartnerCenter`, so the shell renders
rather than access-denied), and the `RoleGuard` doesn't block it. The real login→`/partner` needs a `/me`
signal — filed as **REQ-038** (`administersPartnerCenterId`) + a paired demo-seed association. No partner
center was seeded this phase (would need real b15 partner↔user wiring that isn't runtime-verifiable here).
- **No new mock seam.** Auth stays 100% real (`USE_AUTH_MOCK = false` untouched) — deliberately, per §4: using
the auth mock to fake roles would hide the very hydration bug this phase fixes.
## Contracts / tracker
- **REQ-004 resolved** — "the client owns the active-role choice"; `MeResult` gains no `activeRole`. A dual
customer+nurse session is disambiguated by the client-carried intended role (A1/B1 switch), defaulting to the
family app; `RoleGuard` lets a dual-role user move between shells.
- **REQ-038 filed** — a `/me` partner-center-admin signal for partner login-routing (see above).
## Gate
- **client:** `npm run check` green; `npm run test:ci -- RoleGuard` green (8/8); `en.json`/`fa.json` in sync.
- **server:** `dotnet build Baya.sln` 0 errors (warnings all pre-existing NuGet advisories / a migration's
CS8632); `DemoWorldSeederTests` 4/4 pass over the SQLite harness. (A real SQL Server still couldn't boot in
this env — same constraint as phase 1 — so the seeder DoD is proved through the test harness.)
## Follow-ups for later phases
- REQ-038 (partner `/me` signal + seed) — likely a small backend refinement phase.
- Cross-actor **hard** route guarding is still server-side only; `RoleGuard` is deliberately chrome-level UX.
@@ -0,0 +1,95 @@
# Refinement Phase 6 — Money-path correctness completion — Report (2026-07-13)
**Track:** backend (money path) · **Depends on:** nothing hard (do before real BNPL/manual refunds — phase 8)
· **Gate:** `dotnet build` 0 new warnings · `dotnet test` 396 pass (383 prior + 13 new).
## The headline fix (6.1) — the unreachable BNPL/manual refund settlement is now wired
Before this phase, a card refund cleared its `refund_payable ↔ escrow_held` leg immediately, but a
BNPL-revert / manual-bank refund was left in `processing` with the clearing "deferred to reconciliation" — and
**no reconciliation path existed**: `Refund.MarkSucceededAsync` had zero callers, and nothing performed
`processing → succeeded`. Every BNPL/manual refund permanently overstated `escrow_held` and stranded
`refund_payable`; the ledger could never reconcile with the bank.
Now:
- **`ConfirmRefundSettlementCommand`** (`Features/Refunds/Commands/ConfirmRefundSettlement/`) transitions
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
**same commit**. It runs under the same `booking:{id}:refund` lock as `CreateRefundCommand` and **re-reads the
tracked refund inside the lock**, so a racing/replayed confirm sees committed truth and no-ops (never
double-clears). Idempotent: an already-`succeeded` refund is a no-op success.
- **`MarkRefundSettlementFailedCommand`** (`.../MarkRefundSettlementFailed/`) is the counterpart —
`processing → failed`, no ledger moves.
- **Admin surface:** `POST admin_refunds/{id}/confirm_settlement` + `.../mark_failed` on `AdminRefundsController`.
- **BNPL callback branch:** `HandleBnplCallback` gained a `RefundConfirmed` action. A provider event whose type
says the revert/refund cash-back **completed/confirmed/settled** (or "cashback") resolves the `processing`
refund created against that order's `payment_transaction` (`GetProcessingRefundIdForTransactionAsync`) and
dispatches `ConfirmRefundSettlementCommand`. `ResolveAction` checks this **before** the order-level `settle`
branch so `revert_settled` doesn't fall through.
- Domain: the misnamed `Refund.MarkSucceededAsync` (not async, uncalled) was renamed `MarkSucceededReconciled`.
**Proof:** `RefundSettlementTests` — a BNPL refund lands `processing` (3 reversal legs, no clearing) → confirm →
`succeeded`, clearing posts, the ledger reconciles (Σdebit = Σcredit, `refund_payable` fully drained,
`escrow_held` credited back); a replayed confirm stays at 5 legs (no double-clear); `mark_failed` leaves 3 legs
and blocks a later confirm (409).
## 6.4 — crash-window closed
`CreateRefundCommand` now persists the refund row (`approved`) **and commits** *before* calling the external
channel; then executes the channel against the persisted row and commits the outcome (succeeded/processing/failed
+ ledger). Same claim-first / execute-second shape the webhook handler uses — a crash between provider success
and our commit now leaves a reconcilable `approved` row instead of a silently-executed refund with no record.
## 6.2 — forward-dep FKs added (additive migration `RefinementPhase6MoneyFks`)
Real FKs (all nullable, `ON DELETE NO ACTION`) on the columns b11 shipped FK-less "until the target ships"
(the targets all shipped in b13/b15): `refunds.ticket_id → messaging.Tickets`,
`nurse_clawbacks.original_payout_id`/`recovered_in_payout_id → payouts.NursePayouts`,
`invoices.partner_center_id → partner.PartnerCenters` (**+ index**). The three now-false config-doc comments were
corrected. Referential integrity no longer rests on application discipline alone.
## 6.3 — IAuditable extended to the admin-decided money & trust entities
`Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification` are now `IAuditable`, so the
`AuditFieldInterceptor` writes an append-only `audit_logs` diff row on create + every admin decision (approve /
reject / process / settle, and the verification `is_verified` decision). `NursePayout.IbanSnapshot` (encrypted)
carries `[AuditRedacted]` so the diff records a marker, never the plaintext IBAN.
## 6.6 — orphaned config key retired
`refund_ticket_required` (seed row id 19) had no consumer left (b15 unconditionally auto-opens a refund ticket).
The seed row is deleted by the migration and the false description removed; the test-host stub was dropped.
## 6.5 — the previously-untested admin money paths now have tests (+13 tests)
- **`ClawbackWriteOffTests`** — write-off posts a balanced `DEBIT bad_debt / CREDIT nurse_clawback_receivable`
group and resolves the clawback; 404 unknown; 409 second write-off. (Was zero-coverage.)
- **`Racing_same_key_insert_is_caught_as_an_idempotent_no_op`** (added to `PaymentWebhookTests`) — a provider
that omits `external_event_id` skips the read-dedup, so the `(provider_code, external_event_id)` UNIQUE is the
sole backstop (the exact state a true concurrent insert reaches); a colliding insert hits `DbUpdateException`
and is treated as an idempotent duplicate no-op (no confirm, no ledger).
- **`MessagingInternalBoundaryTests`** (Foundation, handler-level) — the `is_internal` boundary: user thread view
strips internal notes; admin view returns them; non-staff admin-view request is forbidden; non-staff can't
post an internal note; a staff internal note never surfaces in the user view.
- **`RefundSettlementTests`** — the 6.1 settlement (both channels) + idempotency + mark_failed.
## Test-infra note (why a refund test host changed)
Adding the `refunds.ticket_id` FK means SQLite (which EF enables FK enforcement on) rejects a refund whose
`ticket_id` points at a non-existent ticket. `RefundsTestHost` now **seeds a real `Ticket`** and exposes
`Senders()` whose `OpenTicket` hook returns that real id (replacing the `TestSenders.WithTicketHooks()` fake id 1
in the refund tests). `PaymentsTestHost`/`PayoutsTestHost` were unaffected (they leave the new FK columns null).
## Contracts / docs updated (same change)
- `dev/contracts/domains/refunds-invoices.md` — the two new endpoints + `RefundSettlement` shape + changelog +
corrected the `refund_ticket_required` note.
- `dev/contracts/openapi/swagger.v1.json` — refreshed (additive: the two routes + `RefundSettlementResult`).
- `server/CLAUDE.md` — refunds/payments section (settlement wiring + crash-window + FKs + retired config), the
audit-interceptor note (expanded IAuditable set + `[AuditRedacted]`), and the feature map.
## Follow-ups (out of scope, for later phases)
- **A `mark_failed` after a successful provider revert** leaves the reversal ledger posted with no clearing (the
money is genuinely in limbo — an ops reconciliation case). Deliberate: the reversal is not un-posted.
- The BNPL/PSP mocks stay until phase 8 (external rails); the settlement path is now complete behind them.
- `audit_logs` growth (2.3 grows it faster) — retention/archival is phase 9 (§7.4).
@@ -0,0 +1,92 @@
# Refinement Phase 7 — Unattended operation: scheduler, locking & multi-instance readiness — Report (2026-07-13)
**Track:** backend (infra) · **Depends on:** phase 6 (its settlement reconciliation is a future job here) ·
**Gate:** `dotnet build` 0 new warnings · `dotnet test` **402 pass** (396 prior + 6 new scheduling tests).
## The headline (7.1) — the platform now runs itself
Before this phase only two hand-written `PeriodicTimer` hosted services existed; the credential-expiry scan, EVV
no-show sweep, and **weekly payout-batch generation** were admin-click-only while their seeded cadence keys sat
unread — so **nurses were paid only when an operator clicked**. Now a single in-process scheduler drives every job
on its own cadence.
**`RecurringJobSchedulerHostedService`** (`Persistence/Services/Scheduling/`) + the **`IRecurringJob`** seam:
- The scheduler owns one independent loop per job (their cadences don't couple; a crash in one never stops the
others), the per-tick DI scope, error isolation (a throwing tick logs and the next tick retries on schedule),
and a per-tick `IDistributedLock("scheduler:{name}")`. A job says only *how often* (usually a `platform_configs`
cadence key, re-read each tick so an admin change applies without a restart) and *what one idempotent run does*.
- **No new infrastructure.** SQL Server stays the only external dependency — a single-instance MVP needs neither
Hangfire/Quartz (durable/cross-restart scheduling is the only thing they add for idempotent periodic sweeps) nor
Redis. Adding a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`.
Jobs registered (`Services/Scheduling/Jobs/`), each dispatching the **same idempotent command the admin trigger
sends** (the admin endpoints are unchanged and remain overrides):
| Job (`Name`) | Cadence source | Re-homed / new |
| --- | --- | --- |
| `booking_request_expiry` | 1 min const | re-homed from `BookingRequestExpiryHostedService` (deleted) |
| `notification_retention` | 24 h const | re-homed from `NotificationRetentionHostedService` (deleted) |
| `verification_expiry_scan` | `verification_expiry_scan_cadence_hours` (24) | **new**`ScanExpiringCredentialsCommand` |
| `no_show_sweep` | `no_show_scan_cadence_hours` (1) | **new**`DetectNoShowSessionsCommand` |
| `weekly_payout_generation` | `nurse_payout_interval_days` (7) | **new**`GeneratePayoutBatchCommand` |
## Money movement stays human-approved (critical rule)
The payout job schedules **generation only** — it opens a `draft` batch over the trailing window; the irreversible
`process` (money-moving) step remains an explicit admin action until trust is earned. To let an unattended run
record a batch with no human initiator, `NursePayoutBatch.InitiatedByAdminId` is now **nullable** (`null` =
system-initiated) — migration `RefinementPhase7SystemPayoutBatch` (alters the column + FK to nullable; the FK, the
`PayoutBatchDto` projection, and `swagger.v1.json` were updated to match). The command's `SystemInitiated` flag is
**scheduler-only**: `AdminPayoutsController.Generate` neutralizes any request-supplied value (`command with {
SystemInitiated = false }`), so an API caller can never bypass the authenticated-admin requirement. A quiet week
(no eligible bookings) is a benign no-op; a re-run over an overlapping window is safe — the
`nurse_payout_booking_links.booking_id` UNIQUE prevents re-selecting an already-paid booking.
## 7.2 — Redis is the scale-out gate, NOT added
Per the phase's "don't add Redis because", the in-process `ICacheService`/`IDistributedLock` stay. They are the
documented **>1-instance scale-out gate**: the moment a second API instance runs, swap the lock seam to Redis and
the scheduler's per-tick lock serializes ticks across nodes (idempotency + the DB uniques cover a double-run
either way). Nothing speaks Redis today; no package added. (Registry rows `ICacheService`/`IDistributedLock`
updated with the framing.)
## 7.3 — Migrations split from boot
`dotnet run -- migrate` is a deploy-time one-shot: it applies EF migrations + the idempotent seeders, then exits —
so concurrent multi-instance start-ups never race on DDL and the runtime login needs no permanent DDL rights.
**Development** boot still migrates + seeds (incl. the Development-only sandbox gateway + demo world) for
convenience; **deployed** boot only *checks* the schema is current (`EnsureSchemaUpToDateAsync` — fail-fast on a
pending migration) and seeds roles/break-glass admin. Env-gated in `Program.cs`.
## What is now testable and exactly how
- **6 new Foundation tests** (`Tests/Baya.Test.Foundation/Scheduling/`): each cadence job reads the right config
key and dispatches the right command (incl. the payout job asserting `SystemInitiated=true` + the trailing
window); the scheduler runs a job at startup under `scheduler:{name}`, keeps siblings alive when one throws, and
stays **dormant under the `Testing` environment**.
- **Live cadence check:** set a short `no_show_scan_cadence_hours` / `verification_expiry_scan_cadence_hours` (or
a short interval) in `platform_configs`, run the API (Development), and watch the job fire on schedule in the
logs, producing the same result as the admin manual trigger.
- **Migration path:** `dotnet run -- migrate` applies + seeds and exits; a deployed-env boot with a pending
migration fails fast with the list of pending migrations.
## What is mocked / deferred (follow-ups)
- **Moadian reconciliation + refund-settlement poll** are **Phase 8's** jobs — they have no command/cadence key
today and Phase 8 explicitly owns registering them. They slot in as new `IRecurringJob`s with one `AddSingleton`
— no scheduler change. Documented in the mocks-registry row.
- **Redis** — the scale-out gate above (only when >1 instance).
## Contracts produced/consumed
- `PayoutBatchDto.initiatedByAdminId` is now nullable (`null` = system/scheduled batch). Updated
`dev/contracts/domains/payouts.md` + `dev/contracts/openapi/swagger.v1.json`. No other wire change.
## Files
New: `Services/Scheduling/{IRecurringJob, RecurringJobSchedulerHostedService}.cs` +
`Services/Scheduling/Jobs/{BookingRequestExpiry, NotificationRetention, CredentialExpiryScan, NoShowSweep,
WeeklyPayoutGeneration}Job.cs`; migration `RefinementPhase7SystemPayoutBatch`; 2 test files. Deleted: the two old
hosted services. Changed: `AddPersistenceServices` (registration) + `EnsureSchemaUpToDateAsync`; `Program.cs`
(migrate one-shot + env-gated boot); `NursePayoutBatch`/`NursePayoutBatchConfig`/`PayoutBatchDto` (nullable
initiator); `GeneratePayoutBatchCommand`(+Handler)/`AdminPayoutsController` (system-initiated path).
@@ -0,0 +1,136 @@
# Refinement Phase 8 — External rails go real (SMS → trust/identity → money) — Report (2026-07-13)
**Track:** backend (integrations) · **Depends on:** phase 6 (money-correctness), phase 7 (scheduler) ·
**Gate:** `dotnet build` **0 new warnings** · `dotnet test` **402 pass** (unchanged — the mocks stay the default,
so no existing test changed behaviour).
## The shape of the phase — an adapter behind every seam, config-selected
Every vendor dependency was a deterministic in-process mock. This phase ships a **real HTTP adapter behind each
seam**, selected by a per-rail **`Seams:*:Provider`** selector. The mock is the **default** (an unconfigured or
typo'd provider falls closed to it), so a **partial rollout is the normal case** — real SMS + real geocoder while
payments stay mocked in a pre-launch environment is three config keys. Swapping is a **registration change in
`AddCrossCuttingSeams`; no handler changed** (the DoD's "handler is unchanged" holds for every rail).
**Zero new NuGet packages.** The CrossCutting project already framework-references `Microsoft.AspNetCore.App`, so
every adapter is `HttpClient` (typed via `IHttpClientFactory`) + `System.Text.Json` + BCL crypto — no vendor SDK,
no restore risk. Credentials come from `Seams:*` (user-secrets/env), never committed. New adapters live in
`Baya.Infrastructure.CrossCutting/Seams/Real/`.
## 3.1 Trust & identity rails
- **5.1 SMS — `KavenegarSmsSender` (launch-critical).** OTP via Kavenegar's `verify/lookup` template API;
free-form via `sms/send`. A non-`200` `return.status` is surfaced as a delivery failure (the OTP command reports
it, never a silent "success"). **The OTP is never logged:** `Program.cs` now runs the Development OTP-in-logs
capture bridge **only while the mock SMS sender is selected** (`Seams:Sms:Provider` empty/`mock`) — the moment a
real gateway is configured the code leaves the process only over the SMS wire.
- **5.2 Shahkar + e-KYC — `FinnotechShahkarVerifier`, `FinnotechIdentityKycProvider`.** A shared `FinnotechClient`
(base URL, bearer auth, per-call `trackId`) fronts both; creds in `Seams:Finnotech`. Shahkar can't distinguish a
shared-SIM from a plain mismatch (the registry only asserts bound/not-bound), so a real no-match is reported as a
plain mismatch — the explicit shared-SIM branch stays reachable through the mock. The raw vendor response is
persisted as `external_response_json`.
- **5.3 استعلام شبا — `FinnotechBankAccountOwnershipVerifier`** (the b13 first-payout money-mule gate). Matches the
IBAN's registered national code against the nurse's; **fails closed** (no national code returned ⇒ no match).
- **5.4 Geocoder — `NeshanGeocoder`.** `x`=lng/`y`=lat parsed to `decimal` (exact EVV haversine downstream). A
Neshan outage **degrades to the null-pin state** — it never blocks saving an address.
- **5.5 Object storage — `S3ObjectStorage`.** MinIO / S3 / ArvanCloud with **manual AWS SigV4** (HMAC-SHA256, all
BCL — no AWS SDK). Server-side put/get/delete are SigV4-header-authed (`UNSIGNED-PAYLOAD` so a blob stream is
never buffered to hash it); `GetUrl` returns a **presigned GET** = the real form of the b6 signed-URL contract.
Path-style default (MinIO/ArvanCloud); virtual-host supported.
- **5.6 MoH/INO/eNamad — kept manual (intended MVP).** `ICredentialVerifier` / `ILicenseVerificationService` stay
mock — there is **no public B2B API**, so the manual admin review *is* the mechanism, not debt. The registry rows
are marked "manual = intended MVP".
## 3.2 Money rails
- **6.1 PSP + webhook signature + تسهیم — `ZarinPalPaymentProvider` + `HmacWebhookVerifier` +
`ProviderSettlementSplitProvider`** (swap together on `Payments:Provider`). ZarinPal v4 request/verify/refund;
the **mandatory server-side verify** re-checks amount + reference (never trusts the callback). The webhook
verifier does **per-provider HMAC over the raw body** (`Seams:Payments:WebhookSigningSecrets[{provider}]`,
constant-time compare, tolerates a `sha256=` prefix); **no secret ⇒ the handler's server-side verify re-check is
the guard** (the contract's signatureless fallback). تسهیم registers a split-by-ratio to registered IBANs.
- **6.2 BNPL — `SnappPayBnplProvider` + `DigipayBnplProvider` + `ConfiguredBnplProviderResolver`**
(`Bnpl:Provider=real`). One adapter per `provider_code`; the SnappPay verb set is the canonical superset the seam
was designed around (OAuth-token cached → eligible → token → verify → settle → status → cancel/revert/update).
**Currency crosses the wire only at the adapter boundary** via a shared `HttpBnplProviderBase.ToWire/FromWire`
over `ICurrencyNormalizer` (`Seams:Bnpl:WireCurrency`, Rial pass-through by default). The **merchant commission
is read from the settle response, never hardcoded.** **REQ-022 / balinyaar decision:** `balinyaar` is the
in-house plan — no external API — so it **resolves to the deterministic net-of-fee model** (the distinction is
the financing entity, not the money mechanics); `tara`/`torobpay` resolve to `null` (unbuilt) so the handler
rejects them cleanly. The b11 `bnpl_revert` refund path injects `IBnplProvider` directly (not per-code) →
SnappPay is the default revert provider (per-code revert resolution is a documented follow-up).
- **6.3 PAYA/SATNA payout — `JibitBankTransferProvider` + the async reconciliation callback.** The real rail is
**async**: an accepted transfer comes back `submitted` (a track id, money not yet confirmed). The existing
`ExecutePayoutBatch` handler already `MarkSubmitted`s first and posts **no ledger** until paid, so it needed no
change. New: **`ReconcilePayoutBatchCommand`** + **`WebhooksPayoutsController` (`POST webhooks/payouts/{provider}`,
anonymous, `webhook` rate policy)** — HMAC-verified (an invalid signature mutates nothing), parses the
per-transfer outcomes, matches `submitted` payouts by `transfer_reference`, and flips `paid` (posts the payout
ledger + nets clawbacks via `PayoutSettlement`) / `failed`. Idempotent by the forward-only status machine + the
ledger-exists guard — a replayed callback is a no-op.
- **6.4 `IPaymentCaptureSimulator` out of production.** Prod registers the fail-closed
`DisabledPaymentCaptureSimulator` (never fabricates a capture); Dev/Testing re-register the succeeding
`MockPaymentCaptureSimulator` via `AddDevelopmentPaymentCapture` (last-wins). The `bookings/convert` path is a
Dev/Testing affordance — production converts via the b10 webhook confirm calling `ConvertRequestToBooking`
directly. (Testing must keep the mock: Mediator constructs the handler before validation runs, so the API tests
that expect `400`/`401` on `bookings/convert` would otherwise `500`.)
- **6.5 Moadian — `MoadianClient` + `MoadianReconciliationJob`.** Submit posts the invoice and maps the outcome
(22-digit ref ⇒ `registered`; accepted-not-yet ⇒ `submitted`; reject ⇒ `failed`; a transient error stays
`submitted` so the next tick retries — never permanently failed on a transient fault). The **reconciliation poll**
is a new `IRecurringJob` (fixed **6 h** cadence — no seeded config key, so **no migration**) running
`ReconcileMoadianInvoicesCommand`, which re-submits every `pending`/`submitted` invoice until it registers
(Moadian dedups on the invoice number, so a re-submit doubles as the status poll — the seam keeps its one verb).
New repo read: `IInvoiceRepository.GetUnregisteredMoadianInvoicesAsync`.
- **6.6 Partner-center settlement rail — decision (product).** **No new center-payout money path is built this
phase.** The MoR resolver already routes the invoice issuer; the settlement decision is: a **merchant-of-record**
center is settled at capture time by **adding its registered `settlement_iban` as a تسهیم split leg** (the
acquirer credits it directly — reusing 6.1, no new batch), and a **non-MoR** center has **no separate money
path** (the nurse is paid via the normal b13 payout; the center's cut is an off-platform arrangement). A
dedicated center-settlement ledger account + payout reusing the b13 machinery is **deferred** until center volume
justifies it. Documented; no code beyond the existing تسهیم leg.
## Config-selection mechanics (how the swap works)
`AddCrossCuttingSeams` reads the bound `SeamOptions` once and, per rail, registers the real adapter **or** the mock.
Real HTTP adapters get a **named `IHttpClientFactory` client**; because the seams are singletons injected into
scoped handlers (and the BNPL resolver holds its adapters), the adapters are singletons resolving one client — the
standard minor SigV4/handler-rotation caveat against these stable vendor hosts is acceptable for the MVP. A
`SeamProviders` token class keeps the selectors typo-safe. `SeamOptions` gained a `Provider` selector on every rail
+ credential blocks (`Sms`, `Finnotech`, `ObjectStorage` S3, `Payments`, `Bnpl.Providers`, `BankTransfer`,
`Moadian`).
## What is testable and how (no live vendors here)
The adapters can't be exercised against live Iranian vendors in this environment; that is deploy-time
credentialing/certification (Shaparak lead time for the PSP especially). What **is** verified now: build + the full
402-test suite stay green with the mocks as default (proving the config-selection default preserves every existing
behaviour). To exercise a real rail: provision the vendor account + credential, set `Seams:{rail}:Provider` +
creds, and run the flow (request OTP → real SMS → login; sandbox card → verify + signed webhook; payout batch →
`submitted``POST webhooks/payouts/jibit``paid`; invoice → `MoadianReconciliationJob``registered`).
## Follow-ups (documented, not forgotten)
- **Per-code BNPL revert** — the b11 refund path injects `IBnplProvider` directly; SnappPay is the default. Route
the revert through `IBnplProviderResolver` by the transaction's `provider_code`.
- **SMS.ir / Ghasedak** adapters — only Kavenegar is implemented; selecting the others throws a clear
`NotSupportedException` at registration (fail fast, never a silent mock).
- **Finnotech token exchange** — the adapters use a pre-issued `AccessToken`; the client-credential refresh is a
deploy-time concern. Same for the Moadian signing certificate.
- **Refund-settlement poll** (BNPL `processing → succeeded`) — the phase-7 note paired it with Moadian; the
settlement-confirm command exists (phase 6 `ConfirmRefundSettlement`), the poll job over "processing refunds" is
the remaining wiring (needs a repo read of pending settlements).
- **Center-settlement payout** — deferred per 6.6.
- **Redis / Elasticsearch** — unchanged scale-out gates, no adapter (correctly single-instance today).
## Files
New (`CrossCutting/Seams/Real/`): `KavenegarSmsSender`, `FinnotechClient`, `FinnotechShahkarVerifier`,
`FinnotechIdentityKycProvider`, `FinnotechBankAccountOwnershipVerifier`, `NeshanGeocoder`, `S3ObjectStorage`,
`ZarinPalPaymentProvider`, `HmacWebhookVerifier`, `ProviderSettlementSplitProvider`, `HttpBnplProviderBase`,
`SnappPayBnplProvider`, `DigipayBnplProvider`, `ConfiguredBnplProviderResolver`, `JibitBankTransferProvider`,
`MoadianClient`. Plus `CrossCutting/Seams/DisabledPaymentCaptureSimulator`;
`Features/Payouts/Commands/ReconcilePayoutBatch/*`; `Features/Invoices/Commands/ReconcileMoadianInvoices/*`;
`Persistence/Services/Scheduling/Jobs/MoadianReconciliationJob`; `Controllers/V1/WebhooksPayoutsController`.
Changed: `SeamOptions` (+ provider selectors/creds), `AddCrossCuttingSeams` (config-selected rewrite),
`DevelopmentSeamExtensions` (+ `AddDevelopmentPaymentCapture`), `Program.cs` (OTP-capture gated on mock SMS +
Dev/Testing payment-capture), `AddPersistenceServices` (register `MoadianReconciliationJob`),
`IInvoiceRepository`/`InvoiceRepository` (+ `GetUnregisteredMoadianInvoicesAsync`).
@@ -0,0 +1,110 @@
# Refinement Phase 9 — Observability, ops hardening, docs honesty & scale-later — Report (2026-07-13)
**Track:** backend (observability/docs) + explicit deferrals · **Depends on:** nothing hard ·
**Gate:** `dotnet build` **0 new warnings** · `dotnet test` **407 pass** (402 prior + 5 new: audit-retention ×2,
ticket-body encryption ×2, liveness ×1; the existing messaging suite now also exercises the encrypted body).
This phase makes the running platform **diagnosable and honest**, and records the explicitly-deferred scale work so
nobody mistakes it for missing MVP scope. No feature behaviour changed; the money/trust rules are untouched.
## Observability & ops hardening (finish before launch)
### 9.1 — Tracing added; one metrics stack; `requestId` = trace id
- **One metrics stack.** Removed the duplicate **prometheus-net** stack (`UseMetricServer`/`UseHttpMetrics`/
`ForwardToPrometheus` + the three `prometheus-net*` packages). **OpenTelemetry is now the only metrics source**,
scraped at `/metrics` via `UseOpenTelemetryPrometheusScrapingEndpoint()`. HTTP request metrics now come from the
OTel ASP.NET Core instrumentation; the `mediator_meter` request-duration histogram (`MetricsBehaviour`) is now
actually exported (added to the meter list — the old prometheus-net stack never captured it).
- **Tracing.** Added `WithTracing` (ASP.NET Core + **EF Core** instrumentation) sharing one resource
(`service.name = Baya.Web.Api`), so a cross-service money flow (webhook → confirm → ledger) is one trace.
- **OTLP export is opt-in.** Traces + metrics export to an OTLP collector **only when `OpenTelemetry:Otlp:Endpoint`
is set** — an MVP with Prometheus alone runs unchanged and no exporter spams an absent collector.
- **`requestId` already carries the trace id** (`ApiResult.RequestId = Activity.Current.TraceId`) with
`Activity.DefaultIdFormat = W3C` — a support ticket maps 1:1 to a trace with no extra wiring.
- **New packages** (all cached, no restore risk): `OpenTelemetry.Exporter.OpenTelemetryProtocol` (1.15.3),
`OpenTelemetry.Instrumentation.EntityFrameworkCore` (1.15.1-beta.1).
### 9.2 — Health checks broadened; liveness/readiness split
- `/healthz/live` — process only (a dependency-free `self` check), so a dependency outage never restart-loops.
- `/healthz/ready` — the app DB, the log DB (**deployed only** — its conn string is a placeholder in Dev/Testing),
and a real **object-storage write round-trip** (`ObjectStorageWriteHealthCheck`: put → get → delete a probe blob).
- `/HealthCheck` — the aggregate, retained for backward compatibility. The dead `currentUrl` line is gone.
- **Redis** is noted as the next readiness check to add *when* it becomes a real dependency (>1 instance); not now.
- `Baya.Infrastructure.Monitoring` now references `Baya.Application` (for the `IObjectStorage` probe) — a legitimate
Infrastructure→Application edge, noted in the server Project map.
### 9.3 — Prod log level raised to Information+; no PII; dead ES sink removed
- Deployed envs now log **Information+** (was Warning+, which dropped every Information-level audit trail), with
framework categories held at Warning so the floor raise doesn't flood the sink.
- **No secrets/PII in logs.** `LoggingSmsSender` **no longer logs the OTP code** (a login secret) in any
environment — a developer gets it from the Development-only `GET /api/v1/dev/last_otp`. Clinical text / IBANs /
phone numbers are already encrypted or masked before any handler logs. The `columnOptions` (previously built but
never applied) are now wired to the SQL sink.
- **Dead Elasticsearch sink resolved by deletion:** removed the commented ES sink block *and* the unused
`Serilog.Sinks.Elasticsearch` package (this also revealed `Serilog.Sinks.File` was only a transitive of the ES
package — added it explicitly). Log-table retention is documented as an ops/DBA responsibility (or ship logs to
the OTLP collector).
### 9.4 — Audit-log retention as a scheduled job
- New `AuditLogRetentionJob` (`IRecurringJob`, registered like the others) runs a **two-tier** retention sweep over
the append-only `ops.AuditLogs`: **financial/verification** entity types (`Refund`, `NurseClawback`,
`NursePayout`, `NursePayoutBatch`, `NurseVerification`, `PlatformConfig`, `PartnerCenter`) keep a long legal window
(`audit_retention_financial_days`, default **2555** ≈ 7 years); everyday rows a shorter one
(`audit_retention_general_days`, default **730** ≈ 2 years). Cadence key `audit_retention_scan_cadence_hours` (24).
- `IAuditLogger.PurgeExpiredAsync(...)` does the delete: oldest-first (Id is monotonic with `OccurredAt`), capped at
20 000 rows/run so a backlog drains across runs; age compared in memory (SQLite can't translate a `DateTimeOffset`
predicate), delete is a single id-keyed `ExecuteDeleteAsync`. Idempotent.
- Migration `RefinementPhase9TicketBodyEncryptionAndAuditRetention` seeds the three config keys (ids 2426).
### 9.5 — `TicketMessage.Body` encrypted; gRPC reflection gated to Development
- **Ticket bodies are the refund/dispute paper trail** (users type phone numbers, addresses, clinical detail) — now
**encrypted at rest** through the existing `IFieldEncryptor` converter (wired in `ApplicationDbContext`, like every
other PII column). The stored column is widened to `nvarchar(max)` (ciphertext is longer than plaintext); the 4000-
char plaintext limit stays a boundary-validation rule (Open/PostMessage validators). Body is never a SQL search/
filter predicate (the admin thread read decrypts per row), so losing SQL-searchability is an accepted trade-off.
- **gRPC decision — keep the plugin, gate reflection to Development.** The plugin exposes only the User service and
the client is HTTP/JSON, but removing it is more invasive than the risk warrants. gRPC **reflection** (which
advertises the full schema) is now registered/mapped **only in Development**. The HTTP/2-posture concern is already
mitigated (refinement-phase-5 set Kestrel `Http1AndHttp2`), so the plugin shares the mixed-protocol listener (ALPN
negotiates h2 for gRPC clients) — no dedicated port needed.
### 9.6 — Docs made honest
- **Mocks-registry:** pruned the 7 **stale duplicate 🔴 rows** (`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/
`ISettlementSplitProvider`/`IWebhookVerifier`/`IMoadianClient`/`ILicenseVerificationService`) the detailed rows
already correct; the recurring-jobs row is the real in-process scheduler; the `IPaymentCaptureSimulator` row now
reflects its 6.4 prod removal (fail-closed in prod; Dev/Testing mock is a test affordance). Added a phase-9 banner.
- **REQ tracker:** already honest — refinement-phase-3 marked every delivered/deferred/resolved REQ; this phase ships
no new contract, so no REQ status changed. (The pre-phase-3 "all 15 open" state the audit flagged is long fixed.)
- **Architecture maps:** `server/CLAUDE.md` updated (observability wiring, the audit-retention cron, ticket-body
encryption, the gRPC decision, the new Monitoring→Application edge); `runtime-services.md` updated (OTel
consolidation, tracing, health split).
## Scale & later — explicitly NOT MVP (recorded, not built). Each has a written pull-trigger.
| # | Deferred item | Where it lives today (the real MVP) | **Pull it when…** |
| --- | --- | --- | --- |
| 9.7 | **Elasticsearch read backend + outbox feeder**`ElasticNurseSearch` + the CDC/outbox stream | `SqlNurseSearch` is real & correct; `Search:Backend` fails fast on any non-`sql` value | **SQL search shows strain** (latency/throughput on `nurse_search_index`). Build `ElasticNurseSearch` (same filters/sort/paging) + the outbox feeder off `ISearchIndexMaintainer`; keep SQL as the reconciliation source (`RebuildAsync`). |
| 9.8 | **Analytics pipeline** — warehouse/stream sink | `IAnalyticsSink` writes `ops.SystemEvents` fire-and-forget (real, queryable) | **Product needs cross-event analytics** beyond SQL queries. Pipe `SystemEvents` to a warehouse/stream (e.g. Kafka→ClickHouse), keeping fire-and-forget semantics. |
| 9.9 | **Holiday-calendar feed** — automated lunar-Hijri drift feed | `IHolidayCalendar` reads the seeded, manually-maintained `ops.IranianHolidays` table (real) | **The manual yearly refresh becomes a burden.** A **yearly ops-checklist item to top up the table is an acceptable MVP alternative** to a feed — the read interface stays. |
| 9.10 | **Push/SMS notification channels** — SMS/FCM fan-out | `InAppNotificationDispatcher` writes real in-app `ops.Notifications`; non-InApp channels are dropped by design | **The notification UX demands** out-of-app reach. Fan out to SMS (via the phase-8 `ISmsSender`) and FCM push behind the same `INotificationDispatcher`. |
| 9.11 | **Deferred product tables**`organizations`, `organization_nurses`, `fraud_flags`, `recurring_booking_schedules`, `bnpl_settlement_entries`, availability slots, customer national-ID KYC, geo bulk import | All **verified absent**; each is a **pure additive migration** when product pulls it | **Product pulls the feature.** No structural blocker — additive migration + feature slice; nothing in the current schema needs to change first. |
**These are decisions, not gaps.** SQL search, in-app notifications, and the manual holiday table are the real,
correct MVP; Elasticsearch/analytics/push each has a concrete trigger above and stays out until then.
## How it was verified
- **Build:** `dotnet build Baya.sln` — 0 new warnings (the pre-existing NU1510 + NU1903 transitive-dependency audit
warnings are unrelated to this phase).
- **Tests:** `dotnet test Baya.sln` — all green. New: `AuditLogRetentionTests` (two-tier purge + idempotency),
`TicketMessageEncryptionTests` (encrypted at rest + round-trips on read), `HealthCheckApiTests` (liveness healthy
without dependencies).
- **Trace/requestId:** a request's `ApiResult.requestId` is `Activity.Current.TraceId` (W3C) — the same id a
configured OTLP collector records.
- **No PII in logs:** the OTP code is no longer logged in any environment; clinical text/IBANs are encrypted/masked.
## Follow-ups for later phases
- Wire an OTLP collector (Grafana Tempo / Jaeger / OTEL Collector) in the deploy topology and set
`OpenTelemetry:Otlp:Endpoint` to turn tracing export on.
- When the first `redis` dependency lands (>1 instance), add its readiness check to `/healthz/ready`.
- The NU1903 transitive-dependency vulnerability warnings (`Microsoft.OpenApi`, `SQLitePCLRaw`) are a separate
dependency-bump task, out of this phase's scope.
+140 -36
View File
@@ -35,7 +35,7 @@ You are a **senior .NET software engineer** working on this codebase. That means
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern) - **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization - **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging - **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning - **OpenTelemetry** (metrics + tracing; Prometheus-scrape at `/metrics`, opt-in OTLP export) for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests - **xUnit** + **NSubstitute** for tests
- All NuGet versions are centrally pinned in `Directory.Packages.props` - All NuGet versions are centrally pinned in `Directory.Packages.props`
@@ -56,9 +56,19 @@ You are a **senior .NET software engineer** working on this codebase. That means
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` | | Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Default URL:** `https://localhost:5002` — Swagger at `/swagger`. **Default URL:** `https://localhost:5002` — Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()`, `SeedDefaultUsersAsync()`, `SeedPaymentGatewaysAsync()` **Migrations are split from boot (refinement-phase-7).** `dotnet run -- migrate` (the deploy-time one-shot / a CI
— and, **only in Development**, `SeedDemoWorldAsync()` (the demo marketplace seeder, see Persistence below). `dotnet ef database update`) applies migrations + the idempotent seeders, then exits — so multi-instance boots never
A reachable SQL Server is required to start. race on DDL and the runtime login needs no permanent DDL rights. **In Development**, boot still migrates + seeds for
convenience: `Program.cs` calls `ApplyMigrationsAsync()` + `SeedDefaultUsersAsync()` (roles always; a bootstrap admin
**only if `Seed:AdminUsername`/`Seed:AdminPassword` are configured** — never a committed credential) + the
Development-only `SeedPaymentGatewaysAsync()` (sandbox gateway) + `SeedDemoWorldAsync()` (demo marketplace, see
Persistence below). **In deployed environments**, boot instead only *checks* the schema is current
(`EnsureSchemaUpToDateAsync` — fail fast on a pending migration) and seeds roles/break-glass admin (idempotent). A
reachable SQL Server is required to start. Startup **fails fast**
(`StartupSecretsGuard`) if a load-bearing secret — the DB connection strings, and in deployed environments the
JWE + field-encryption keys — is missing or left at its committed `SET_VIA_USER_SECRETS_OR_ENV` placeholder
(refinement-phase-5). Development supplies working dev-only crypto keys via `appsettings.Development.json`; only
the connection string must come from user-secrets (see [RUNBOOK](../dev/post-phase/refinement/RUNBOOK.md)).
--- ---
@@ -83,12 +93,12 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
src/ src/
├── Core/ ├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker) │ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), Booking/ (BookingRequest — the money-free pre-payment intent + BookingRequestStatus/BookingRequestTransitions forward-only status guard + CaregiverGender codes; b9 adds Booking/BookingSession/BookingCareInstruction/VisitVerification/CancellationPolicy + their status/transition tables + BookingAmounts money split), Payments/ (b10 ledger/txn/webhook/gateway + LedgerPosting; b11 adds Refunds/ + Invoices/), Bnpl/ (b12 BnplTransaction + BnplStatus/BnplTransitions/BnplEligibilityStatus/BnplProviderCodes — the net-of-fee card-payment model), Payouts/ (b13 NursePayoutBatch/NursePayout/NursePayoutBookingLink + PayoutBatchStatus/PayoutStatus/*Transitions — the weekly payout run), Reviews/ (b14 Review (IAuditable) + ReviewModerationStatus/ReviewModerationAction codes + ReviewTagMaster/ReviewTagLink + PatientCareRecord — moderated reviews, tag vocab & patient-scoped encrypted clinical notes), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers) │ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; Booking area = the b8 booking-request lifecycle (create/accept/reject/cancel + role-scoped inbox/detail + the expiry sweep command); Bookings area = the b9 booking engine (convert/detail/list/transition, care-instructions submit+gated read, EVV check-in/out + today's sessions + admin EVV queue, cancel booking/session, no-show sweep, cancellation-policy CRUD); Payments area = the b10 money core (initiate/webhook/confirm-post-ledger/nurse-payable-balance); Refunds + Invoices areas = the b11 reversal leg (create refund/confirm-settlement/mark-failed [refinement-phase-6: the BNPL/manual `processing → succeeded` clearing]/write-off clawback/list/refund-status; issue invoice/get invoice); Bnpl area = the b12 provider-financed-installment checkout (eligibility/initiate/verify/settle/revert/callback/status + BookingConversion shared with b10); Payouts area = the b13 weekly payout engine (compute-eligible/generate-batch/process/retry/mark-failed + admin batch detail/list + nurse history; PayoutSettlement shared ledger+clawback-netting step); Reviews area = the b14 reviews & ratings (submit/moderate/attach-tags + public list/tag-aggregates + admin moderation-queue; RecomputeNurseRating from-source helper + ReviewCache); PatientCareRecords area = the b14 encrypted patient-scoped clinical notes (write/history under strict clinical access); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + Contracts/Reviews IReviewModerationService (AI review pre-screen seam) + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository + IReviewRepository + IPatientCareRecordRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/ ├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch + Booking/ = BookingRequestExpiryHostedService) │ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/ + BookingConfig/ — b8 BookingRequest + b9 bookings/sessions/care/EVV/cancellation-policy configs & seed + ReviewsConfig/ — b14 reviews/tags-master (seeded)/tag-links/patient-care-records configs), Repositories/ (incl. b9 BookingRepository + CancellationPolicyRepository + b14 ReviewRepository + PatientCareRecordRepository), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + Scheduling/ = RecurringJobSchedulerHostedService + Jobs/ (the IRecurringJob crons — refinement-phase-7) + Search/ = SearchIndexMaintainer + SqlNurseSearch)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/) │ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams │ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier + MockPaymentCaptureSimulator + MockBankTransferProvider + MockReviewModerationService) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net │ └── Baya.Infrastructure.Monitoring HealthChecks (live/ready split + IObjectStorage write-probe → refs Baya.Application), OpenTelemetry (one stack: metrics + tracing, opt-in OTLP)
├── API/ ├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json │ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Development-only Dev (dev/last_otp OTP helper, 404 outside Development) + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch + customer/nurse BookingRequests + admin AdminBookingRequests + customer/nurse/admin Bookings + nurse/admin BookingSessions + admin AdminEvv + admin AdminCancellationPolicies + customer PaymentsController + public WebhooksController + admin AdminRefunds/AdminClawbacks/AdminInvoices + customer Refunds/Invoices + customer CheckoutBnpl + public WebhooksBnpl + admin AdminBnpl + admin AdminPayouts + nurse NursePayouts + customer BookingReviews (submit) + owner/admin Reviews (tags + moderate status) + admin AdminReviews (moderation queue) + public Nurses (reviews + review_tags) + nurse/owner/admin PatientCareRecords), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting) │ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
@@ -114,6 +124,30 @@ Application reference Infrastructure or the API — this is a hard rule.
real provider is a registration change — handlers depend only on the contract. Audit fields are real provider is a registration change — handlers depend only on the contract. Audit fields are
stamped by `AuditFieldInterceptor` (Persistence), not in handlers. stamped by `AuditFieldInterceptor` (Persistence), not in handlers.
**External rails go real — config-selected vendor adapters (refinement-phase-8).** Every vendor rail now has a
**real HTTP adapter** in `Baya.Infrastructure.CrossCutting/Seams/Real/`, **config-selected** by a per-rail
`Seams:*:Provider` selector in `AddCrossCuttingSeams` (default = the mock, so an unconfigured env is unchanged;
a typo falls closed to the mock). Real adapters use `HttpClient` (typed via `IHttpClientFactory`) +
`System.Text.Json` + BCL crypto — **no new NuGet packages**; credentials come from `Seams:*` (user-secrets/env,
never committed). Swapping is a registration change; **no handler is touched**. The adapters:
`KavenegarSmsSender` (`Sms:Provider=kavenegar`**launch-critical**; when a real provider is selected the
Development OTP-in-logs bridge is **disabled**, so the OTP is never logged), `Finnotech{Shahkar,IdentityKyc,
BankAccountOwnership}` (`{Shahkar,IdentityKyc,BankOwnership}:Provider=finnotech`, shared `Seams:Finnotech`
creds), `NeshanGeocoder` (`Geocoding:Provider=neshan`), `S3ObjectStorage` (`ObjectStorage:Provider=s3` — MinIO/
S3/ArvanCloud via **manual AWS SigV4**, presigned GET = the real b6 signed-URL contract), `ZarinPalPaymentProvider`
+ `HmacWebhookVerifier` (per-provider HMAC over the raw body) + `ProviderSettlementSplitProvider`
(`Payments:Provider=zarinpal`), `SnappPayBnplProvider`/`DigipayBnplProvider` + `ConfiguredBnplProviderResolver`
(`Bnpl:Provider=real`; **`balinyaar` = in-house, resolves to the net-of-fee model, no external API**),
`JibitBankTransferProvider` (`BankTransfer:Provider=jibit`**async rail**: accepts as `submitted`, the
reconciliation callback `POST webhooks/payouts/{provider}``ReconcilePayoutBatchCommand` [HMAC-verified] flips
`submitted → paid/failed`), and `MoadianClient` (`Moadian:Provider=moadian`) with the `MoadianReconciliationJob`
`IRecurringJob` (6 h, walks `pending/submitted → registered`). **6.4:** `IPaymentCaptureSimulator` is out of the
production registration — prod gets the fail-closed `DisabledPaymentCaptureSimulator`; Dev/Testing re-register the
succeeding `MockPaymentCaptureSimulator` (the `bookings/convert` path is a Dev/Testing affordance — prod converts
via the b10 webhook confirm). **5.6:** `ICredentialVerifier`/`ILicenseVerificationService` stay mock —
**manual MoH/INO/eNamad review is the intended MVP** (no public B2B API). `ICurrencyNormalizer` is already
config-driven (the real impl). See the mocks-registry for the per-rail config keys.
**Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated **Platform-signal facades (backend-phase-1).** The cross-cutting marketplace tables live in a dedicated
**`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`, **`ops` schema** (mirroring how Identity uses `usr`): `PlatformConfigs`, `AuditLogs`, `SystemEvents`,
`IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application `IranianHolidays`, `Notifications`, `SupportAlerts`. Because they are DB-backed, their Application
@@ -122,11 +156,13 @@ contracts — `IPlatformConfig` (typed cached config), `IHolidayCalendar` (bank-
trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal trail), `INotificationService` (per-user notification reads/commands), `ISupportAlertService` (internal
worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by worklist) — are implemented in **`Baya.Infrastructure.Persistence/Services/`** and registered by
`AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app `AddPersistenceServices`, *not* in CrossCutting. The real `INotificationDispatcher` (in-app
`notifications` write) also lives there and **supersedes** the b0 log stub. The `notifications` write) also lives there and **supersedes** the b0 log stub. Other domains call these
`NotificationRetentionHostedService` (the retention/`IJobScheduler` seam) is registered as a hosted contracts; they never re-create the tables. The
service there too. Other domains call these contracts; they never re-create the tables. The
`AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity `AuditFieldInterceptor` additionally writes an append-only `audit_logs` row for any `IAuditable` entity
(currently `PlatformConfig`) in the same transaction as the change. (`PlatformConfig`, `PartnerCenter`, `Review`, and — refinement-phase-6 — the admin-decided money & trust
entities `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`, `NurseVerification`; encrypted columns
like `NursePayout.IbanSnapshot` carry `[AuditRedacted]` so the diff records a marker, never plaintext) in the
same transaction as the change.
**Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine, **Identity profiles, patients & nurse bank accounts (backend-phase-3).** On top of the b2 auth spine,
the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded the `usr` schema gains four role-attached tables: `NurseProfiles` (1:1 with `Users`; guarded
@@ -233,8 +269,8 @@ b9/b10). One customer requests one nurse for a patient/variant/address/date; the
30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire. 30-minute payment window) or rejects before a frozen response deadline; unanswered/unpaid requests auto-expire.
Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in Features under `Baya.Application/Features/Booking/{Commands|Queries}/`; config in
`Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`; `Persistence/Configuration/BookingConfig/`; per-domain repo (`IBookingRequestRepository`) on `IUnitOfWork`;
the recurring sweep is `Persistence/Services/Booking/BookingRequestExpiryHostedService` (reuses the b1 the recurring expiry sweep is the `booking_request_expiry` `IRecurringJob` run by the scheduler (see
`IJobScheduler`/`BackgroundService` seam). Load-bearing rules: "Unattended operation" below — refinement-phase-7 re-homed it from a standalone hosted service). Load-bearing rules:
- **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment - **No money, ever, and no `bookings` row.** A request carries no price/total; accept only opens the payment
window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`. window. b9 consumes an `accepted_awaiting_payment` request → creates the booking → sets it `converted`.
- **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes` - **Two-stage clinical disclosure (stage 1).** The nurse sees **only** the unencrypted, limited `customer_notes`
@@ -350,16 +386,23 @@ per-domain repos `IRefundRepository` + `IInvoiceRepository` on `IUnitOfWork`; co
external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card = external reference (`gateway_refund_reference` vs `external_revert_reference`), and the ETA differ (card =
immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config immediate `succeeded` + clearing posts now; BNPL = `processing` + `expected_customer_refund_eta` ≈ now + config
business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only business days, clearing deferred to reconciliation). The `refund_payable ↔ escrow_held` clearing posts only
once the customer cash-back confirms. once the customer cash-back confirms — **reached (refinement-phase-6) by `ConfirmRefundSettlementCommand`**
(admin `POST admin_refunds/{id}/confirm_settlement` + the BNPL cash-back callback branch), which transitions
`processing → succeeded`, stamps the settled instant, and posts `LedgerPosting.RefundPayableClearing` in the
same commit (idempotent under `booking:{id}:refund`); `MarkRefundSettlementFailedCommand` (`.../mark_failed`)
is the counterpart. **The refund row is now persisted (approved) *before* the external channel call** — the
crash-window fix (claim-first / execute-second), matching the webhook handler.
- **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes - **Invoices: VAT on the commission line only, sequential number.** `IssueInvoiceCommand` computes
`vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0), `vat_irr = round(platform_commission_irr × vat_rate)` (config `vat_rate`, default 0.10; `vat_rate = 0` ⇒ 0),
never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row never on the nurse payout, and draws a gap-free `invoice_number` from the `InvoiceNumberSequences` counter row
(locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per (locked + committed with the invoice, portable across SQL Server/SQLite — no DB sequence). Idempotent per
booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits booking (`UNIQUE(booking_id)`). `IMoadianClient` (introduced here; `MockMoadianClient` in CrossCutting) submits
to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`). to سامانه مودیان — mock leaves `moadian_status = pending` / no ref (config can force `registered`).
- **Forward-deps as nullable columns, no FK.** `refunds.ticket_id` (tickets → b15; "ticket required" is the - **Forward-dep columns — FKs added in refinement-phase-6.** `refunds.ticket_id` (→ `messaging.Tickets`),
config-gated `refund_ticket_required` rule, off by default), `nurse_clawbacks.original_payout_id` / `nurse_clawbacks.original_payout_id` / `recovered_in_payout_id` (→ `payouts.NursePayouts`),
`recovered_in_payout_id` (nurse_payouts → b13), `invoices.partner_center_id` (partner_centers → b15). The `invoices.partner_center_id` (`partner.PartnerCenters`, + index) now carry real FKs (`ON DELETE NO ACTION`;
all nullable) — b15 unconditionally auto-opens the refund ticket so `refunds.ticket_id` is always non-null, and
the orphaned `refund_ticket_required` config key was retired (its rule had no consumer left). The
data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is data-model's `manual_bank` channel is stored/served as the canonical wire code **`manual`**. `IBnplProvider` is
introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real introduced here as a **thin local stub** so the `bnpl_revert` path runs before b12 merges — **b12 owns the real
seam definition**. seam definition**.
@@ -497,6 +540,52 @@ controllers `TicketsController` / `AdminTicketsController` / `AdminPartnerCenter
manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam manual-approve at MVP; `VerifyPartnerCenter` records the human decision. There is **no** telephony/VoIP seam
(the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase. (the emergency call is an out-of-platform `tel:` link by design). This is the last backend phase.
**Unattended operation — the recurring-job scheduler (refinement-phase-7).** A single in-process scheduler,
`Persistence/Services/Scheduling/RecurringJobSchedulerHostedService`, drives every registered `IRecurringJob`
(`Services/Scheduling/Jobs/`) on its own cadence — replacing the two stand-alone `PeriodicTimer` hosted services
and giving the previously admin-manual sweeps a schedule, **using no new infrastructure** (SQL Server stays the
only external dependency). Jobs, each reading its seeded `platform_configs` cadence key via `IPlatformConfig`:
`booking_request_expiry` (1 min const) · `notification_retention` (24 h const) · `verification_expiry_scan`
(`verification_expiry_scan_cadence_hours`) · `no_show_sweep` (`no_show_scan_cadence_hours`) ·
`weekly_payout_generation` (`nurse_payout_interval_days`) · `MoadianReconciliationJob` (6 h, refinement-phase-8) ·
`audit_log_retention` (`audit_retention_scan_cadence_hours`, refinement-phase-9). Load-bearing rules:
- **Add a cron = implement `IRecurringJob` + one `AddSingleton<IRecurringJob, …>()`** in `AddPersistenceServices`.
Phase 8 registers the Moadian reconciliation + refund-settlement poll exactly this way. The scheduler owns the
per-tick DI scope, error isolation (a throwing tick never kills the loop), and the lock; a job says only *how
often* and *what one idempotent run does*.
- **Jobs must be idempotent** — a retry (or a second instance once the lock is Redis-backed) must never double-pay
or double-post; the DB uniques/state-machines are the backstop. Each tick runs under
`IDistributedLock("scheduler:{name}")` — in-proc today, the **>1-instance scale-out gate** (swap the seam to
Redis to serialize ticks across nodes; single-instance MVP needs neither Redis nor Hangfire/Quartz).
- **Money movement stays human-approved.** The payout job schedules *generation* only (a `draft` batch, recorded
system-initiated — `NursePayoutBatch.InitiatedByAdminId` is nullable = "no human initiator"); the irreversible
`process` step remains an explicit admin action. The command's `SystemInitiated` flag is scheduler-only —
`AdminPayoutsController` neutralizes any request-supplied value.
- **Admin manual triggers remain overrides** (the same idempotent commands). The scheduler is **dormant under the
`Testing` environment** so integration tests stay deterministic; each job/command is unit-tested directly.
- **Audit-log retention (refinement-phase-9 §9.4)** is an `IRecurringJob` (`AuditLogRetentionJob`) over the
append-only `ops.AuditLogs`: a **two-tier** sweep via `IAuditLogger.PurgeExpiredAsync` — financial/verification
entity types (`Refund`/`NurseClawback`/`NursePayout`/`NursePayoutBatch`/`NurseVerification`/`PlatformConfig`/
`PartnerCenter`) keep `audit_retention_financial_days` (default 2555 ≈ 7 yr); everyday rows
`audit_retention_general_days` (default 730 ≈ 2 yr). Oldest-first, capped, id-keyed delete; idempotent.
**Observability (refinement-phase-9).** One **OpenTelemetry** stack (`Baya.Infrastructure.Monitoring`,
`SetupOpenTelemetry`): metrics (runtime + ASP.NET Core + the `mediator_meter` histogram) scraped at `/metrics` via
the OTel Prometheus exporter, and **tracing** (ASP.NET Core + EF Core) sharing `service.name = Baya.Web.Api`. The
duplicate prometheus-net stack was removed. **OTLP export (traces + metrics) is opt-in** — wired only when
`OpenTelemetry:Otlp:Endpoint` is set, so an MVP with Prometheus alone runs unchanged. `ApiResult.RequestId` is the
W3C trace id (`Activity.Current.TraceId`, `Activity.DefaultIdFormat = W3C`), so a support ticket maps 1:1 to a
trace. **Health checks split** (`ConfigureHealthChecks`/`UseHealthChecks`): `/healthz/live` (process, dependency-
free), `/healthz/ready` (app DB + `logDb` [deployed only] + an `IObjectStorage` write-probe), `/HealthCheck`
(aggregate, kept for compat). **Logs:** deployed envs write **Information+** to `Baya_Logs` (framework categories
held at Warning); **no PII/secrets** — the mock SMS sender never logs the OTP code; clinical text/IBANs are
encrypted/masked. The dead Elasticsearch sink + package were removed (SQL sink is the deployed default; set the
OTLP collector to ship logs off-box). **gRPC reflection is Development-only** (`GrpcPluginStartup` gates
`AddGrpcReflection`/`MapGrpcReflectionService` on `IsDevelopment`); the plugin shares the mixed-protocol Kestrel
listener. **`TicketMessage.Body` is encrypted at rest** through `IFieldEncryptor` (converter in
`ApplicationDbContext`; column widened to `nvarchar(max)`; the 4000-char cap stays a boundary-validation rule) —
ticket bodies are the refund/dispute paper trail (phone numbers, addresses, clinical detail).
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or **Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -510,25 +599,29 @@ only canonical if it stays accurate.
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`): Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
``` ```
ConfigureHealthChecks() · SetupOpenTelemetry() builder.ValidateRequiredSecrets() // refinement-phase-5: fail fast on missing/placeholder DB + crypto secrets
ConfigureHealthChecks() · SetupOpenTelemetry() // refinement-phase-9: live/ready health split + object-storage probe; one OTel stack (metrics + tracing, opt-in OTLP)
AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate) AddApplicationServices() // Mediator + pipeline behaviors (Logging → Metrics → Validate)
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies, ICurrentUser + IHttpContextAccessor RegisterIdentityServices(…, requireHttpsMetadata) // Identity, JWT/JWE (RequireHttpsMetadata on outside Dev/Testing), ICurrentUser
AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories AddPersistenceServices(...) // DbContext (+ AuditFieldInterceptor), UnitOfWork, repositories, the IRecurringJob crons + RecurringJobSchedulerHostedService (refinement-phase-7)
AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks) AddCrossCuttingSeams(config) // IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher (mocks)
AddWebFrameworkServices() // API versioning + snake_case routing AddWebFrameworkServices() // API versioning + snake_case routing
AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev) AddCorsPolicies(config) // browser CORS policy from Cors:AllowedOrigins (refinement-phase-0; default http://localhost:3000 in Dev)
AddRateLimitingPolicies() // built-in rate limiter: per-IP global + named (otp/auth/sensitive) AddForwardedHeadersConfiguration(config) // refinement-phase-5: trust ForwardedHeaders:KnownProxies/KnownNetworks so the rate limiter sees the real client IP behind a proxy
AddRateLimitingPolicies() // built-in rate limiter: per-resolved-IP global + named (otp/auth/sensitive/webhook)
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster() AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices() ConfigureGrpcPluginServices(builder.Environment) // refinement-phase-9: gRPC reflection registered only in Development
// Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each // Development-only: AddDevelopmentOtpCapture() (refinement-phase-0) decorates ISmsSender to capture each
// OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development. // OTP in-memory for the GET /api/v1/dev/last_otp/{phone} helper — never wired outside Development.
``` ```
Pipeline order: exception handler → Swagger → routing → **CORS → rate limiter → authentication → Pipeline order: **forwarded headers** exception handler → Swagger → routing → **CORS → rate limiter →
authorization** → controllers → metrics → health checks → gRPC. `UseCors(...)` (refinement-phase-0) sits authentication → authorization** → controllers → metrics → health checks → gRPC. `UseForwardedHeaders()`
**after `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the (refinement-phase-5) is **first** so the resolved client IP (`X-Forwarded-For` from a trusted proxy) is in
limiter/auth run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP place before the rate limiter partitions on it. `UseCors(...)` (refinement-phase-0) sits **after
attempts are rejected (`429`) before hitting the auth stack. `UseRouting()` and before `UseRateLimiter()`** so a pre-flight `OPTIONS` is answered before the limiter/auth
run; `UseRateLimiter()` is placed **before** `UseAuthentication()` so over-limit auth/OTP attempts are
rejected (`429`) before hitting the auth stack.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` When adding new infrastructure, expose it as an extension method and call it from `Program.cs`
never inline registrations there directly. never inline registrations there directly.
@@ -575,8 +668,11 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
- **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs` - **Development demo seeder (refinement-phase-1).** `Persistence/Services/Seeding/DemoWorldSeeder.cs`
(+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference (+ `DemoWorldDefinitions.cs`) idempotently populates a coherent demo marketplace on top of the reference
`HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials `HasData` seeds — 3 nurses (2 verified w/ variants + Tehran coverage + `approved` verification + credentials
+ a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), and one + a `matched_national_id` bank account, 1 unverified), 2 customers (patients + addresses), **2 phone-OTP
cross-category required demo option group (شیفت / *Shift Type*). It writes through the real entities and admins** (refinement-phase-2: a `super_admin` + a scoped `finance` operator, so the `/admin` console is
reachable through the normal phone-OTP login and `useAdminCapabilities` gating is demonstrable — admin
sub-roles are server-granted, never self-selectable), and one cross-category required demo option group
(شیفت / *Shift Type*). It writes through the real entities and
drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows), drives the search projection through `ISearchIndexMaintainer.RebuildAsync` (never hand-inserts index rows),
guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()` guarding each persona on its phone number so re-runs are a no-op. Invoked via `SeedDemoWorldAsync()`
**only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is **only under `IsDevelopment()`** — never in Production/Staging. The demo world (phones, which nurse is
@@ -603,18 +699,26 @@ action to `sender.Send(...)`. Full conventions are in [CONVENTIONS.md](CONVENTIO
process-wide singleton because EF caches the model). Equality lookups go through the deterministic process-wide singleton because EF caches the model). Equality lookups go through the deterministic
`PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the `PhoneHash` column (UNIQUE, synced on SaveChanges — which also resets `ShahkarVerifiedAt` when the
phone actually changes). Never query `PhoneNumber == x`. phone actually changes). Never query `PhoneNumber == x`.
- **Roles:** full vocabulary in `Domain/Entities/User/RoleNames` (seeded by `SeedDataBase`). - **Roles:** full vocabulary in `Domain/Entities/User/RoleNames`; `SeedDataBase` always seeds the roles,
`customer`/`nurse` are self-selectable via `POST me/select_role` (audited and seeds a **bootstrap admin only when `Seed:AdminUsername`/`Seed:AdminPassword` are configured**
`granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are internal-only and (refinement-phase-5 — no more committed `admin`/`qw123321`; break-glass only, day-to-day admins come from
return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants disappear the phone-OTP demo seeds or are provisioned out-of-band). `customer`/`nurse` are self-selectable via
from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`, `POST me/select_role` (audited `granted_by`/`granted_at`, idempotent, both can be held); admin sub-roles are
internal-only and return 403 there. `user_roles.revoked_at` has a global query filter, so revoked grants
disappear from every role read automatically. Auth knobs (`auth_otp_resend_seconds`, `auth_otp_max_attempts`,
`auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`. `auth_session_ttl_days`) are `platform_configs` rows read via `IPlatformConfig`.
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route - Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route
values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay values and checks role claims. Always use `[controller]`/`[action]` tokens so the keys stay
consistent (see CONVENTIONS.md §1 Routing). consistent (see CONVENTIONS.md §1 Routing).
- Settings bound from `appsettings.json``IdentitySettings`. - Settings bound from `appsettings.json``IdentitySettings`. **JWE keys are never committed**: the
committed values are `SET_VIA_USER_SECRETS_OR_ENV` placeholders (real ones via user-secrets/env; Development
uses dev-only keys in `appsettings.Development.json`). `RequireHttpsMetadata` is **on outside Dev/Testing**
(passed into `RegisterIdentityServices`), the access-token lifetime is `ExpirationMinutes: 60`, and
`Issuer`/`Audience` are real (`Balinyaar`/`BalinyaarClient`) — refinement-phase-5.
- Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use - Auth and OTP endpoints must be rate-limited (CONVENTIONS.md §11) — `request_otp`/`verify_otp` use
the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. the `otp` policy, `refresh` the `auth` policy; plus a per-phone resend window via `ICacheService`. The two
PSP/BNPL webhooks share the single deliberate **`webhook`** policy (bursty-tolerant, partitioned per-provider);
behind a reverse proxy the limiter partitions on the forwarded client IP (see Startup wiring).
--- ---
+3 -4
View File
@@ -36,19 +36,18 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" /> <PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" /> <PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" /> <PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" /> <PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" /> <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" /> <PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Pluralize.NET" Version="1.0.2" /> <PackageVersion Include="Pluralize.NET" Version="1.0.2" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" /> <PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" /> <PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" /> <PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" /> <PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.1" /> <PackageVersion Include="System.Linq.Async" Version="7.0.1" />
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
namespace Baya.Web.Api.Configuration;
/// <summary>
/// Fail-fast validation that no load-bearing secret is missing or left at its committed placeholder.
/// A database connection is required in every real environment; the JWE + field-encryption keys are
/// required only in <b>deployed</b> environments (Development keeps working dev-only defaults in
/// <c>appsettings.Development.json</c>, and the "Testing" environment runs on in-memory SQLite with
/// test-injected keys). The effect: a fresh clone with no user-secrets stops at boot with a clear
/// message instead of silently connecting somewhere unintended, and a deployment can never fall back
/// to a committed placeholder key.
/// </summary>
public static class StartupSecretsGuard
{
// Substrings that mark a value as a committed placeholder, never a real secret. Any configured value
// containing one of these is treated as "not provided".
private static readonly string[] PlaceholderMarkers =
[
"SET_VIA_USER_SECRETS_OR_ENV",
"not-for-production",
"change-me",
"ShouldBe-LongerThan-16Char-SecretKey",
"16CharEncryptKey"
];
public static void ValidateRequiredSecrets(this WebApplicationBuilder builder)
{
// Integration tests boot as "Testing" over in-memory SQLite and inject their own crypto keys.
if (builder.Environment.IsEnvironment("Testing"))
return;
var config = builder.Configuration;
var errors = new List<string>();
RequireReal(errors, "ConnectionStrings:SqlServer", config.GetConnectionString("SqlServer"));
RequireReal(errors, "ConnectionStrings:logDb", config.GetConnectionString("logDb"));
// Development supplies working dev-only keys via appsettings.Development.json; only deployed
// environments must inject real per-environment secrets (env vars / Key Vault / KMS).
if (!builder.Environment.IsDevelopment())
{
RequireReal(errors, "IdentitySettings:SecretKey", config["IdentitySettings:SecretKey"]);
RequireReal(errors, "IdentitySettings:Encryptkey", config["IdentitySettings:Encryptkey"]);
RequireReal(errors, "Seams:FieldEncryption:Key", config["Seams:FieldEncryption:Key"]);
RequireReal(errors, "Seams:FieldEncryption:HashKey", config["Seams:FieldEncryption:HashKey"]);
}
if (errors.Count == 0)
return;
throw new InvalidOperationException(
"Refusing to start: required secret configuration is missing or still a committed placeholder. " +
"Provide real values via user-secrets (Development) or environment variables (deployed) — see " +
"dev/post-phase/refinement/RUNBOOK.md.\n - " + string.Join("\n - ", errors));
}
private static void RequireReal(List<string> errors, string key, string? value)
{
if (string.IsNullOrWhiteSpace(value))
errors.Add($"{key} is not set.");
else if (PlaceholderMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase)))
errors.Add($"{key} is still a committed placeholder.");
}
}
@@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter; using Baya.Application.Features.PartnerCenters.Commands.CreatePartnerCenter;
using Baya.Application.Features.PartnerCenters.Commands.SetPartnerCenterActive;
using Baya.Application.Features.PartnerCenters.Commands.SponsorNurse; using Baya.Application.Features.PartnerCenters.Commands.SponsorNurse;
using Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter; using Baya.Application.Features.PartnerCenters.Commands.UpdatePartnerCenter;
using Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter; using Baya.Application.Features.PartnerCenters.Commands.VerifyPartnerCenter;
@@ -48,6 +49,12 @@ public sealed class AdminPartnerCentersController(ISender sender) : BaseControll
public async Task<IActionResult> SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken) public async Task<IActionResult> SponsorNurse(long id, SponsorNurseCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken)); => OperationResult(await sender.Send(command with { CenterId = id }, cancellationToken));
// Activate/suspend toggle (distinct from verify, which records licensing approval) — REQ-032.
[HttpPost("{id}/set-active")]
[ProducesOkApiResponseType<PartnerCenterDetailDto>]
public async Task<IActionResult> SetActive(long id, SetPartnerCenterActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpGet] [HttpGet]
[ProducesOkApiResponseType<PagedResult<PartnerCenterListItemDto>>] [ProducesOkApiResponseType<PagedResult<PartnerCenterListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken) public async Task<IActionResult> List([FromQuery] ListPartnerCentersQuery query, CancellationToken cancellationToken)
@@ -42,7 +42,9 @@ public sealed class AdminPayoutsController(ISender sender) : BaseController
[HttpPost("batches")] [HttpPost("batches")]
[ProducesOkApiResponseType<GeneratePayoutBatchResult>] [ProducesOkApiResponseType<GeneratePayoutBatchResult>]
public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken) public async Task<IActionResult> Generate(GeneratePayoutBatchCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken)); // SystemInitiated is scheduler-only — neutralize any request-supplied value so an API caller can never
// record a batch without an authenticated admin initiator (refinement-phase-7).
=> OperationResult(await sender.Send(command with { SystemInitiated = false }, cancellationToken));
[HttpPost("batches/{id}/process")] [HttpPost("batches/{id}/process")]
[ProducesOkApiResponseType<ExecutePayoutBatchResult>] [ProducesOkApiResponseType<ExecutePayoutBatchResult>]
@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Refunds.Commands.ConfirmRefundSettlement;
using Baya.Application.Features.Refunds.Commands.CreateRefund; using Baya.Application.Features.Refunds.Commands.CreateRefund;
using Baya.Application.Features.Refunds.Commands.MarkRefundSettlementFailed;
using Baya.Application.Features.Refunds.Queries.ListRefunds; using Baya.Application.Features.Refunds.Queries.ListRefunds;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds; using Baya.Application.Models.Refunds;
@@ -37,4 +39,20 @@ public sealed class AdminRefundsController(ISender sender) : BaseController
[ProducesOkApiResponseType<PagedResult<RefundListItemDto>>] [ProducesOkApiResponseType<PagedResult<RefundListItemDto>>]
public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken) public async Task<IActionResult> List([FromQuery] ListRefundsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken)); => OperationResult(await sender.Send(query, cancellationToken));
// Reconciliation confirmed the customer cash-back for a processing BNPL/manual refund — settle it (posts the
// deferred refund_payable ↔ escrow_held clearing). Idempotent.
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RefundSettlementResult>]
public async Task<IActionResult> ConfirmSettlement(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new ConfirmRefundSettlementCommand(id), cancellationToken));
// Reconciliation reported the customer cash-back did not land — fail the processing refund (no ledger moves).
[HttpPost("{id}/[action]")]
[ProducesOkApiResponseType<RefundSettlementResult>]
public async Task<IActionResult> MarkFailed(long id, MarkRefundFailedBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new MarkRefundSettlementFailedCommand(id, body.Reason), cancellationToken));
} }
/// <summary>The mark-failed body (the id comes from the route).</summary>
public record MarkRefundFailedBody(string? Reason);
@@ -5,6 +5,7 @@ using Baya.Application.Features.Booking.Commands.CancelBookingRequest;
using Baya.Application.Features.Booking.Commands.CreateBookingRequest; using Baya.Application.Features.Booking.Commands.CreateBookingRequest;
using Baya.Application.Features.Booking.Commands.RejectBookingRequest; using Baya.Application.Features.Booking.Commands.RejectBookingRequest;
using Baya.Application.Features.Booking.Queries.GetBookingRequest; using Baya.Application.Features.Booking.Queries.GetBookingRequest;
using Baya.Application.Features.Booking.Queries.GetCheckoutSummary;
using Baya.Application.Features.Booking.Queries.ListBookingRequests; using Baya.Application.Features.Booking.Queries.ListBookingRequests;
using Baya.Application.Models.Booking; using Baya.Application.Models.Booking;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
@@ -57,4 +58,10 @@ public sealed class BookingRequestsController(ISender sender) : BaseController
[ProducesOkApiResponseType<BookingRequestDto>] [ProducesOkApiResponseType<BookingRequestDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken) public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken)); => OperationResult(await sender.Send(new GetBookingRequestQuery(id), cancellationToken));
// The C6 money breakdown for an accepted-awaiting-payment request (owner-scoped, server-computed).
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<CheckoutSummaryDto>]
public async Task<IActionResult> CheckoutSummary(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCheckoutSummaryQuery(id), cancellationToken));
} }
@@ -1,6 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Reviews.Commands.SubmitReview; using Baya.Application.Features.Reviews.Commands.SubmitReview;
using Baya.Application.Features.Reviews.Queries.GetMyReview;
using Baya.Application.Features.Reviews.Queries.GetReviewEligibility;
using Baya.Application.Models.Reviews; using Baya.Application.Models.Reviews;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
@@ -27,6 +29,18 @@ public sealed class BookingReviewsController(ISender sender) : BaseController
=> OperationResult(await sender.Send( => OperationResult(await sender.Send(
new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken)); new SubmitReviewCommand(bookingId, body.Rating, body.Body, body.TagCodes), cancellationToken));
// Can the caller review this booking? (completed/closed AND not already reviewed) — REQ-026.
[HttpGet("{bookingId}/review_eligibility")]
[ProducesOkApiResponseType<ReviewEligibilityDto>]
public async Task<IActionResult> ReviewEligibility(long bookingId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetReviewEligibilityQuery(bookingId), cancellationToken));
// The caller's own review for the booking (persistent "under review" state across sessions) — REQ-026.
[HttpGet("{bookingId}/my_review")]
[ProducesOkApiResponseType<MyReviewDto>]
public async Task<IActionResult> MyReview(long bookingId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetMyReviewQuery(bookingId), cancellationToken));
/// <summary>The review body (the booking id comes from the route).</summary> /// <summary>The review body (the booking id comes from the route).</summary>
public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList<string>? TagCodes); public record SubmitReviewBody(int Rating, string? Body, IReadOnlyList<string>? TagCodes);
} }
@@ -7,8 +7,11 @@ using Baya.Application.Features.Bookings.Commands.TransitionBookingStatus;
using Baya.Application.Features.Bookings.Queries.GetBookingDetail; using Baya.Application.Features.Bookings.Queries.GetBookingDetail;
using Baya.Application.Features.Bookings.Queries.GetCareInstructions; using Baya.Application.Features.Bookings.Queries.GetCareInstructions;
using Baya.Application.Features.Bookings.Queries.ListBookings; using Baya.Application.Features.Bookings.Queries.ListBookings;
using Baya.Application.Features.Refunds.Commands.CancelBookingAndRefund;
using Baya.Application.Features.Refunds.Queries.GetCancellationPolicyPreview;
using Baya.Application.Models.Booking; using Baya.Application.Models.Booking;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Application.Models.Refunds;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration; using Baya.WebFramework.ServiceConfiguration;
@@ -60,6 +63,19 @@ public sealed class BookingsController(ISender sender) : BaseController
public async Task<IActionResult> Cancel(long id, CancelBookingCommand command, CancellationToken cancellationToken) public async Task<IActionResult> Cancel(long id, CancelBookingCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken)); => OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
// Customer-initiated cancel: cancels the booking AND opens its refund in one call (REQ-019).
[HttpPost("{id}/cancel")]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[ProducesOkApiResponseType<RefundStatusDto>]
public async Task<IActionResult> CancelAndRefund(long id, CancelBookingAndRefundCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { BookingId = id }, cancellationToken));
// Pre-cancel disclosure: the applicable policy + per-session refundability, resolved by current lead time (REQ-020).
[HttpGet("{id}/cancellation_policy")]
[ProducesOkApiResponseType<CancellationPolicyPreviewDto>]
public async Task<IActionResult> CancellationPolicy(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCancellationPolicyPreviewQuery(id), cancellationToken));
[HttpPost("[action]/{id}")] [HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<CareInstructionsDto>] [ProducesOkApiResponseType<CareInstructionsDto>]
public async Task<IActionResult> SubmitCareInstructions(long id, SubmitCareInstructionsCommand command, CancellationToken cancellationToken) public async Task<IActionResult> SubmitCareInstructions(long id, SubmitCareInstructionsCommand command, CancellationToken cancellationToken)
@@ -2,6 +2,7 @@ using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder; using Baya.Application.Features.Bnpl.Commands.InitiateBnplOrder;
using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility; using Baya.Application.Features.Bnpl.Queries.CheckBnplEligibility;
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderByRequest;
using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus; using Baya.Application.Features.Bnpl.Queries.GetBnplOrderStatus;
using Baya.Application.Models.Bnpl; using Baya.Application.Models.Bnpl;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
@@ -42,11 +43,17 @@ public sealed class CheckoutBnplController(ISender sender) : BaseController
new InitiateBnplOrderCommand(body.BookingRequestId, body.ProviderCode, idempotencyKey), cancellationToken)); new InitiateBnplOrderCommand(body.BookingRequestId, body.ProviderCode, idempotencyKey), cancellationToken));
} }
[HttpGet("{id}")] [HttpGet("{id:long}")]
[ProducesOkApiResponseType<BnplOrderStatusDto>] [ProducesOkApiResponseType<BnplOrderStatusDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken) public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken)); => OperationResult(await sender.Send(new GetBnplOrderStatusQuery(id, AdminView: false), cancellationToken));
// Reach the BNPL order from the booking request id (the return-poll holds the request id, not the order id).
[HttpGet("by_request/{bookingRequestId}")]
[ProducesOkApiResponseType<BnplOrderStatusDto>]
public async Task<IActionResult> ByRequest(long bookingRequestId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetBnplOrderByRequestQuery(bookingRequestId), cancellationToken));
/// <summary>The initiate body (the idempotency key comes from the <c>Idempotency-Key</c> header).</summary> /// <summary>The initiate body (the idempotency key comes from the <c>Idempotency-Key</c> header).</summary>
public record InitiateBnplBody(long BookingRequestId, string ProviderCode); public record InitiateBnplBody(long BookingRequestId, string ProviderCode);
} }
@@ -1,12 +1,15 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Identity.Commands.UploadCustomerAvatar;
using Baya.Application.Features.Identity.Commands.UpsertCustomerProfile; using Baya.Application.Features.Identity.Commands.UpsertCustomerProfile;
using Baya.Application.Features.Identity.Queries.GetMyCustomerProfile; using Baya.Application.Features.Identity.Queries.GetMyCustomerProfile;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity; using Baya.Application.Models.Identity;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
using Mediator; using Mediator;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1; namespace Baya.Web.Api.Controllers.V1;
@@ -27,4 +30,18 @@ public sealed class CustomerProfilesController(ISender sender) : BaseController
[ProducesOkApiResponseType<CustomerProfileDto>] [ProducesOkApiResponseType<CustomerProfileDto>]
public async Task<IActionResult> Me(CancellationToken cancellationToken) public async Task<IActionResult> Me(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken)); => OperationResult(await sender.Send(new GetMyCustomerProfileQuery(), cancellationToken));
// Multipart image upload → stored via IObjectStorage; the returned URL is persisted on the profile.
[HttpPost("[action]")]
[ProducesOkApiResponseType<AvatarUploadResult>]
public async Task<IActionResult> Avatar(IFormFile file, CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
return OperationResult(Baya.Application.Models.Common.OperationResult<AvatarUploadResult>.FailureResult("No file uploaded."));
using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var command = new UploadCustomerAvatarCommand(buffer.ToArray(), file.ContentType, file.Length);
return OperationResult(await sender.Send(command, cancellationToken));
}
} }
@@ -1,5 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Payouts.Queries.GetNurseEarnings;
using Baya.Application.Features.Payouts.Queries.GetNurseEarningsBalance;
using Baya.Application.Features.Payouts.Queries.GetNursePayoutDetail;
using Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory; using Baya.Application.Features.Payouts.Queries.GetNursePayoutHistory;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Application.Models.Payouts; using Baya.Application.Models.Payouts;
@@ -25,4 +28,22 @@ public sealed class NursePayoutsController(ISender sender) : BaseController
[ProducesOkApiResponseType<PagedResult<NursePayoutHistoryDto>>] [ProducesOkApiResponseType<PagedResult<NursePayoutHistoryDto>>]
public async Task<IActionResult> History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken) public async Task<IActionResult> History([FromQuery] GetNursePayoutHistoryQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken)); => OperationResult(await sender.Send(query, cancellationToken));
// The four-bucket balance + ledger-derived signed net payable (REQ-025).
[HttpGet("earnings_balance")]
[ProducesOkApiResponseType<NurseEarningsBalanceDto>]
public async Task<IActionResult> EarningsBalance(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetNurseEarningsBalanceQuery(), cancellationToken));
// The per-booking earnings list with server-derived money-state, filterable by state (REQ-025).
[HttpGet("earnings")]
[ProducesOkApiResponseType<PagedResult<NurseEarningsItemDto>>]
public async Task<IActionResult> Earnings([FromQuery] GetNurseEarningsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
// The nurse's own payout detail — batch window + covered bookings for reconciliation (REQ-025).
[HttpGet("{id:long}")]
[ProducesOkApiResponseType<NursePayoutDetailDto>]
public async Task<IActionResult> Detail(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetNursePayoutDetailQuery(id), cancellationToken));
} }
@@ -1,13 +1,16 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings; using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
using Baya.Application.Features.Identity.Commands.UploadNurseAvatar;
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile; using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
using Baya.Application.Features.Identity.Queries.GetMyNurseProfile; using Baya.Application.Features.Identity.Queries.GetMyNurseProfile;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity; using Baya.Application.Models.Identity;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
using Mediator; using Mediator;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1; namespace Baya.Web.Api.Controllers.V1;
@@ -33,4 +36,18 @@ public sealed class NurseProfilesController(ISender sender) : BaseController
[ProducesOkApiResponseType<NurseProfileDto>] [ProducesOkApiResponseType<NurseProfileDto>]
public async Task<IActionResult> Me(CancellationToken cancellationToken) public async Task<IActionResult> Me(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken)); => OperationResult(await sender.Send(new GetMyNurseProfileQuery(), cancellationToken));
// Multipart image upload → stored via IObjectStorage; the returned URL is persisted on the profile.
[HttpPost("[action]")]
[ProducesOkApiResponseType<AvatarUploadResult>]
public async Task<IActionResult> Avatar(IFormFile file, CancellationToken cancellationToken)
{
if (file is null || file.Length == 0)
return OperationResult(Baya.Application.Models.Common.OperationResult<AvatarUploadResult>.FailureResult("No file uploaded."));
using var buffer = new MemoryStream();
await file.CopyToAsync(buffer, cancellationToken);
var command = new UploadNurseAvatarCommand(buffer.ToArray(), file.ContentType, file.Length);
return OperationResult(await sender.Send(command, cancellationToken));
}
} }
@@ -5,6 +5,7 @@ using Baya.Application.Features.Verification.Commands.RequestDocumentUploadUrl;
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification; using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
using Baya.Application.Features.Verification.Commands.RunIdentityKyc; using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
using Baya.Application.Features.Verification.Commands.RunShahkarMatch; using Baya.Application.Features.Verification.Commands.RunShahkarMatch;
using Baya.Application.Features.Verification.Commands.SubmitCredentialDetails;
using Baya.Application.Features.Verification.Commands.SubmitVerification; using Baya.Application.Features.Verification.Commands.SubmitVerification;
using Baya.Application.Features.Verification.Queries.GetStatus; using Baya.Application.Features.Verification.Queries.GetStatus;
using Baya.Application.Models.Verification; using Baya.Application.Models.Verification;
@@ -43,6 +44,13 @@ public sealed class NurseVerificationController(ISender sender) : BaseController
public async Task<IActionResult> ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken) public async Task<IActionResult> ConfirmDocument(long stepId, ConfirmDocumentUploadCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken)); => OperationResult(await sender.Send(command with { StepId = stepId }, cancellationToken));
// Captures the structured credential fields (INO number, specialties, optional license details) B5
// collects — the real path used to drop them silently.
[HttpPost("[action]")]
[ProducesOkApiResponseType<VerificationStatusDto>]
public async Task<IActionResult> CredentialDetails(SubmitCredentialDetailsCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("steps/identity_kyc/run")] [HttpPost("steps/identity_kyc/run")]
[ProducesOkApiResponseType<RunStepResult>] [ProducesOkApiResponseType<RunStepResult>]
public async Task<IActionResult> RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken) public async Task<IActionResult> RunIdentityKyc(RunIdentityKycCommand command, CancellationToken cancellationToken)
@@ -1,8 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Nurses.Queries.GetNursePublicProfile;
using Baya.Application.Features.Reviews.Queries.GetTagAggregates; using Baya.Application.Features.Reviews.Queries.GetTagAggregates;
using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse; using Baya.Application.Features.Reviews.Queries.ListReviewsForNurse;
using Baya.Application.Features.Verification.Queries.GetTrustBadge; using Baya.Application.Features.Verification.Queries.GetTrustBadge;
using Baya.Application.Models.Nurses;
using Baya.Application.Models.Reviews; using Baya.Application.Models.Reviews;
using Baya.Application.Models.Verification; using Baya.Application.Models.Verification;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
@@ -26,6 +28,12 @@ public sealed class NursesController(ISender sender) : BaseController
public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken) public async Task<IActionResult> TrustBadge(long nurseId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken)); => OperationResult(await sender.Send(new GetVerifiedTrustBadgeQuery(nurseId), cancellationToken));
// Public: the aggregated discovery detail (identity + aggregates + verification + services + latest review).
[HttpGet("{nurseId}/[action]")]
[ProducesOkApiResponseType<NursePublicProfileDto>]
public async Task<IActionResult> Profile(long nurseId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetNursePublicProfileQuery(nurseId), cancellationToken));
// Public: published reviews only (the publish gate is enforced in the query) + the cached rating aggregate. // Public: published reviews only (the publish gate is enforced in the query) + the cached rating aggregate.
[HttpGet("{nurseProfileId}/reviews")] [HttpGet("{nurseProfileId}/reviews")]
[ProducesOkApiResponseType<NurseReviewsResult>] [ProducesOkApiResponseType<NurseReviewsResult>]
@@ -1,8 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.PatientCareRecords.Commands.UpsertCarePlan;
using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord; using Baya.Application.Features.PatientCareRecords.Commands.WritePatientCareRecord;
using Baya.Application.Features.PatientCareRecords.Queries.GetCarePlan;
using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory; using Baya.Application.Features.PatientCareRecords.Queries.GetPatientHistory;
using Baya.Application.Features.PatientCareRecords.Queries.GetRecordAccess;
using Baya.Application.Models.Common; using Baya.Application.Models.Common;
using Baya.Application.Models.Patients;
using Baya.Application.Models.Reviews; using Baya.Application.Models.Reviews;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
@@ -29,13 +33,38 @@ public sealed class PatientCareRecordsController(ISender sender) : BaseControlle
[ProducesOkApiResponseType<WriteCareRecordResult>] [ProducesOkApiResponseType<WriteCareRecordResult>]
public async Task<IActionResult> Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken) public async Task<IActionResult> Write(long patientId, WriteCareRecordBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send( => OperationResult(await sender.Send(
new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body), cancellationToken)); new WritePatientCareRecordCommand(patientId, body.BookingId, body.Body, body.TaskResults), cancellationToken));
[HttpGet("{patientId}/care_records")] [HttpGet("{patientId}/care_records")]
[ProducesOkApiResponseType<PagedResult<CareRecordDto>>] [ProducesOkApiResponseType<PagedResult<CareRecordDto>>]
public async Task<IActionResult> History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default) public async Task<IActionResult> History(long patientId, [FromQuery] int page = 1, [FromQuery] int pageSize = 20, CancellationToken cancellationToken = default)
=> OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken)); => OperationResult(await sender.Send(new GetPatientHistoryQuery(patientId, page, pageSize), cancellationToken));
// The family-owned care plan (medications/routine/tasks) — read (owner/nurse/admin), REQ-027.
[HttpGet("{patientId}/care_record")]
[ProducesOkApiResponseType<CarePlanDto>]
public async Task<IActionResult> CarePlan(long patientId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCarePlanQuery(patientId), cancellationToken));
// Replace the family-owned care plan (owning customer only), REQ-027.
[HttpPut("{patientId}/care_record")]
[ProducesOkApiResponseType<CarePlanDto>]
public async Task<IActionResult> UpsertCarePlan(long patientId, UpsertCarePlanBody body, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(
new UpsertCarePlanCommand(patientId, body.Medications, body.Routine, body.Tasks), cancellationToken));
// The caller's access to this patient's records (view/edit/append-note + non-leaking denied), REQ-027.
[HttpGet("{patientId}/record_access")]
[ProducesOkApiResponseType<RecordAccessDto>]
public async Task<IActionResult> RecordAccess(long patientId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetRecordAccessQuery(patientId), cancellationToken));
/// <summary>The care-record body (the patient id comes from the route).</summary> /// <summary>The care-record body (the patient id comes from the route).</summary>
public record WriteCareRecordBody(long? BookingId, string Body); public record WriteCareRecordBody(long? BookingId, string Body, IReadOnlyList<TaskResultDto>? TaskResults);
/// <summary>The family care-plan body (the patient id comes from the route).</summary>
public record UpsertCarePlanBody(
IReadOnlyList<MedicationDto>? Medications,
IReadOnlyList<RoutineItemDto>? Routine,
IReadOnlyList<CareTaskDto>? Tasks);
} }
@@ -1,5 +1,6 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Asp.Versioning; using Asp.Versioning;
using Baya.Application.Features.Refunds.Queries.GetRefundByBooking;
using Baya.Application.Features.Refunds.Queries.GetRefundStatus; using Baya.Application.Features.Refunds.Queries.GetRefundStatus;
using Baya.Application.Models.Refunds; using Baya.Application.Models.Refunds;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
@@ -23,4 +24,10 @@ public sealed class RefundsController(ISender sender) : BaseController
[ProducesOkApiResponseType<RefundStatusDto>] [ProducesOkApiResponseType<RefundStatusDto>]
public async Task<IActionResult> Status(long id, CancellationToken cancellationToken) public async Task<IActionResult> Status(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken)); => OperationResult(await sender.Send(new GetRefundStatusQuery(id), cancellationToken));
// Reach the refund from its booking id (the id the customer holds) — 404 when none exists (REQ-021).
[HttpGet("by_booking/{bookingId}")]
[ProducesOkApiResponseType<RefundStatusDto>]
public async Task<IActionResult> ByBooking(long bookingId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetRefundByBookingQuery(bookingId), cancellationToken));
} }
@@ -25,7 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
[ApiController] [ApiController]
[Route("api/v{version:apiVersion}/webhooks_bnpl")] [Route("api/v{version:apiVersion}/webhooks_bnpl")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)] [EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")] [Display(Description = "BNPL provider callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksBnplController(ISender sender) : BaseController public sealed class WebhooksBnplController(ISender sender) : BaseController
{ {
@@ -7,9 +7,11 @@ using Baya.Application.Features.Payments.Commands.HandlePaymentWebhook;
using Baya.Application.Models.Payments; using Baya.Application.Models.Payments;
using Baya.WebFramework.Attributes; using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController; using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator; using Mediator;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1; namespace Baya.Web.Api.Controllers.V1;
@@ -23,6 +25,7 @@ namespace Baya.Web.Api.Controllers.V1;
[ApiController] [ApiController]
[Route("api/v{version:apiVersion}/webhooks")] [Route("api/v{version:apiVersion}/webhooks")]
[AllowAnonymous] [AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")] [Display(Description = "PSP/BNPL payment callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksController(ISender sender) : BaseController public sealed class WebhooksController(ISender sender) : BaseController
{ {
@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Text;
using Asp.Versioning;
using Baya.Application.Features.Payouts.Commands.ReconcilePayoutBatch;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// The async PAYA/SATNA payout <b>reconciliation callback</b> (refinement-phase-8, 6.3). The real bank rail
/// accepts a payout as <c>submitted</c> and calls back later with the settled outcome, flipping each payout
/// <c>submitted → paid/failed</c> (the mock rail collapsed this into the submit). Authenticated by
/// <b>signature</b>, not a user session, so it is anonymous to the auth pipeline; the callback is HMAC-verified and
/// idempotent (a replayed callback re-driving an already-settled payout is a no-op). Shares the deliberate
/// bursty-tolerant <c>webhook</c> rate policy with the PSP/BNPL callbacks.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/webhooks")]
[AllowAnonymous]
[EnableRateLimiting(RateLimitingServiceExtension.WebhookPolicy)]
[Display(Description = "PAYA/SATNA payout reconciliation callbacks (signature-authenticated, idempotent)")]
public sealed class WebhooksPayoutsController(ISender sender) : BaseController
{
[HttpPost("payouts/{provider}")]
[ProducesOkApiResponseType<bool>]
public async Task<IActionResult> Payouts(string provider, CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync(cancellationToken);
var headers = Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase);
return OperationResult(await sender.Send(new ReconcilePayoutBatchCommand(provider, headers, rawBody), cancellationToken));
}
}
+58 -10
View File
@@ -12,6 +12,7 @@ using Baya.Infrastructure.Identity.ServiceConfiguration;
using Baya.Infrastructure.Monitoring.Configurations; using Baya.Infrastructure.Monitoring.Configurations;
using Baya.Infrastructure.Persistence.ServiceConfiguration; using Baya.Infrastructure.Persistence.ServiceConfiguration;
using Baya.SharedKernel.Extensions; using Baya.SharedKernel.Extensions;
using Baya.Web.Api.Configuration;
using Baya.Web.Plugins.Grpc; using Baya.Web.Plugins.Grpc;
using Baya.WebFramework.Filters; using Baya.WebFramework.Filters;
using Baya.WebFramework.Middlewares; using Baya.WebFramework.Middlewares;
@@ -29,8 +30,16 @@ builder.Host.UseSerilog(LoggingConfiguration.ConfigureLogger);
var configuration = builder.Configuration; var configuration = builder.Configuration;
// Fail fast if a load-bearing secret (DB connection, JWE/field-encryption keys) is missing or still a
// committed placeholder — before any service reaches for it. Skipped in the "Testing" environment.
builder.ValidateRequiredSecrets();
Activity.DefaultIdFormat = ActivityIdFormat.W3C; Activity.DefaultIdFormat = ActivityIdFormat.W3C;
// HTTPS metadata is required for the token exchange in deployed environments; relaxed for local
// Development and the Testing host, which run over plain HTTP.
var requireHttpsMetadata = !builder.Environment.IsDevelopment() && !builder.Environment.IsEnvironment("Testing");
builder builder
.ConfigureHealthChecks() .ConfigureHealthChecks()
.SetupOpenTelemetry(); .SetupOpenTelemetry();
@@ -68,18 +77,28 @@ builder.Services.AddSwagger("v1","v1.1");
builder.Services.AddApplicationServices() builder.Services.AddApplicationServices()
.RegisterIdentityServices(identitySettings) .RegisterIdentityServices(identitySettings, requireHttpsMetadata)
.AddPersistenceServices(configuration) .AddPersistenceServices(configuration)
.AddCrossCuttingSeams(configuration) .AddCrossCuttingSeams(configuration)
.AddWebFrameworkServices() .AddWebFrameworkServices()
.AddCorsPolicies(configuration) .AddCorsPolicies(configuration)
.AddForwardedHeadersConfiguration(configuration)
.AddRateLimitingPolicies(); .AddRateLimitingPolicies();
// Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login // Development-only: capture each OTP in-memory so GET /api/v1/dev/last_otp/{phone} can complete a login
// without an SMS gateway. Nothing here is wired in any other environment. // without an SMS gateway. refinement-phase-8: the capture bridge runs ONLY while the log-only mock SMS sender is
if (builder.Environment.IsDevelopment()) // selected — once a real gateway (Seams:Sms:Provider) ships, the OTP is delivered over the wire and never logged
// or captured. Nothing here is wired in any other environment.
var smsProvider = configuration["Seams:Sms:Provider"];
var usingMockSms = string.IsNullOrWhiteSpace(smsProvider) || smsProvider.Equals("mock", StringComparison.OrdinalIgnoreCase);
if (builder.Environment.IsDevelopment() && usingMockSms)
builder.Services.AddDevelopmentOtpCapture(); builder.Services.AddDevelopmentOtpCapture();
// The IPaymentCaptureSimulator + bookings/convert path is a Development/Testing affordance (b10's real webhook
// confirm supersedes it in production). Re-register the succeeding mock over the production fail-closed stand-in.
if (builder.Environment.IsDevelopment() || builder.Environment.IsEnvironment("Testing"))
builder.Services.AddDevelopmentPaymentCapture();
builder.Services.RegisterValidatorsAsServices(); builder.Services.RegisterValidatorsAsServices();
builder.Services.AddExceptionHandler<ExceptionHandler>(); builder.Services.AddExceptionHandler<ExceptionHandler>();
@@ -91,7 +110,7 @@ TypeAdapterConfig.GlobalSettings.Scan(typeof(UserCreateCommand).Assembly,
#region Plugin Services Configuration #region Plugin Services Configuration
builder.Services.ConfigureGrpcPluginServices(); builder.Services.ConfigureGrpcPluginServices(builder.Environment);
#endregion #endregion
@@ -100,18 +119,43 @@ builder.Services.ConfigureGrpcPluginServices();
var app = builder.Build(); var app = builder.Build();
// Deploy-time migration one-shot (refinement-phase-7): `dotnet run -- migrate` (or `<binary> migrate`) applies
// EF migrations + the idempotent seeders, then exits. Running DDL as a separate deploy step means normal boots —
// especially concurrent multi-instance start-ups — never race on schema, and the runtime login needs no
// permanent DDL rights.
if (args.Any(a => string.Equals(a, "migrate", StringComparison.OrdinalIgnoreCase)))
{
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
if (app.Environment.IsDevelopment())
{
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync();
}
return;
}
// Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server // Integration tests (WebApplicationFactory, env "Testing") run on in-memory SQLite — the SQL Server
// migrations can't apply there; the test factory does EnsureCreated + seeding itself. // migrations can't apply there; the test factory does EnsureCreated + seeding itself.
if (!app.Environment.IsEnvironment("Testing")) if (!app.Environment.IsEnvironment("Testing"))
{ {
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
// Development-only: populate a demo marketplace (nurses/variants/search rows, customers/patients)
// so the real-path screens aren't empty. Idempotent; never runs in Production/Staging.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{
// Local convenience: apply migrations + seed on boot. Development-only: a sandbox payment gateway
// (all-zeros merchant id) and a demo marketplace (nurses/variants/search rows, customers/patients) so the
// real-path screens aren't empty. Neither belongs in a deployed DB — both are idempotent.
await app.ApplyMigrationsAsync();
await app.SeedDefaultUsersAsync();
await app.SeedPaymentGatewaysAsync();
await app.SeedDemoWorldAsync(); await app.SeedDemoWorldAsync();
}
else
{
// Deployed: DDL is the separate `migrate` step above. Boot only *checks* the schema is current (fail fast
// on a pending migration) and seeds idempotent runtime data (roles + any configured break-glass admin).
await app.EnsureSchemaUpToDateAsync();
await app.SeedDefaultUsersAsync();
}
} }
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -121,6 +165,10 @@ if (app.Environment.IsDevelopment())
else else
app.UseExceptionHandler(_=>{}); app.UseExceptionHandler(_=>{});
// First in the pipeline so the resolved client IP (X-Forwarded-For, from a trusted proxy) is in place
// before anything downstream — notably the rate limiter — reads HttpContext.Connection.RemoteIpAddress.
app.UseForwardedHeaders();
app.UseSwaggerAndUi(); app.UseSwaggerAndUi();
app.UseRouting(); app.UseRouting();
@@ -1,37 +1,15 @@
{ {
"ConnectionStrings": {
"SqlServer": "Server=localhost,1433;Database=Baya;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;",
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
},
"IdentitySettings": { "IdentitySettings": {
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", "SecretKey": "dev-only-jwe-signing-key-not-for-production-0123456789abcdef",
"Encryptkey": "16CharEncryptKey", "Encryptkey": "dev-only-16bytes"
"Issuer": "MyWebsite",
"Audience": "MyWebsite",
"NotBeforeMinutes": "0",
"ExpirationMinutes": "10000"
}, },
"Seams": { "Seams": {
"FieldEncryption": { "FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me", "Key": "local-dev-field-encryption-key-not-for-production",
"HashKey": "local-dev-field-hash-key-change-me" "HashKey": "local-dev-field-hash-key-not-for-production"
},
"ObjectStorage": {
"RootPath": ""
},
"Geocoding": {
"ReturnNullCoordinates": false,
"LowConfidenceMarker": "NO_GEO",
"ResolvedConfidence": 0.9
} }
}, },
"Cors": { "Cors": {
"AllowedOrigins": [ "http://localhost:3000" ] "AllowedOrigins": [ "http://localhost:3000" ]
},
"AllowedHosts": "*",
"Kestrel": {
"EndpointDefaults": {
"Protocols": "Http2"
}
} }
} }
+12 -8
View File
@@ -4,17 +4,17 @@
"logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;" "logDb": "Server=localhost,1433;Database=Baya_Logs;User Id=sa;Password=SET_VIA_USER_SECRETS_OR_ENV;TrustServerCertificate=True;Encrypt=False;"
}, },
"IdentitySettings": { "IdentitySettings": {
"SecretKey": "ShouldBe-LongerThan-16Char-SecretKey", "SecretKey": "SET_VIA_USER_SECRETS_OR_ENV",
"Encryptkey": "16CharEncryptKey", "Encryptkey": "SET_VIA_USER_SECRETS_OR_ENV",
"Issuer": "MyWebsite", "Issuer": "Balinyaar",
"Audience": "MyWebsite", "Audience": "BalinyaarClient",
"NotBeforeMinutes": "0", "NotBeforeMinutes": "0",
"ExpirationMinutes": "10000" "ExpirationMinutes": "60"
}, },
"Seams": { "Seams": {
"FieldEncryption": { "FieldEncryption": {
"Key": "local-dev-field-encryption-key-change-me", "Key": "SET_VIA_USER_SECRETS_OR_ENV",
"HashKey": "local-dev-field-hash-key-change-me" "HashKey": "SET_VIA_USER_SECRETS_OR_ENV"
}, },
"ObjectStorage": { "ObjectStorage": {
"RootPath": "" "RootPath": ""
@@ -28,10 +28,14 @@
"Cors": { "Cors": {
"AllowedOrigins": [] "AllowedOrigins": []
}, },
"ForwardedHeaders": {
"KnownProxies": [],
"KnownNetworks": []
},
"AllowedHosts": "*", "AllowedHosts": "*",
"Kestrel": { "Kestrel": {
"EndpointDefaults": { "EndpointDefaults": {
"Protocols": "Http2" "Protocols": "Http1AndHttp2"
} }
} }
} }
@@ -50,6 +50,14 @@ public class BaseController : ControllerBase
return new JsonResult(new ApiResult(false, ApiResultStatusCode.Conflict, FirstErrorMessage(result))) return new JsonResult(new ApiResult(false, ApiResultStatusCode.Conflict, FirstErrorMessage(result)))
{ StatusCode = StatusCodes.Status409Conflict }; { StatusCode = StatusCodes.Status409Conflict };
// A coded failure (e.g. otp_locked) is written as the full envelope so the client sees the stable
// `code` (+ optional `data`), rather than the bare ModelState errors of an ordinary 400.
if (result.ErrorCode is not null)
return new JsonResult(
new ApiResult<object>(false, ApiResultStatusCode.BadRequest, result.ErrorData, FirstErrorMessage(result))
{ Code = result.ErrorCode })
{ StatusCode = StatusCodes.Status400BadRequest };
AddErrors(result); AddErrors(result);
var badRequestErrors = new ValidationProblemDetails(ModelState); var badRequestErrors = new ValidationProblemDetails(ModelState);

Some files were not shown because too many files have changed in this diff Show More