frontend phase 9

This commit is contained in:
hamid
2026-07-10 11:49:55 +03:30
parent cd6c2591a6
commit 40cc1d163b
49 changed files with 4130 additions and 20 deletions
+12 -2
View File
@@ -129,8 +129,13 @@ client/
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail │ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") │ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer")
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5 │ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards │ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
│ │ │ │ ── checkout/page.tsx # /bookings/checkout — pay & confirm handoff target (DEFERRED→f9 stub; C5 accept CTA lands here with request_id) │ │ │ │ ── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
│ │ │ │ └── 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) + disabled BNPL seam (f11)
│ │ │ │ ├── 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
│ │ │ │ └── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice)
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive) │ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
│ │ │ ├── wallet/page.tsx # /wallet │ │ │ ├── wallet/page.tsx # /wallet
@@ -180,6 +185,9 @@ client/
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested) │ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested) │ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested) │ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
│ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later (tested)
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (tested)
│ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── 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)
│ └── 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, useCountdown
@@ -234,6 +242,7 @@ client/
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved │ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary │ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list │ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
│ └── {domain}/ │ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical) │ ├── keys.ts # React Query key factory (hierarchical)
@@ -329,6 +338,7 @@ async function MyServerComponent() {
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA - `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites - `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages - `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
- `'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, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
+55
View File
@@ -558,6 +558,61 @@
"list_error": "Couldn't load your bookings.", "list_error": "Couldn't load your bookings.",
"list_total": "Total" "list_total": "Total"
}, },
"payment": {
"title_checkout": "Confirm & pay",
"row_service_cost": "Service cost",
"row_service_cost_with_count": "Service cost ({count, plural, one {# visit} other {# visits}})",
"row_commission": "Balinyaar fee",
"row_vat": "VAT",
"row_total": "Total",
"escrow_notice": "The amount is held in escrow with Balinyaar and released after the visit ends",
"cta_pay": "Continue to payment →",
"bnpl_option": "Or pay in installments",
"state_initiating": "Starting payment…",
"state_redirecting": "Redirecting to the payment gateway…",
"state_pending_title": "Confirming your payment…",
"state_pending_hint": "The gateway is confirming your payment; this usually takes a few moments.",
"check_again": "Check again",
"state_failed_title": "Payment failed",
"state_failed_hint": "Nothing was charged. You can try again.",
"retry_payment": "Try again",
"back_to_request": "Back to the request",
"already_paid_title": "This request is already paid",
"already_paid_body": "Your booking has been confirmed.",
"window_expired_title": "The payment window has closed",
"window_expired_body": "To continue, submit a new request.",
"not_payable_title": "This request can no longer be paid",
"initiate_failed": "The payment could not be started.",
"confirm_title": "Payment successful",
"confirm_subtitle": "Your booking is confirmed and the nurse has been notified.",
"view_booking": "View booking",
"download_invoice": "Download invoice",
"total_paid_label": "Amount paid",
"invoice_title": "Invoice",
"invoice_number_label": "Invoice number",
"invoice_issued_at": "Issued on",
"invoice_vat_on_commission": "VAT (on the Balinyaar fee)",
"invoice_not_issued_title": "The invoice has not been issued yet",
"invoice_not_issued_body": "The invoice for this booking will appear here once issued.",
"print_invoice": "Print invoice",
"moadian_label": "Moadian (e-invoicing) status",
"moadian_pending": "Awaiting registration",
"moadian_submitted": "Submitted",
"moadian_registered": "Registered",
"moadian_failed": "Failed",
"pstatus_pending": "Awaiting confirmation",
"pstatus_succeeded": "Paid",
"pstatus_failed": "Failed",
"gateway_title": "Test payment gateway",
"gateway_hint": "This page stands in for the real gateway in development.",
"gateway_reference_label": "Reference",
"gateway_pay_success": "Pay successfully",
"gateway_pay_fail": "Simulate a failed payment",
"error_title": "Something went wrong",
"error_body": "We couldn't load the payment summary.",
"invalid_link_body": "This payment link is not valid.",
"issuer_platform": "Balinyaar"
},
"auth": { "auth": {
"customer_title": "Sign in to Balinyaar", "customer_title": "Sign in to Balinyaar",
"customer_subtitle": "Sign in with your mobile number", "customer_subtitle": "Sign in with your mobile number",
+55
View File
@@ -558,6 +558,61 @@
"list_error": "بارگذاری رزروها ممکن نشد.", "list_error": "بارگذاری رزروها ممکن نشد.",
"list_total": "مبلغ کل" "list_total": "مبلغ کل"
}, },
"payment": {
"title_checkout": "تایید و پرداخت",
"row_service_cost": "هزینه خدمت",
"row_service_cost_with_count": "هزینه خدمت ({count, plural, one {# ویزیت} other {# ویزیت}})",
"row_commission": "کارمزد بالین‌یار",
"row_vat": "مالیات بر ارزش افزوده",
"row_total": "مبلغ کل",
"escrow_notice": "مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود",
"cta_pay": "ادامه پرداخت ←",
"bnpl_option": "یا پرداخت اقساطی",
"state_initiating": "در حال آغاز پرداخت…",
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
"state_pending_title": "در حال تایید پرداخت…",
"state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول می‌کشد.",
"check_again": "بررسی دوباره",
"state_failed_title": "پرداخت ناموفق بود",
"state_failed_hint": "مبلغی از حساب شما کسر نشده است. می‌توانید دوباره تلاش کنید.",
"retry_payment": "تلاش دوباره",
"back_to_request": "بازگشت به درخواست",
"already_paid_title": "این درخواست قبلاً پرداخت شده است",
"already_paid_body": "رزرو شما نهایی شده است.",
"window_expired_title": "مهلت پرداخت به پایان رسید",
"window_expired_body": "برای ادامه، درخواست تازه‌ای ثبت کنید.",
"not_payable_title": "این درخواست دیگر قابل پرداخت نیست",
"initiate_failed": "شروع پرداخت ممکن نشد.",
"confirm_title": "پرداخت با موفقیت انجام شد",
"confirm_subtitle": "رزرو شما نهایی شد و پرستار در جریان قرار گرفت.",
"view_booking": "مشاهده رزرو",
"download_invoice": "دانلود فاکتور",
"total_paid_label": "مبلغ پرداخت‌شده",
"invoice_title": "فاکتور",
"invoice_number_label": "شماره فاکتور",
"invoice_issued_at": "تاریخ صدور",
"invoice_vat_on_commission": "مالیات بر ارزش افزوده (بر کارمزد بالین‌یار)",
"invoice_not_issued_title": "فاکتور هنوز صادر نشده است",
"invoice_not_issued_body": "فاکتور این رزرو پس از صدور در همین‌جا در دسترس خواهد بود.",
"print_invoice": "چاپ فاکتور",
"moadian_label": "وضعیت سامانه مودیان",
"moadian_pending": "در انتظار ثبت",
"moadian_submitted": "ارسال‌شده",
"moadian_registered": "ثبت‌شده",
"moadian_failed": "ناموفق",
"pstatus_pending": "در انتظار تایید",
"pstatus_succeeded": "موفق",
"pstatus_failed": "ناموفق",
"gateway_title": "درگاه پرداخت آزمایشی",
"gateway_hint": "این صفحه در محیط توسعه جایگزین درگاه واقعی است.",
"gateway_reference_label": "کد پیگیری",
"gateway_pay_success": "پرداخت موفق",
"gateway_pay_fail": "شبیه‌سازی پرداخت ناموفق",
"error_title": "مشکلی پیش آمد",
"error_body": "بارگذاری خلاصه پرداخت ممکن نشد.",
"invalid_link_body": "پیوند پرداخت معتبر نیست.",
"issuer_platform": "بالین‌یار"
},
"auth": { "auth": {
"customer_title": "ورود به بلینیار", "customer_title": "ورود به بلینیار",
"customer_subtitle": "با شماره موبایل خود وارد شوید", "customer_subtitle": "با شماره موبایل خود وارد شوید",
@@ -0,0 +1,215 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useParams, useRouter } from 'next/navigation';
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate, parseIrr } from '@/utils';
import { useInvoice } from '@/services/payment';
import type { MoadianStatus } from '@/services/payment/types';
/** The printable region — everything else is hidden by the print rules below. */
const PRINT_AREA_CLASS = 'invoice-print-area';
// مودیان registration is a backend concern — surfaced strictly read-only (contract exposes the state).
const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
registered: 'verified',
failed: 'rejected',
};
/**
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util — no float math;
* the service line is the exact integer remainder of served amounts (gross commission VAT).
*/
export default function BookingInvoicePage() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const bookingId = Number(params.id);
const validId = Number.isInteger(bookingId) && bookingId > 0;
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
if (!validId) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="error" size={44} color="var(--bal-error)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('invalid_link_body')}
</Typography>
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</Stack>
</Paper>
);
}
if (isLoading) {
return (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="text" width="40%" height={36} />
<Skeleton variant="rounded" height={120} />
<Skeleton variant="rounded" height={200} />
</Stack>
);
}
if (!invoice) {
const notIssued = error instanceof ApiError && error.status === 404;
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{notIssued ? t('invoice_not_issued_title') : t('error_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
</Typography>
{notIssued ? (
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
) : (
<AppButton variant="contained" onClick={() => refetch()} sx={{ m: 0 }}>
{tc('retry')}
</AppButton>
)}
</Stack>
</Paper>
);
}
// The receipt must print on paper colors: the tokens are attribute-driven, so a dark-scheme user
// would otherwise print cream-on-dark. Flip to the light tokens for the print dialog and restore
// after it closes (afterprint) — no hard-coded colors, the same token system does the work.
const handlePrint = () => {
const root = document.documentElement;
const previous = root.getAttribute('data-mui-color-scheme');
if (previous === 'dark') {
const restore = () => {
root.setAttribute('data-mui-color-scheme', previous);
window.removeEventListener('afterprint', restore);
};
window.addEventListener('afterprint', restore);
root.setAttribute('data-mui-color-scheme', 'light');
}
window.print();
};
// Display rows from served amounts only, integer-safe: the service line is the exact remainder, so
// service + commission + VAT reconciles to the gross total by construction.
const serviceIrr = (
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
).toString();
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
style: 'percent',
maximumFractionDigits: 2,
}).format(invoice.vatRate);
return (
<Stack sx={{ gap: 2 }}>
<GlobalStyles
styles={{
'@media print': {
'body *': { visibility: 'hidden' },
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
[`.${PRINT_AREA_CLASS}`]: { position: 'absolute', top: 0, insetInlineStart: 0, width: '100%' },
},
}}
/>
<Paper
elevation={0}
className={PRINT_AREA_CLASS}
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 2 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="h6" component="h1">
{t('invoice_title')}
</Typography>
{/* The issuer line uses the product spelling «بالین‌یار» — fa `common.brand` currently reads
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
{t('issuer_platform')}
</Typography>
</Stack>
<Divider />
<Stack sx={{ gap: 0.75 }}>
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
</Stack>
<PriceBreakdown
rows={[
{ key: 'service_cost', label: t('row_service_cost'), amountIrr: serviceIrr },
{ key: 'commission', label: t('row_commission'), amountIrr: invoice.platformCommissionIrr },
{
key: 'vat',
label: `${t('invoice_vat_on_commission')} (${vatPercent})`,
amountIrr: invoice.vatIrr,
},
]}
totalLabel={t('row_total')}
totalAmountIrr={invoice.grossIrr}
/>
{invoice.moadianStatus ? (
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('moadian_label')}
</Typography>
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
</Stack>
) : null}
</Stack>
</Paper>
<Stack sx={{ gap: 1 }}>
{invoice.pdfUrl ? (
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab sx={{ m: 0 }}>
{t('download_invoice')}
</AppButton>
) : (
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint} sx={{ m: 0 }}>
{t('print_invoice')}
</AppButton>
)}
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
);
}
function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
return (
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }} dir={ltr ? 'ltr' : undefined}>
{value}
</Typography>
</Stack>
);
}
@@ -0,0 +1,98 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCheckoutSummary } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
/**
* Post-payment confirmation — the booking is now **confirmed** (flipped by cache invalidation on the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
* the invoice («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
* to the bookings list and the invoice link is hidden — the invoice route needs the booking id.
*/
export default function CheckoutConfirmationPage() {
return (
<Suspense fallback={<AppLoading />}>
<ConfirmationScreen />
</Suspense>
);
}
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const { data: summary } = useCheckoutSummary(
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
);
return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('confirm_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('confirm_subtitle')}
</Typography>
</Stack>
{summary ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')}
</Typography>
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
color="primary"
variant="contained"
size="large"
onClick={() =>
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
{bookingId != null ? (
<AppButton
variant="outlined"
color="primary"
startIcon="document"
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('download_invoice')}
</AppButton>
) : null}
</Stack>
</Stack>
);
}
@@ -0,0 +1,76 @@
'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,25 +1,299 @@
'use client'; 'use client';
import { Suspense } from 'react'; import { Suspense, useRef } from 'react';
import { useTranslations } from 'next-intl'; import { useLocale, useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { AppLoading, PlaceholderScreen } from '@/components'; import { Paper, Skeleton, Stack, Typography } from '@mui/material';
import {
AppButton,
AppIcon,
AppLoading,
CountdownTimer,
EscrowNotice,
PriceBreakdown,
StatusChip,
} from '@/components';
import AppAlert from '@/components/common/AppAlert';
import { ROUTES } from '@/constants';
import { ApiError } from '@/lib/api/errors';
import { formatShamsiDate } from '@/utils';
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
import {
BNPL_ENABLED,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { CheckoutSummaryDto } from '@/services/payment/types';
/** /**
* Checkout (pay & confirm) **DEFERRED frontend-phase-9-b10**. C5's "ادامه پرداخت" hands off here with * C6 خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling
* the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder * service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the
* confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary. * «ادامه پرداخت » CTA that initiates the card payment and follows the gateway redirect. Reached from
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary.
*/ */
export default function CheckoutPage() { export default function CheckoutPage() {
return ( return (
<Suspense fallback={<AppLoading />}> <Suspense fallback={<AppLoading />}>
<CheckoutDeferred /> <CheckoutScreen />
</Suspense> </Suspense>
); );
} }
function CheckoutDeferred() { function CheckoutScreen() {
const t = useTranslations('booking'); const t = useTranslations('payment');
const tb = useTranslations('booking');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const requestId = params.get('request_id') ?? '—';
return <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />; const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const { data: summary, isLoading, isError, refetch } = useCheckoutSummary(validId ? requestId : undefined);
const initiate = useInitiatePayment();
// One idempotency key per payment ATTEMPT: created lazily on the first tap and reused across retries
// of the same attempt (double-tap, transient network error). A new attempt — after a failed outcome the
// user comes back to a fresh mount of this screen — gets a new key.
const attemptKeyRef = useRef<string | null>(null);
// A malformed/missing request_id can never load — navigation, not a retry that would fire
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
if (!validId) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('invalid_link_body')}
ctaLabel={tb('bd_my_bookings')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
/>
);
}
if (isError) {
return (
<MessageCard
icon="error"
tone="var(--bal-error)"
title={t('error_title')}
body={t('error_body')}
ctaLabel={tc('retry')}
onCta={() => refetch()}
/>
);
}
if (isLoading || !summary) return <CheckoutSkeleton />;
const returnUrl = (extra?: Record<string, string>) => {
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(requestId), ...extra });
return `/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`;
};
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
if (summary.requestStatus === 'converted') {
return (
<MessageCard
icon="verified"
tone="var(--bal-success)"
title={t('already_paid_title')}
body={t('already_paid_body')}
ctaLabel={tb('converted_cta')}
onCta={() => router.replace(returnUrl())}
/>
);
}
if (summary.requestStatus !== 'accepted_awaiting_payment') {
const expired = summary.requestStatus === 'payment_deadline_expired';
return (
<MessageCard
icon="pending"
tone="var(--bal-warning)"
title={expired ? t('window_expired_title') : t('not_payable_title')}
body={expired ? t('window_expired_body') : undefined}
ctaLabel={t('back_to_request')}
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
/>
);
}
const handlePay = () => {
attemptKeyRef.current ??= crypto.randomUUID();
initiate.mutate(
{ bookingRequestId: requestId, idempotencyKey: attemptKeyRef.current },
{
onSuccess: (result) => {
if (!result.redirectUrl) {
// No gateway hop to make — read the outcome directly.
router.push(returnUrl({ [CHECKOUT_QUERY_TRANSACTION_ID]: String(result.transactionId) }));
return;
}
if (/^https?:\/\//i.test(result.redirectUrl)) {
// The real PSP page — a full navigation, outside the app router.
window.location.assign(result.redirectUrl);
return;
}
router.push(`/${locale}${result.redirectUrl}`);
},
onError: (error) => {
if (error instanceof ApiError && error.status === 409) {
// "Already paid / already in progress / window lapsed" — benign convergence, never a toast:
// the return surface reads the actual outcome and routes accordingly.
router.replace(returnUrl());
}
},
},
);
};
const busy = initiate.isPending || initiate.isSuccess;
// ApiError.message is a raw server/network string, never localized — always show the i18n copy.
const inlineError =
initiate.error && !(initiate.error instanceof ApiError && initiate.error.status === 409)
? t('initiate_failed')
: null;
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
<StatusChip status="verified" label={tb('accepted_badge')} />
<Typography variant="h6" component="h1">
{t('title_checkout')}
</Typography>
</Stack>
<EngagementSummary summary={summary} locale={locale} />
{summary.paymentDeadlineAt ? (
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<CountdownTimer
deadlineIso={summary.paymentDeadlineAt}
label={tb('payment_countdown_label')}
elapsedText={tb('payment_elapsed')}
urgent
onElapsed={() => refetch()}
/>
</Paper>
) : null}
<PriceBreakdown
rows={[
{
key: 'service_cost',
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
// quantity that always matches the charged gross (variant price × session count).
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
amountIrr: summary.serviceCostIrr,
},
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
]}
totalLabel={t('row_total')}
totalAmountIrr={summary.totalIrr}
/>
<EscrowNotice />
{inlineError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{inlineError}
</AppAlert>
) : null}
<Stack sx={{ gap: 1 }}>
<AppButton
color="secondary"
variant="contained"
size="large"
disabled={busy}
onClick={handlePay}
sx={{ m: 0, py: 1.25 }}
>
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
</AppButton>
{/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */}
<AppButton variant="outlined" color="primary" disabled={!BNPL_ENABLED} sx={{ m: 0 }}>
{t('bnpl_option')}
</AppButton>
{!BNPL_ENABLED ? (
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
{tc('coming_soon')}
</Typography>
) : null}
</Stack>
</Stack>
);
}
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
});
return (
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{summary.variantLabel}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.nurseName} · {summary.patientName}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} {timeFmt.format(end)}
</Typography>
</Stack>
</Paper>
);
}
function MessageCard({
icon,
tone,
title,
body,
ctaLabel,
onCta,
}: {
icon: string;
tone: string;
title: string;
body?: string;
ctaLabel: string;
onCta: () => void;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
{body}
</Typography>
) : null}
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
{ctaLabel}
</AppButton>
</Paper>
);
}
function CheckoutSkeleton() {
return (
<Stack sx={{ gap: 3 }}>
<Stack sx={{ gap: 1, alignItems: 'center' }}>
<Skeleton variant="rounded" width={140} height={24} />
<Skeleton variant="text" width="50%" height={32} />
</Stack>
<Skeleton variant="rounded" height={96} />
<Skeleton variant="rounded" height={64} />
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={56} />
<Skeleton variant="rounded" height={48} />
</Stack>
);
} }
@@ -0,0 +1,180 @@
'use client';
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components';
import { ROUTES } from '@/constants';
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
import {
CHECKOUT_QUERY_BOOKING_ID,
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
/**
* Return-from-gateway surface drives the tail of the checkout state machine: report the return
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" pending is
* normal, reflected calmly) until a terminal outcome: succeeded invalidate the booking/request caches
* and hand off to the confirmation screen; failed a retry affordance (a fresh C6 mount = a new attempt
* with a NEW idempotency key); window lapsed back to the request's terminal card.
*/
export default function CheckoutReturnPage() {
return (
<Suspense fallback={<AppLoading />}>
<ReturnScreen />
</Suspense>
);
}
function ReturnScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const queryClient = useQueryClient();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const validId = Number.isInteger(requestId) && requestId > 0;
const transactionIdParam = params.get(CHECKOUT_QUERY_TRANSACTION_ID);
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
const gatewayOutcome = params.get(CHECKOUT_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
const confirm = useConfirmGatewayReturn();
const { mutate: confirmMutate } = confirm;
// Fire the return report exactly once per mount — a browser refresh on this page replays it, which the
// backend/mock treat as a duplicate (idempotent convergence, per the webhook-dedup rule).
const confirmFiredRef = useRef(false);
useEffect(() => {
if (confirmFiredRef.current || !validId) return;
confirmFiredRef.current = true;
confirmMutate({ bookingRequestId: requestId, transactionId, outcome: gatewayOutcome });
}, [confirmMutate, validId, requestId, transactionId, gatewayOutcome]);
// The poll takes over once the return report settles (its result is primed into the outcome key).
const confirmSettled = confirm.isSuccess || confirm.isError;
const outcomeQuery = usePaymentOutcome(validId ? requestId : undefined, {
enabled: confirmSettled,
});
// Trust the outcome cache only after THIS mount's report settled — a previous attempt's failed outcome
// survives in the cache and would otherwise flash a false "payment failed" (with a live retry CTA)
// while the current attempt's capture is still in flight.
const outcome = confirmSettled ? outcomeQuery.data : undefined;
const succeeded = outcome?.transactionStatus === 'succeeded' || outcome?.requestStatus === 'converted';
const windowExpired = outcome?.requestStatus === 'payment_deadline_expired';
const failed = !succeeded && !windowExpired && outcome?.transactionStatus === 'failed';
// Hand off to the confirmation exactly once. The confirm mutation already invalidated on an immediate
// success; a success that arrived later through the poll invalidates here instead (never twice).
const navigatedRef = useRef(false);
const confirmSawSuccess = confirm.data?.transactionStatus === 'succeeded';
useEffect(() => {
if (!succeeded || navigatedRef.current || !outcome) return;
navigatedRef.current = true;
if (!confirmSawSuccess) {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(outcome.bookingRequestId) });
if (outcome.bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(outcome.bookingId));
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
}, [succeeded, outcome, confirmSawSuccess, queryClient, router, locale]);
if (!validId) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
{t('view_booking')}
</AppButton>
</StateCard>
);
}
if (windowExpired) {
return (
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
<AppButton
variant="contained"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{t('back_to_request')}
</AppButton>
</StateCard>
);
}
if (failed) {
return (
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
<PaymentStatusBadge status="failed" />
<AppButton
color="secondary"
variant="contained"
size="large"
onClick={() =>
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
}
sx={{ m: 0 }}
>
{t('retry_payment')}
</AppButton>
<AppButton
variant="text"
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
sx={{ m: 0 }}
>
{t('back_to_request')}
</AppButton>
</StateCard>
);
}
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
return (
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}>
<CircularProgress color="primary" size="2.5rem" />
<PaymentStatusBadge status="pending" />
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()} sx={{ m: 0 }}>
{t('check_again')}
</AppButton>
</StateCard>
);
}
function StateCard({
icon,
tone,
title,
body,
children,
}: {
icon: string;
tone: string;
title: string;
body?: string;
children?: ReactNode;
}) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon={icon} size={44} color={tone} />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{body ? (
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{body}
</Typography>
) : null}
{children}
</Stack>
</Paper>
);
}
@@ -120,7 +120,12 @@ export default function BookingRequestStatusPage() {
tone="var(--bal-success)" tone="var(--bal-success)"
title={t('converted_title')} title={t('converted_title')}
ctaLabel={t('converted_cta')} ctaLabel={t('converted_cta')}
onCta={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} // Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
onCta={() =>
router.push(
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
)
}
/> />
</Stack> </Stack>
); );
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import faMessages from '../../../messages/fa.json';
// next-intl mocked to read the REAL fa message file, so the test pins the product-mandated escrow copy
// as it actually ships — a rewording in fa.json (or a broken key) fails here as a conscious change.
jest.mock('next-intl', () => ({
useTranslations: (namespace: string) => (key: string) => {
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
return messages[namespace][key];
},
}));
import EscrowNotice from './EscrowNotice';
function renderNotice() {
return render(
<ThemeProvider>
<EscrowNotice />
</ThemeProvider>,
);
}
describe('<EscrowNotice/> component', () => {
it('renders the mandated escrow copy verbatim from fa.json', () => {
renderNotice();
expect(
screen.getByText('مبلغ به‌صورت امانی نزد بالین‌یار می‌ماند و پس از پایان ویزیت آزاد می‌شود'),
).toBeInTheDocument();
expect(screen.getByText(faMessages.payment.escrow_notice)).toBeInTheDocument();
});
it('renders as an info (trust) surface, never an error tone', () => {
renderNotice();
const alert = screen.getByTestId('escrow-notice');
expect(alert.className).toContain('MuiAlert-colorInfo');
expect(alert.className).not.toContain('MuiAlert-colorError');
});
});
@@ -0,0 +1,37 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
/**
* The escrow trust callout product-mandated copy («مبلغ بهصورت امانی نزد بالینیار میماند و پس از
* پایان ویزیت آزاد میشود»), rendered **verbatim in fa** as an info/trust surface (teal `--bal-info`
* tokens, a lock never an error tone). It is *why* the family pays on-platform, so f10/f11 must reuse
* this exact component rather than re-writing the message. Self-translating (the copy is fixed), no props.
* @component EscrowNotice
*/
const EscrowNotice: FunctionComponent = () => {
const t = useTranslations('payment');
return (
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon="lock" size={20} color="var(--bal-primary)" />}
data-testid="escrow-notice"
sx={{
marginY: 0,
// --bal-primary, not --bal-info: the info token is an alert *background* color and is too dark
// to read as text on the dark scheme; primary is the same deep teal in light and lifts in dark.
borderColor: 'var(--bal-primary)',
color: 'var(--bal-primary)',
backgroundColor: 'var(--bal-primary-soft)',
fontWeight: 500,
}}
>
{t('escrow_notice')}
</AppAlert>
);
};
export default EscrowNotice;
@@ -0,0 +1 @@
export { default } from './EscrowNotice';
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { PaymentTransactionStatus } from '@/services/payment/types';
// next-intl mocked to echo keys — each status must resolve its own pstatus_* label key.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
}));
import PaymentStatusBadge from './PaymentStatusBadge';
function renderBadge(status: PaymentTransactionStatus) {
return render(
<ThemeProvider>
<PaymentStatusBadge status={status} />
</ThemeProvider>,
);
}
// Every b10 wire status → its label key + the semantic chip kind it must map to.
const CASES: Array<{ status: PaymentTransactionStatus; kind: string }> = [
{ status: 'pending', kind: 'pending' },
{ status: 'succeeded', kind: 'verified' },
{ status: 'failed', kind: 'rejected' },
];
describe('<PaymentStatusBadge/> component', () => {
it.each(CASES)('renders the $status status with its label key and chip kind', ({ status, kind }) => {
const { container } = renderBadge(status);
expect(screen.getByText(`pstatus_${status}`)).toBeInTheDocument();
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
});
});
@@ -0,0 +1,31 @@
'use client';
import { FunctionComponent } from 'react';
import { useTranslations } from 'next-intl';
import type { ChipProps } from '@mui/material/Chip';
import StatusChip, { StatusKind } from '@/components/StatusChip';
import type { PaymentTransactionStatus } from '@/services/payment/types';
// The full b10 enum → chip kind; a status missing here fails the type check, so a contract enum change
// surfaces at build time instead of rendering an unmapped chip.
const STATUS_KIND: Record<PaymentTransactionStatus, StatusKind> = {
pending: 'pending',
succeeded: 'verified',
failed: 'rejected',
};
export interface PaymentStatusBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
status: PaymentTransactionStatus;
}
/**
* Payment-transaction status chip: maps the b10 wire code to a `--bal-*` semantic StatusChip variant and
* an i18n label (`payment.pstatus_*` labels are keys off the code, never derived from it). Shared by
* the checkout return surface now and the f10 refund / f11 BNPL surfaces later.
* @component PaymentStatusBadge
*/
const PaymentStatusBadge: FunctionComponent<PaymentStatusBadgeProps> = ({ status, ...rest }) => {
const t = useTranslations('payment');
return <StatusChip status={STATUS_KIND[status]} label={t(`pstatus_${status}`)} {...rest} />;
};
export default PaymentStatusBadge;
@@ -0,0 +1,2 @@
export { default } from './PaymentStatusBadge';
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
@@ -0,0 +1,56 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import { formatIrrToToman, parseIrr } from '@/utils';
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import PriceBreakdown, { PriceBreakdownProps } from './PriceBreakdown';
const ComponentToTest: FunctionComponent<PriceBreakdownProps> = (props) => (
<ThemeProvider>
<PriceBreakdown {...props} />
</ThemeProvider>
);
// Served-shaped figures: service + commission + VAT = total (the phase's reconciliation rule).
const ROWS = [
{ key: 'service_cost', label: 'Service cost', amountIrr: '39060000' },
{ key: 'commission', label: 'Balinyaar fee', amountIrr: '5400000' },
{ key: 'vat', label: 'VAT', amountIrr: '540000' },
];
const TOTAL = '45000000';
describe('<PriceBreakdown/> component', () => {
it('renders every row label with its Toman-formatted amount', () => {
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
for (const row of ROWS) {
expect(screen.getByText(row.label)).toBeInTheDocument();
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument();
}
});
it('renders a total equal to the integer sum of the served rows', () => {
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
const sum = ROWS.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
expect(sum.toString()).toBe(TOTAL);
expect(screen.getByText(new RegExp(formatIrrToToman(TOTAL, 'en')))).toBeInTheDocument();
});
it('exposes each row via a data attribute', () => {
const { container } = render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
expect(container.querySelector('[data-row="service_cost"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
});
it('warns loudly in dev when the rows do not reconcile to the total', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr="45000001" />);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
errorSpy.mockRestore();
});
});
@@ -0,0 +1,71 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import { formatIrrToToman, parseIrr } from '@/utils';
export interface PriceBreakdownRow {
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
key: string;
/** Display label — already translated by the caller (labels are i18n keys, never derived from codes). */
label: string;
/** IRR digit-string, straight off the wire. */
amountIrr: string;
}
export interface PriceBreakdownProps {
rows: PriceBreakdownRow[];
totalLabel: string;
/** IRR digit-string. Must equal the integer sum of `rows` — see the reconciliation guard below. */
totalAmountIrr: string;
}
/**
* The reconciling money breakdown (C6 checkout, invoice; f10/f11 reuse it for refunds/BNPL). Rows and
* total are **served amounts** this component only formats (BigInt-safe, Toman display via the money
* util) and never computes a figure. The one thing it enforces is the phase's hard rule: the displayed
* rows must sum to the displayed total. A mismatch is a data bug upstream, so it is surfaced loudly in
* dev (console.error) rather than silently rendered.
* @component PriceBreakdown
*/
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
const locale = useLocale();
const tc = useTranslations('common');
if (process.env.NODE_ENV !== 'production') {
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
if (sum !== parseIrr(totalAmountIrr)) {
console.error(
`PriceBreakdown: rows sum to ${sum} but total is ${totalAmountIrr} — a breakdown must reconcile to the rial.`,
);
}
}
return (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
{rows.map((row) => (
<Stack key={row.key} data-row={row.key} direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{row.label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatIrrToToman(row.amountIrr, locale)}
</Typography>
</Stack>
))}
<Divider />
<Stack data-row="total" direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
{totalLabel}
</Typography>
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
{formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
</Stack>
</Paper>
);
};
export default PriceBreakdown;
@@ -0,0 +1,2 @@
export { default } from './PriceBreakdown';
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
+8
View File
@@ -21,6 +21,9 @@ import NurseResultCard from './NurseResultCard';
import ServicePriceRow from './ServicePriceRow'; import ServicePriceRow from './ServicePriceRow';
import CountdownTimer from './CountdownTimer'; import CountdownTimer from './CountdownTimer';
import BookingRequestSummaryCard from './BookingRequestSummaryCard'; import BookingRequestSummaryCard from './BookingRequestSummaryCard';
import PriceBreakdown from './PriceBreakdown';
import EscrowNotice from './EscrowNotice';
import PaymentStatusBadge from './PaymentStatusBadge';
export { export {
UserInfo, UserInfo,
@@ -44,6 +47,9 @@ export {
ServicePriceRow, ServicePriceRow,
CountdownTimer, CountdownTimer,
BookingRequestSummaryCard, BookingRequestSummaryCard,
PriceBreakdown,
EscrowNotice,
PaymentStatusBadge,
}; };
export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput'; export type { OtpInputProps } from './OtpInput';
@@ -65,3 +71,5 @@ export type { NurseResultCardProps } from './NurseResultCard';
export type { ServicePriceRowProps } from './ServicePriceRow'; export type { ServicePriceRowProps } from './ServicePriceRow';
export type { CountdownTimerProps } from './CountdownTimer'; export type { CountdownTimerProps } from './CountdownTimer';
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard'; export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
+11 -1
View File
@@ -18,8 +18,14 @@ export const ROUTES = {
BOOKING_REQUEST: '/bookings/request', BOOKING_REQUEST: '/bookings/request',
// C5 awaiting-acceptance base — append `/{id}`; create navigates here, the id keys the polled status. // C5 awaiting-acceptance base — append `/{id}`; create navigates here, the id keys the polled status.
BOOKING_REQUEST_STATUS: '/bookings/request', BOOKING_REQUEST_STATUS: '/bookings/request',
// Checkout (pay & confirm) — the C5 accept CTA hands off here (screen itself is DEFERRED → f9 stub). // Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
CHECKOUT: '/bookings/checkout', CHECKOUT: '/bookings/checkout',
// Dev mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
// Return-from-gateway surface — pending-callback poll → succeeded/failed states.
CHECKOUT_RETURN: '/bookings/checkout/return',
// Post-payment success screen — links to the booking detail + invoice.
CHECKOUT_CONFIRMATION: '/bookings/checkout/confirmation',
PATIENTS: '/patients', PATIENTS: '/patients',
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
ADDRESSES: '/addresses', ADDRESSES: '/addresses',
@@ -49,5 +55,9 @@ export const ROUTES = {
ADMIN_NOTIFICATIONS: '/admin/notifications', ADMIN_NOTIFICATIONS: '/admin/notifications',
} as const; } as const;
/** A booking's invoice view (f9) — keyed by the booking the invoice belongs to. */
export const bookingInvoicePath = (bookingId: number | string): string =>
`${ROUTES.BOOKINGS}/${bookingId}/invoice`;
/** Paths (without locale prefix) that bypass auth in middleware. */ /** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN]; export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
@@ -14,13 +14,14 @@ const BASE = '/api/v1/booking_requests';
/** /**
* The b8 wire `BookingRequestDto` identical to our app DTO minus the client-augmented `variantPrice` * The b8 wire `BookingRequestDto` identical to our app DTO minus the client-augmented `variantPrice`
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price). * (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'>; type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */ /** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
function toDto(wire: BookingRequestWireDto): BookingRequestDto { function toDto(wire: BookingRequestWireDto): BookingRequestDto {
return { ...wire, variantPrice: null }; return { ...wire, variantPrice: null, bookingId: null };
} }
/** /**
@@ -36,6 +36,7 @@ function seedRow(overrides: Partial<BookingRequestDto>): BookingRequestDto {
return { return {
id, id,
status: 'pending_nurse_response', status: 'pending_nurse_response',
bookingId: null,
nurseId: 1, nurseId: 1,
nurseName: 'مریم رضایی', nurseName: 'مریم رضایی',
nurseRating: 4.9, nurseRating: 4.9,
@@ -167,6 +168,7 @@ function buildFromContext(
return { return {
id, id,
status: 'pending_nurse_response', status: 'pending_nurse_response',
bookingId: null,
nurseId: payload.nurseId, nurseId: payload.nurseId,
nurseName: context?.nurseName ?? '', nurseName: context?.nurseName ?? '',
nurseRating: context?.nurseRating ?? 0, nurseRating: context?.nurseRating ?? 0,
@@ -283,3 +285,20 @@ export const bookingRequestsMockApi: BookingRequestsApi = {
return updated; return updated;
}, },
}; };
/**
* Mock-only capture bridge (f9): the payment mock's stand-in for the server's webhook-confirm
* `BookingFactory` step. Marks a paid request `converted` and stamps the client-augmented `bookingId`
* (REQ-017) so C5's terminal card and the checkout confirmation can deep-link the booking. NOT part of
* the `BookingRequestsApi` seam no real endpoint does this from the client; only `services/payment`'s
* mock imports it.
*/
export function mockMarkBookingRequestConverted(id: number, bookingId: number): BookingRequestDto {
const dto = find(id);
if (dto.status !== 'accepted_awaiting_payment') {
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
const updated: BookingRequestDto = { ...dto, status: 'converted', bookingId };
store = store.map((row) => (row.id === id ? updated : row));
return updated;
}
@@ -59,10 +59,17 @@ export function isTerminalBookingRequestStatus(status: BookingRequestStatus): bo
* `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit` * `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit`
* but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the * but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the
* real client leaves it `null` (the summary then hides the amount) until the field lands. * real client leaves it `null` (the summary then hides the amount) until the field lands.
*
* `bookingId` is likewise **client-augmented** (filed as REQ-017): once a paid request converts, the b8
* DTO gives no way to reach the booking it became. The mock stamps it at capture so C5's `converted`
* card and the checkout confirmation can deep-link the booking; the real client leaves it `null` (those
* surfaces then fall back to the bookings list) until the field lands.
*/ */
export interface BookingRequestDto { export interface BookingRequestDto {
id: number; id: number;
status: BookingRequestStatus; status: BookingRequestStatus;
/** Client-augmented (REQ-017): the converted booking's id, or `null` until converted / on the real path. */
bookingId: number | null;
nurseId: number; nurseId: number;
nurseName: string; nurseName: string;
nurseRating: number; nurseRating: number;
@@ -380,3 +380,84 @@ export const bookingsMockApi: BookingsApi = {
return { ...updated }; return { ...updated };
}, },
}; };
// Converted bookings get ids above the seeded 5001/5002 range; their sessions above the seeded 700xx range.
let nextConvertedBookingId = 6001;
let nextConvertedSessionId = 80001;
/** What the payment mock knows about a captured request — enough to snapshot a faithful booking. */
export interface ConvertedBookingSeed {
bookingRequestId: number;
nurseId: number;
nurseName: string;
patientId: number;
patientName: string;
variantId: number;
variantLabel: string;
variantPriceUnit: string;
customerAddressId: number;
/** Pre-serialized address snapshot (customer view; the nurse view masks it), or `null` if unknown. */
addressSnapshotJson: string | null;
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
pspFeeAmount: string | null;
platformFeeRate: number;
scheduledDate: string;
scheduledTimeStart: string;
scheduledTimeEnd: string;
}
/**
* Mock-only capture bridge (f9): the client stand-in for the server's webhook-confirm `BookingFactory`
* step (b10) that creates & confirms the booking. Inserts a **confirmed** single-session booking built
* from the paid request's snapshot, so the f8 booking detail/list show the conversion immediately. NOT
* part of the `BookingsApi` seam no real endpoint does this from the client; only `services/payment`'s
* mock imports it.
*/
export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingDetailDto {
const now = new Date().toISOString();
const bookingId = nextConvertedBookingId++;
const booking: BookingDetailDto = {
id: bookingId,
bookingRequestId: seed.bookingRequestId,
status: 'confirmed',
nurseId: seed.nurseId,
nurseName: seed.nurseName,
patientId: seed.patientId,
patientName: seed.patientName,
variantId: seed.variantId,
variantSnapshotJson: JSON.stringify({ displayName: seed.variantLabel, priceUnit: seed.variantPriceUnit }),
customerAddressId: seed.customerAddressId,
addressSnapshotJson: seed.addressSnapshotJson,
grossPriceIrr: seed.grossPriceIrr,
balinyaarCommissionIrr: seed.balinyaarCommissionIrr,
nursePayoutAmount: seed.nursePayoutAmount,
pspFeeAmount: seed.pspFeeAmount,
platformFeeRate: seed.platformFeeRate,
sessionCount: 1,
scheduledDate: seed.scheduledDate,
scheduledTimeStart: seed.scheduledTimeStart,
scheduledTimeEnd: seed.scheduledTimeEnd,
confirmedAt: now,
completedAt: null,
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: null,
createdAt: now,
sessions: [
{
...makeSession(nextConvertedSessionId++, 1, 0, seed.nursePayoutAmount),
scheduledDate: seed.scheduledDate,
scheduledTimeStart: seed.scheduledTimeStart,
scheduledTimeEnd: seed.scheduledTimeEnd,
},
],
};
bookings = [booking, ...bookings];
return cloneBooking(booking);
}
@@ -0,0 +1,79 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import type {
CheckoutSummaryDto,
ConfirmGatewayReturnInput,
InitiatePaymentInput,
InitiatePaymentResult,
InvoiceDto,
PaymentApi,
PaymentOutcomeDto,
} from '../types';
const BOOKINGS = '/api/v1/bookings';
const BOOKING_REQUESTS = '/api/v1/booking_requests';
const INVOICES = '/api/v1/invoices';
/** The header b10's initiate (and b12's BNPL initiate) read the per-attempt idempotency key from. */
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
/**
* Real HTTP implementation of the `PaymentApi` seam. `initiatePayment` and `getInvoice` map the
* published b10/b11 contracts 1:1. The other two calls cover contract gaps the frontend filed:
* - `getCheckoutSummary` targets the **REQ-016 proposed route** (no checkout-summary endpoint exists;
* the b8 request DTO is money-free, and the client must never derive commission/VAT itself).
* - `getPaymentOutcome`/`confirmGatewayReturn` poll the existing `booking_requests/get/{id}` and map its
* status (`converted` succeeded, `payment_deadline_expired` failed, else pending) the b10
* contract has no client transaction read (REQ-017); the server verifies captures inside the webhook
* handler, so on return from the PSP there is nothing to "verify" client-side, only an outcome to read.
*
* NOT the primary implementation this phase (`USE_PAYMENT_MOCK = true`) see `constants.ts` for why.
*/
export const paymentClientApi: PaymentApi = {
getCheckoutSummary: async (bookingRequestId: number) =>
// REQ-016 proposed slug (action-style, mirrors b8's `[controller]/[action]` routing). 404s until the
// backend delivers it — which is why the domain stays mock-primary.
unwrap(
await clientFetch<ApiEnvelope<CheckoutSummaryDto>>(
`${BOOKING_REQUESTS}/checkout_summary/${bookingRequestId}`,
),
),
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) =>
unwrap(
await clientFetch<ApiEnvelope<InitiatePaymentResult>>(`${BOOKINGS}/${bookingRequestId}/payments`, {
method: 'POST',
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
}),
),
confirmGatewayReturn: async ({ bookingRequestId }: ConfirmGatewayReturnInput) =>
// The PSP hit the webhook before redirecting back — there is nothing to submit; read the outcome.
paymentClientApi.getPaymentOutcome(bookingRequestId),
getPaymentOutcome: async (bookingRequestId: number) => {
const request = unwrap(
await clientFetch<ApiEnvelope<Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>>>(
`${BOOKING_REQUESTS}/get/${bookingRequestId}`,
),
);
const outcome: PaymentOutcomeDto = {
bookingRequestId: request.id,
requestStatus: request.status,
transactionStatus:
request.status === 'converted'
? 'succeeded'
: request.status === 'payment_deadline_expired'
? 'failed'
: 'pending',
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
// to the bookings list until the field lands.
bookingId: null,
};
return outcome;
},
getInvoice: async (bookingId: number) =>
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_PAYMENT_MOCK } from '../constants';
import type { PaymentApi } from '../types';
import { paymentClientApi } from './clientApi';
import { paymentMockApi } from './mockApi';
/**
* The selected `PaymentApi` implementation the single seam the hooks import. Selection is by config
* (`USE_PAYMENT_MOCK`), never by scattered `if (mock)` checks.
*/
export const paymentApi: PaymentApi = USE_PAYMENT_MOCK ? paymentMockApi : paymentClientApi;
+267
View File
@@ -0,0 +1,267 @@
import { multiplyIrr, parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { ROUTES } from '@/constants';
import {
bookingRequestsMockApi,
mockMarkBookingRequestConverted,
} from '@/services/bookingRequests/apis/mockApi';
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import {
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
MOCK_PLATFORM_FEE_RATE,
MOCK_VAT_RATE,
} from '../constants';
import type {
CheckoutSummaryDto,
ConfirmGatewayReturnInput,
InitiatePaymentInput,
InitiatePaymentResult,
InvoiceDto,
PaymentApi,
PaymentOutcomeDto,
PaymentTransactionStatus,
} from '../types';
const MOCK_LATENCY_MS = 350;
/** A request is a single visit until multi-session requests exist (b8 carries one date/time window). */
const SESSION_COUNT = 1;
// Integer-only rate math: rates as parts-per-10000 so the money path never touches a float.
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
const RATE_SCALE = BigInt(10_000);
const FEE_RATE_PPM = BigInt(Math.round(MOCK_PLATFORM_FEE_RATE * Number(RATE_SCALE)));
const VAT_RATE_PPM = BigInt(Math.round(MOCK_VAT_RATE * Number(RATE_SCALE)));
/**
* The C6 money decomposition, all BigInt (mirrors what REQ-016 asks the server to serve):
* commission (net) = gross × feeRate; VAT = commission × vatRate (b11 rule: VAT is computed ON the
* commission, the platform's taxable supply); service cost = the remainder (= the nurse payout leg).
* Rows reconcile by construction: service + commission + vat = gross, and the b10 three-amount
* invariant holds with balinyaarCommission = commission + vat.
*/
function splitAmounts(grossIrr: string) {
const gross = parseIrr(grossIrr);
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
const serviceCost = gross - commissionNet - vat;
return {
grossPriceIrr: gross.toString(),
commissionIrr: commissionNet.toString(),
vatIrr: vat.toString(),
serviceCostIrr: serviceCost.toString(),
balinyaarCommissionIrr: (commissionNet + vat).toString(),
nursePayoutAmount: serviceCost.toString(),
};
}
interface MockTransaction {
transactionId: number;
bookingRequestId: number;
idempotencyKey: string;
status: PaymentTransactionStatus;
gatewayReferenceCode: string;
grossPriceIrr: string;
bookingId: number | null;
}
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
// checkout, the C5 poll, and the bookings list all observe the same capture.
let transactions: MockTransaction[] = [];
const invoices: Record<number, InvoiceDto> = {};
let nextTransactionId = 42_001;
let nextInvoiceId = 101;
function latestTransactionFor(bookingRequestId: number): MockTransaction | undefined {
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
}
function requestGross(request: BookingRequestDto): string {
// The frozen gross the server would charge: variant price × session count, never client-supplied.
// A request without a price (REQ-013 unmet) must fail loudly — never a reconciling 0-rial checkout.
if (request.variantPrice == null) {
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
}
return multiplyIrr(request.variantPrice, SESSION_COUNT);
}
function toOutcome(request: BookingRequestDto, transaction: MockTransaction | undefined): PaymentOutcomeDto {
return {
bookingRequestId: request.id,
requestStatus: request.status,
transactionStatus: transaction?.status ?? null,
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
};
}
/** The webhook-confirm stand-in: flip the transaction, convert the request, insert the booking, invoice it. */
function capture(request: BookingRequestDto, transaction: MockTransaction): PaymentOutcomeDto {
const amounts = splitAmounts(transaction.grossPriceIrr);
const booking = mockInsertConvertedBooking({
bookingRequestId: request.id,
nurseId: request.nurseId,
nurseName: request.nurseName,
patientId: request.patientId,
patientName: request.patientName,
variantId: request.variantId,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
customerAddressId: request.customerAddressId,
addressSnapshotJson: JSON.stringify({
title: request.addressTitle,
city: request.cityNameFa,
district: request.districtNameFa,
line: request.addressLine,
postalCode: request.postalCode,
}),
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
// The PSP's cut is a platform expense outside the three-amount split; 2% matches the f8 seeds.
pspFeeAmount: ((parseIrr(amounts.grossPriceIrr) * BigInt(200)) / RATE_SCALE).toString(),
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
scheduledDate: request.requestedDate,
scheduledTimeStart: request.requestedTimeStart,
scheduledTimeEnd: request.requestedTimeEnd,
});
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
transaction.status = 'succeeded';
transaction.bookingId = booking.id;
invoices[booking.id] = {
id: nextInvoiceId,
bookingId: booking.id,
invoiceNumber: `INV-${String(nextInvoiceId).padStart(10, '0')}`,
issuingEntityType: 'platform',
grossIrr: amounts.grossPriceIrr,
platformCommissionIrr: amounts.commissionIrr,
bnplCommissionIrr: null,
vatRate: MOCK_VAT_RATE,
vatIrr: amounts.vatIrr,
moadianReferenceNumber: null,
moadianStatus: 'pending',
pdfUrl: null,
issuedAt: new Date().toISOString(),
};
nextInvoiceId += 1;
return toOutcome(converted, transaction);
}
/**
* In-memory mock behind the `PaymentApi` seam the missing conversion trigger between the f7 and f8
* mock stores. It serves the unserved C6 summary (REQ-016), plays the PSP + webhook roles (initiate
* app-relative gateway-harness redirect capture on the success return), enforces b10's idempotency
* semantics (same-key retry reuses the attempt; a repeat initiate after capture is a `409`; a replayed
* return is a no-op), and issues the b11-shaped invoice at capture. Swap to the real `clientApi` once
* the upstream domains are real and REQ-016/017 land (`USE_PAYMENT_MOCK = false`).
*/
export const paymentMockApi: PaymentApi = {
getCheckoutSummary: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const amounts = splitAmounts(requestGross(request));
const summary: CheckoutSummaryDto = {
bookingRequestId: request.id,
requestStatus: request.status,
nurseName: request.nurseName,
patientName: request.patientName,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
sessionCount: SESSION_COUNT,
requestedDate: request.requestedDate,
requestedTimeStart: request.requestedTimeStart,
requestedTimeEnd: request.requestedTimeEnd,
paymentDeadlineAt: request.paymentDeadlineAt,
serviceCostIrr: amounts.serviceCostIrr,
commissionIrr: amounts.commissionIrr,
vatIrr: amounts.vatIrr,
vatRate: MOCK_VAT_RATE,
totalIrr: amounts.grossPriceIrr,
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
};
return summary;
},
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const existing = latestTransactionFor(bookingRequestId);
if (request.status === 'converted' || existing?.status === 'succeeded') {
throw new ApiError(409, 'Request already paid', 'already_paid');
}
if (request.status !== 'accepted_awaiting_payment') {
// Covers the lapsed 30-minute window (`payment_deadline_expired`) and every other non-payable state.
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
// b10 idempotency: a retried start with the same `Idempotency-Key` reuses the attempt/reference.
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'pending') {
return toInitiateResult(existing);
}
const transaction: MockTransaction = {
transactionId: nextTransactionId++,
bookingRequestId,
idempotencyKey,
status: 'pending',
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
grossPriceIrr: requestGross(request),
bookingId: null,
};
transactions = [transaction, ...transactions];
return toInitiateResult(transaction);
},
confirmGatewayReturn: async ({ bookingRequestId, transactionId, outcome }: ConfirmGatewayReturnInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const transaction =
(transactionId != null ? transactions.find((t) => t.transactionId === transactionId) : undefined) ??
latestTransactionFor(bookingRequestId);
// Replays / double-taps converge (the webhook dedup analogue): if capture already happened, report it.
if (request.status === 'converted' || transaction?.status === 'succeeded') {
return toOutcome(request, transaction);
}
if (!transaction) throw new ApiError(404, 'Payment transaction not found', 'not_found');
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
// A declined gateway, or the payment window lapsed while the customer was at the gateway.
transaction.status = 'failed';
return toOutcome(request, transaction);
}
return capture(request, transaction);
},
getPaymentOutcome: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
return toOutcome(request, latestTransactionFor(bookingRequestId));
},
getInvoice: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
const invoice = invoices[bookingId];
// b11: the invoice exists only once issued — a clean 404 until then (the UI shows "not issued yet").
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
return { ...invoice };
},
};
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
const query = new URLSearchParams();
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
return {
transactionId: transaction.transactionId,
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
gatewayReferenceCode: transaction.gatewayReferenceCode,
};
}
+54
View File
@@ -0,0 +1,54 @@
/**
* When true, the payment domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PaymentApi` seam.
*
* **Mock is primary this phase.** The b10 `initiate` endpoint and the b11 `invoices/{bookingId}` read are
* live server-side, but the checkout cannot run real end-to-end from the client yet:
* - the accepted request being paid comes from the **mock-primary** `bookingRequests` store (f7), so a
* real initiate would reference an id that exists only in memory;
* - the contract serves **no checkout summary** (gross/commission/VAT for C6 REQ-016) and **no
* client-readable transaction status** for the return poll (REQ-017);
* - nothing fires the PSP webhook in dev (b10's mock provider returns a fake redirect URL and the
* "webhook simulator" is a manual server-side POST), so a real payment would never confirm.
* The mock closes the loop: capture converts the f7 request, inserts a **confirmed** booking into the f8
* 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.
*/
export const USE_PAYMENT_MOCK = true;
/**
* f11 wires the BNPL method screens (D1D5); until then C6 renders the «یا پرداخت اقساطی» seam as a
* clearly-deferred secondary (disabled + "coming soon"), never a dead button.
*/
export const BNPL_ENABLED = false;
/** Prices/deadlines can move between visits — keep the checkout summary short-lived. */
export const CHECKOUT_SUMMARY_STALE_TIME = 10 * 1000;
/** An issued invoice is immutable — cache it for the session. */
export const INVOICE_STALE_TIME = 60 * 60 * 1000;
/**
* The pending-callback poll ("PSP received ≠ cash in bank" a pending state is normal, reflect it
* calmly). Starts at ~2s, grows geometrically, caps, and gives up after a bounded number of attempts
* never a tight loop. Terminal statuses stop the poll regardless (see `usePaymentOutcome`).
*/
export const PAYMENT_OUTCOME_POLL_BASE_MS = 2 * 1000;
export const PAYMENT_OUTCOME_POLL_GROWTH = 1.5;
export const PAYMENT_OUTCOME_POLL_MAX_MS = 15 * 1000;
export const PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS = 40;
/** Checkout deep-link query params (C5 hands off `request_id`; the gateway round-trip carries the rest). */
export const CHECKOUT_QUERY_REQUEST_ID = 'request_id';
export const CHECKOUT_QUERY_TRANSACTION_ID = 'transaction_id';
export const CHECKOUT_QUERY_BOOKING_ID = 'booking_id';
export const CHECKOUT_QUERY_OUTCOME = 'outcome';
/**
* Mock-only money rates. `MOCK_PLATFORM_FEE_RATE` matches the f8 seeds' snapshotted `platformFeeRate`
* (0.12); `MOCK_VAT_RATE` is the current 10% VAT (product: VAT applies to **Balinyaar's commission
* only** and the rate is config-driven server-side b11 serves `vatRate`/`vatIrr`; the client never
* derives tax on the real path).
*/
export const MOCK_PLATFORM_FEE_RATE = 0.12;
export const MOCK_VAT_RATE = 0.1;
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
/**
* The C6 payload badge status, nurse/service mini-summary, and the served, reconciling money rows
* (service + commission + VAT = total). Short `staleTime`: the price/deadline can change between visits,
* and after a capture the summary's `requestStatus` must flip promptly (it is also invalidated by
* `invalidateAfterPaymentSuccess`). Enabled only when an id is present.
*/
export function useCheckoutSummary(bookingRequestId: number | undefined) {
return useQuery({
queryKey: paymentKeys.summary(bookingRequestId ?? -1),
queryFn: () => paymentApi.getCheckoutSummary(bookingRequestId as number),
enabled: bookingRequestId != null && bookingRequestId > 0,
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
});
}
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { invalidateAfterPaymentSuccess } from '../invalidations';
import type { ConfirmGatewayReturnInput } from '../types';
/**
* Report the return from the gateway and read the outcome. On the real path the PSP already confirmed
* via the webhook, so this is a read; in the mock it is the capture trigger. When the outcome is already
* `succeeded`, the affected booking/request caches are invalidated here (the booking flips to confirmed
* by cache invalidation, never a blanket refetch); a still-`pending` outcome is primed into the outcome
* key so the pending-callback poll (`usePaymentOutcome`) starts from fresh data.
*/
export function useConfirmGatewayReturn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ConfirmGatewayReturnInput) => paymentApi.confirmGatewayReturn(input),
onSuccess: (outcome) => {
queryClient.setQueryData(paymentKeys.outcome(outcome.bookingRequestId), outcome);
if (outcome.transactionStatus === 'succeeded') {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
},
});
}
@@ -0,0 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import type { InitiatePaymentInput } from '../types';
/**
* Start a card payment for an accepted request. The caller owns the **per-attempt idempotency key**
* (generated once per attempt and reused across retries of that attempt see the C6 page), because only
* the UI knows when a *new* attempt starts (after a failed/cancelled outcome). On success the caller
* navigates to `redirectUrl`. A `409` here means "already paid / already in progress / window lapsed"
* the page converges (routes to the return surface to read the outcome) instead of toasting; other
* domain 4xx render inline via `mutation.error` (the fetch layer owns 401/403/5xx toasts).
*/
export function useInitiatePayment() {
return useMutation({
mutationFn: (input: InitiatePaymentInput) => paymentApi.initiatePayment(input),
});
}
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { ApiError } from '@/lib/api/errors';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { INVOICE_STALE_TIME } from '../constants';
/**
* The booking's commission invoice (b11). An issued invoice is immutable long `staleTime`, no polling.
* A 404 is a domain state, not a transient failure ("not issued yet" b11 issues via an admin action;
* auto-issue on capture is filed as REQ-018), so it renders as an empty state and is **not retried**.
*/
export function useInvoice(bookingId: number | undefined) {
return useQuery({
queryKey: paymentKeys.invoice(bookingId ?? -1),
queryFn: () => paymentApi.getInvoice(bookingId as number),
enabled: bookingId != null && bookingId > 0,
staleTime: INVOICE_STALE_TIME,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
}
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import {
PAYMENT_OUTCOME_POLL_BASE_MS,
PAYMENT_OUTCOME_POLL_GROWTH,
PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS,
PAYMENT_OUTCOME_POLL_MAX_MS,
} from '../constants';
import { isTerminalPaymentOutcome } from '../types';
/**
* The pending-callback poll. "PSP received ≠ cash in bank" a pending state after the gateway return is
* normal, so this polls **with geometric backoff** (base ×growth cap) and **stops** on any terminal
* outcome or after a bounded number of attempts never a tight loop, never hammering the endpoint. The
* return surface offers a manual re-check once the budget is spent. `dataUpdateCount` is the attempt
* counter (it increments per successful fetch, which is exactly the cadence backoff should follow).
*/
export function usePaymentOutcome(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: paymentKeys.outcome(bookingRequestId ?? -1),
queryFn: () => paymentApi.getPaymentOutcome(bookingRequestId as number),
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
refetchInterval: (query) => {
const outcome = query.state.data;
if (outcome && isTerminalPaymentOutcome(outcome)) return false;
const attempt = query.state.dataUpdateCount;
if (attempt >= PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS) return false;
return Math.min(
PAYMENT_OUTCOME_POLL_BASE_MS * PAYMENT_OUTCOME_POLL_GROWTH ** attempt,
PAYMENT_OUTCOME_POLL_MAX_MS,
);
},
});
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Payment domain barrel re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCheckoutSummary } from './hooks/useCheckoutSummary';
export { useInitiatePayment } from './hooks/useInitiatePayment';
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
export { useInvoice } from './hooks/useInvoice';
@@ -0,0 +1,27 @@
import type { QueryClient } from '@tanstack/react-query';
import { bookingKeys } from '@/services/bookings/keys';
import { bookingRequestKeys } from '@/services/bookingRequests/keys';
import { paymentKeys } from './keys';
/**
* The one cache transition a successful capture causes: the request flipped `converted` and a confirmed
* booking now exists. Invalidate exactly the affected keys the request detail + inboxes, the bookings
* lists (a new row), the specific booking detail when known, and this request's checkout summary/outcome
* never a blanket refetch (phase rule: the booking flips to confirmed *by cache invalidation*, no
* refetch storm). Shared by `useConfirmGatewayReturn` (immediate success) and the pending-callback poll
* (late success).
*/
export function invalidateAfterPaymentSuccess(
queryClient: QueryClient,
bookingRequestId: number,
bookingId: number | null,
): void {
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
if (bookingId != null) {
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
}
queryClient.invalidateQueries({ queryKey: paymentKeys.summary(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: paymentKeys.outcome(bookingRequestId) });
}
+14
View File
@@ -0,0 +1,14 @@
/**
* React Query key factory for the payment domain (hierarchical, per the `services/{domain}` pattern).
* Summary and outcome are keyed by the booking-request id (payment runs against the accepted request);
* the invoice is keyed by the booking id it belongs to.
*/
export const paymentKeys = {
all: ['payment'] as const,
summaries: () => [...paymentKeys.all, 'summary'] as const,
summary: (bookingRequestId: number) => [...paymentKeys.summaries(), bookingRequestId] as const,
outcomes: () => [...paymentKeys.all, 'outcome'] as const,
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
invoices: () => [...paymentKeys.all, 'invoice'] as const,
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
};
+150
View File
@@ -0,0 +1,150 @@
import type { PriceUnit } from '@/services/catalog/types';
import { isTerminalBookingRequestStatus, type BookingRequestStatus } from '@/services/bookingRequests/types';
/**
* Payment domain the checkout/card-capture layer (b10) plus the customer invoice read (b11). Shapes
* mirror the published contracts (`dev/contracts/domains/payments.md`, `refunds-invoices.md`; camelCase
* wire, `clientFetch` unwraps the `ApiEnvelope<T>`).
*
* Load-bearing semantics (contracts + phase §5):
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
* `@/utils` BigInt helpers, display as Toman via `formatIrrToToman`. No float math on the money path.
* - **Payment is initiated against the accepted REQUEST, not a booking** the `bookings` row is created
* & confirmed on capture (the PSP webhook), never on initiate.
* - **Idempotency is per attempt.** One stable `Idempotency-Key` (header) per payment attempt, reused
* across retries of that attempt; a NEW attempt gets a new key. A `409` on initiate means "already in
* progress / already captured" a benign convergence, never an error toast.
* - **There is no client verify endpoint.** The server re-verifies inside the webhook handler; the client
* learns the outcome by polling (`getPaymentOutcome`). On the real path that poll maps the request
* status (`converted` succeeded); a first-class transaction-status read is filed as REQ-017.
* - **VAT is on Balinyaar's commission only** (the platform's taxable supply) never the nurse payout.
*/
/** `payment_transactions.status` (b10 contract enum): no other states cross the wire. */
export type PaymentTransactionStatus = 'pending' | 'succeeded' | 'failed';
/** `invoices.moadian_status` (b11) — the e-invoicing registration state, surfaced read-only. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
/** What the dev gateway round-trip reports back (mock harness); the real PSP signals via the webhook. */
export type GatewayReturnOutcome = 'success' | 'failure';
/**
* The C6 payload. **Not yet served by the backend** (REQ-016): the b8 `BookingRequestDto` is money-free
* and no checkout-summary endpoint exists, so the mock builds this and the real `clientApi` targets the
* proposed route until it lands. Display rows are **served, never client-derived** in particular
* `vatIrr` (the client must not derive tax with a float rate) and reconcile by construction:
* `serviceCostIrr + commissionIrr + vatIrr = totalIrr`, with `totalIrr = grossPriceIrr` and the b10
* three-amount invariant `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
*/
export interface CheckoutSummaryDto {
bookingRequestId: number;
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
requestStatus: BookingRequestStatus;
nurseName: string;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
sessionCount: number;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Server-frozen UTC end of the 30-minute payment window. */
paymentDeadlineAt: string | null;
/** Display row: the nursing service itself (= the nurse payout leg). IRR digit-string. */
serviceCostIrr: string;
/** Display row: Balinyaar's commission **net of VAT**. IRR digit-string. */
commissionIrr: string;
/** Display row: VAT on the commission (the served figure — never derived client-side). */
vatIrr: string;
/** Snapshotted VAT rate (decimal, e.g. 0.1) — informational; the rial figure is `vatIrr`. */
vatRate: number;
/** Display total (= `grossPriceIrr`); the rows above sum to exactly this. */
totalIrr: string;
/** The b10 three-amount split (gross = commission + payout, guaranteed server-side). */
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
}
/** `POST bookings/{bookingRequestId}/payments` input — the key travels as the `Idempotency-Key` header. */
export interface InitiatePaymentInput {
bookingRequestId: number;
/** Stable per payment attempt (reused across retries of the same attempt); a new attempt gets a new one. */
idempotencyKey: string;
}
/** `InitiatePaymentResult` (b10 swagger): where to send the customer, and the PSP reference. */
export interface InitiatePaymentResult {
transactionId: number;
/** Absolute PSP URL on the real path; an app-relative gateway-harness path from the mock. */
redirectUrl: string | null;
gatewayReferenceCode: string | null;
}
/**
* What the return-from-gateway surface reports: the harness outcome plus which transaction came back.
* On the real path the PSP has already hit the webhook before redirecting the impl just re-reads the
* outcome; in the mock this IS the capture trigger (the client stand-in for the webhook confirm).
*/
export interface ConfirmGatewayReturnInput {
bookingRequestId: number;
transactionId: number | null;
outcome: GatewayReturnOutcome;
}
/**
* The pending-callback poll target. No client transaction read exists in the b10 contract (REQ-017), so
* the real path derives `transactionStatus` from the request status (`converted` succeeded,
* `payment_deadline_expired` failed, else pending) and `bookingId` stays `null` until the backend
* serves it; the mock knows both first-hand.
*/
export interface PaymentOutcomeDto {
bookingRequestId: number;
requestStatus: BookingRequestStatus;
transactionStatus: PaymentTransactionStatus | null;
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
bookingId: number | null;
}
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
return (
outcome.transactionStatus === 'succeeded' ||
outcome.transactionStatus === 'failed' ||
isTerminalBookingRequestStatus(outcome.requestStatus)
);
}
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
export interface InvoiceDto {
id: number;
bookingId: number;
/** Sequential, gap-free human-facing reference (e.g. `INV-0000000001`) — treat as opaque. */
invoiceNumber: string;
issuingEntityType: 'platform' | 'partner_center';
grossIrr: string;
/** Balinyaar's commission — the VAT-relevant line (`vatIrr = round(commission × vatRate)`). */
platformCommissionIrr: string;
bnplCommissionIrr: string | null;
vatRate: number;
vatIrr: string;
moadianReferenceNumber: string | null;
moadianStatus: MoadianStatus | null;
pdfUrl: string | null;
issuedAt: string;
}
/**
* The payment API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_PAYMENT_MOCK`), never scattered `if (mock)` checks.
*/
export interface PaymentApi {
getCheckoutSummary(bookingRequestId: number): Promise<CheckoutSummaryDto>;
initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
getInvoice(bookingId: number): Promise<InvoiceDto>;
}
+26
View File
@@ -0,0 +1,26 @@
# Post-phase server audit — 2026-07-10
A read-only audit of the completed backend chain (backend-phase-0 → 15), produced after the final backend
phase shipped. Three deliverables, each in canonical Markdown with a matching self-contained HTML view
(`index.html` is the browsable entry point):
| Deliverable | What it answers |
| --- | --- |
| [post-phase-backend-plan.md](post-phase-backend-plan.md) · [html](post-phase-backend-plan.html) | What backend work remains — 8 prioritized, runnable "post-phases" (security hygiene → money-path fixes → contract batch → scheduler/Redis → trust rails → money rails → observability → later) |
| [frontend-backend-gaps.md](frontend-backend-gaps.md) · [html](frontend-backend-gaps.html) | REQ-001…015 reconciled against the shipped contract + code: 2 done, 1 doc-fix, 12 missing; plus what unbuilt f9f15 will hit, and the unblock priority |
| [runtime-services.md](runtime-services.md) · [html](runtime-services.html) | The deployment topology: 17 services/rails derived from the seams + config, a dependency graph, per-service defaults/config keys/health notes |
**Executive summary.** The chain is genuinely complete against its own specs — 358 green tests, and the
load-bearing invariants (balanced ledger groups, four money DB CHECKs, webhook idempotency, tenancy 404s,
forward-only status machines) all verifiably exist in code. The API's only real external dependency today
is SQL Server; all 18 vendor/infra seams are deterministic in-process mocks, which is the designed MVP
posture. What the audit surfaced beyond that design: **(1)** committed live credentials — a real `sa`
connection string in `appsettings*.json`, placeholder JWE/encryption keys, and a seeded `admin`/`qw123321`
user — that block any deployment; **(2)** one genuine money-correctness hole — the BNPL/manual refund
settlement path is unreachable (`Refund.MarkSucceededAsync` has zero callers), so those refunds strand
`refund_payable`/`escrow_held` forever; **(3)** the frontend is still 11/12 domains mock-primary because 12
of its 15 filed REQs were never delivered and none were answered; **(4)** unattended operation doesn't
exist yet — payout batches, credential-expiry scans, no-show sweeps, and Moadian reconciliation are
admin-click-only while their cadence config keys sit unread; and **(5)** the promised forward-dep FKs
(refunds→tickets, clawbacks→payouts, invoices→partner_centers) were never added after their target tables
shipped. Full evidence and the fix-by-fix plan are in the three documents.
@@ -0,0 +1,122 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Frontend ↔ backend gaps — REQ reconciliation</title>
<style>
:root{
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
--warn:#a04b12; color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
--warn:#e09355;
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
p{margin:.6rem 0;}
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
padding: .9rem 1rem;overflow-x:auto;}
pre code{background:none;padding:0;}
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
table{border-collapse:collapse;width:100%;font-size:.92rem;}
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
tbody tr:last-child td{border-bottom:none;}
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
li{margin:.35rem 0;}
li>code:first-child{font-weight:600;}
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
nav.toc strong{display:block;margin-bottom:.4rem;}
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
nav.toc>ul{padding-inline-start:0;}
nav.toc li{margin:.2rem 0;}
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
nav.toc .l3 a{color:var(--muted);}
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
.crumbs a{color:var(--muted);}
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
details.src summary{cursor:pointer;}
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
svg .t2{font-size:11px;fill:var(--muted);}
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
svg .box.mock{stroke-dasharray:5 4;}
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
svg .edge{fill:none;stroke-width:1.7;}
svg .edge.real{stroke:var(--accent);}
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
svg .lbl{font-size:10.5px;fill:var(--muted);}
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
</style>
</head>
<body><main>
<div class="crumbs"><a href="index.html">← Post-phase server audit</a></div>
<h1 id="frontend-backend-gaps-req-reconciliation">Frontend ↔ backend gaps — REQ reconciliation</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p><nav class="toc"><strong>Contents</strong><ul><li class="l2"><a href="#verdict-summary">Verdict summary</a></li><li class="l2"><a href="#per-req-detail">Per-REQ detail</a></li><li class="l3"><a href="#req-001-envelope-casing-pagination-done-needs-a-written-confirmation-one-caveat">REQ-001 — envelope, casing, pagination — <strong>Done, needs a written confirmation + one caveat</strong></a></li><li class="l3"><a href="#req-002-otp-length-expiry-missing">REQ-002 — OTP length + expiry — <strong>Missing</strong></a></li><li class="l3"><a href="#req-003-machine-readable-verify-otp-errors-missing">REQ-003 — machine-readable verify_otp errors — <strong>Missing</strong></a></li><li class="l3"><a href="#req-004-activerole-confirmation-missing-recommend-answer-client-owns-it">REQ-004 — activeRole confirmation — <strong>Missing (recommend: answer "client owns it")</strong></a></li><li class="l3"><a href="#req-005-patient-relation-conditions-missing">REQ-005 — patient relation + conditions — <strong>Missing</strong></a></li><li class="l3"><a href="#req-006-avatar-upload-avatarurl-missing">REQ-006 — avatar upload + avatarUrl — <strong>Missing</strong></a></li><li class="l3"><a href="#req-007-customer-name-preferred-language-missing">REQ-007 — customer name + preferred language — <strong>Missing</strong></a></li><li class="l3"><a href="#req-008-accept-the-client-map-pin-missing">REQ-008 — accept the client map pin — <strong>Missing</strong></a></li><li class="l3"><a href="#req-009-provinceid-on-customeraddressdto-missing">REQ-009 — provinceId on CustomerAddressDto — <strong>Missing</strong></a></li><li class="l3"><a href="#req-010-pagesize-param-name-partial-server-verified-docs-stale">REQ-010 — pageSize param name — <strong>Partial (server verified; docs stale)</strong></a></li><li class="l3"><a href="#req-011-nurse-credential-details-isrequired-missing">REQ-011 — nurse credential_details + isRequired — <strong>Missing</strong></a></li><li class="l3"><a href="#req-012-search-enrichment-public-nurse-profile-missing-highest-leverage-gap">REQ-012 — search enrichment + public nurse profile — <strong>Missing (highest-leverage gap)</strong></a></li><li class="l3"><a href="#req-013-variantprice-on-bookingrequestdto-missing">REQ-013 — variantPrice on BookingRequestDto — <strong>Missing</strong></a></li><li class="l3"><a href="#req-014-variantlabel-patientage-on-the-inbox-row-missing">REQ-014 — variantLabel/patientAge on the inbox row — <strong>Missing</strong></a></li><li class="l3"><a href="#req-015-enum-codes-checkinaddressmatch-tri-state-done-verified-needs-a-written-confirmation">REQ-015 — enum codes + checkInAddressMatch tri-state — <strong>Done (verified), needs a written confirmation</strong></a></li><li class="l2"><a href="#beyond-the-filed-reqs-what-f9-f15-will-hit">Beyond the filed REQs — what f9f15 will hit</a></li><li class="l2"><a href="#frontend-unblock-priority">Frontend-unblock priority</a></li></ul></nav>
<p><strong>Audit date:</strong> 2026-07-10 · <strong>Sources:</strong> <code>dev/shared-working-context/frontend/requests/for-backend.md</code> (REQ-001…015), the frontend phase reports/STATUS (f0f8), the client code's mock flags, the published contracts (<code>dev/contracts/domains/*.md</code> + <code>dev/contracts/openapi/swagger.v1.json</code>), and the server code. Every verdict was checked against <strong>both</strong> the contract surface and the actual DTO/handler/controller code.</p>
<p><strong>Headline:</strong> the backend chain is complete (b0b15), but of the 15 filed REQs only <strong>REQ-001</strong> and <strong>REQ-015</strong> are effectively satisfied and <strong>REQ-010</strong> is a documentation fix — the other <strong>12 are undelivered</strong>. Every REQ still reads <code>Status: open</code> in the tracker. As a direct consequence, <strong>11 of the client's 12 service domains still default to mock-primary</strong> (only auth is real-default, <code>client/src/services/auth/constants.ts:6</code>). Beyond the filed REQs, the unbuilt frontend phases f9f15 will consume backend surfaces that mostly exist — with one data gap (catalog option groups) and one pre-flagged shape gap (checkout VAT line).</p>
<h2 id="verdict-summary">Verdict summary</h2>
<div class="tblwrap"><table><thead><tr><th>REQ</th><th>Ask (short)</th><th>Verdict</th><th>One-line evidence</th></tr></thead><tbody><tr><td>REQ-001</td><td>Envelope / casing / pagination shape</td><td><strong>Done</strong> (confirm + caveat)</td><td><code>ApiResult</code> + <code>PagedResult</code> match the typed shape; <code>statusCode</code> is an <strong>integer</strong> enum</td></tr><tr><td>REQ-002</td><td><code>codeLength</code>/<code>expiresInSeconds</code> on RequestOtpResult</td><td><strong>Missing</strong></td><td><code>RequestOtpResult.cs:7</code> has only <code>OtpSent</code>, <code>ResendAvailableInSeconds</code></td></tr><tr><td>REQ-003</td><td>Machine error codes for verify_otp</td><td><strong>Missing</strong></td><td>envelope has no <code>code</code> slot; lockout differs only by message text</td></tr><tr><td>REQ-004</td><td><code>activeRole</code> on MeResult (confirmation)</td><td><strong>Missing</strong> (answer: client owns it)</td><td><code>MeResult.cs:9</code> — no ActiveRole anywhere in the contract</td></tr><tr><td>REQ-005</td><td>Patient <code>relation</code> + <code>conditions</code></td><td><strong>Missing</strong></td><td><code>PatientDto.cs:7</code>, create/update commands unchanged</td></tr><tr><td>REQ-006</td><td>Avatar upload route + <code>avatarUrl</code></td><td><strong>Missing</strong></td><td>zero <code>IFormFile</code>/avatar matches in <code>server/src</code></td></tr><tr><td>REQ-007</td><td>Customer name + preferred language</td><td><strong>Missing</strong></td><td>upsert body is emergency-contact only</td></tr><tr><td>REQ-008</td><td>Accept client map pin on address create/update</td><td><strong>Missing</strong></td><td>commands have no lat/lng; always geocodes</td></tr><tr><td>REQ-009</td><td><code>provinceId</code> on CustomerAddressDto</td><td><strong>Missing</strong></td><td>DTO ends at RecipientPhone</td></tr><tr><td>REQ-010</td><td>pageSize vs page_size</td><td><strong>Partial</strong></td><td>server binds <code>pageSize</code> (verified); contract docs still say <code>page_size</code></td></tr><tr><td>REQ-011</td><td>Nurse credential_details endpoint + <code>isRequired</code></td><td><strong>Missing</strong></td><td>no such route/command; step DTO lacks isRequired</td></tr><tr><td>REQ-012</td><td>Search row name/avatar/distance + <code>GET nurses/{id}/profile</code></td><td><strong>Missing</strong></td><td>DTO ids-only; no profile action on NursesController</td></tr><tr><td>REQ-013</td><td><code>variantPrice</code> on BookingRequestDto</td><td><strong>Missing</strong></td><td>DTO has unit without price</td></tr><tr><td>REQ-014</td><td><code>variantLabel</code>/<code>patientAge</code> on list item</td><td><strong>Missing</strong></td><td>list DTO omits both</td></tr><tr><td>REQ-015</td><td>Status enum codes + <code>checkInAddressMatch</code> tri-state (confirmation)</td><td><strong>Done</strong> (verified in code)</td><td>snake_case string constants on the wire; null-when-no-GPS confirmed</td></tr></tbody></table></div>
<hr>
<h2 id="per-req-detail">Per-REQ detail</h2>
<h3 id="req-001-envelope-casing-pagination-done-needs-a-written-confirmation-one-caveat">REQ-001 — envelope, casing, pagination — <strong>Done, needs a written confirmation + one caveat</strong></h3>
<ul><li><strong>Frontend expects:</strong> payload always under <code>data</code> in <code>{ isSuccess, statusCode, message, requestId, data }</code>; camelCase JSON; lists as <code>{ items, total, page, pageSize }</code>.</li><li><strong>Backend ships:</strong> exactly that. <code>ApiResult</code> at <code>server/src/Core/Baya.Application/Models/ApiResult/ApiResult.cs:8</code> (+ generic <code>Data</code> at <code>:28</code>); <code>PagedResult&lt;T&gt;(Items, Total, Page, PageSize)</code> at <code>server/src/Core/Baya.Application/Models/Common/PagedResult.cs:4</code>; camelCase is the System.Text.Json default (no naming-policy override exists in <code>server/src/API</code>); swagger confirms (<code>dev/contracts/openapi/swagger.v1.json:13448</code> envelope, <code>:14205</code> paged shape).</li><li><strong>Caveat to communicate:</strong> <code>statusCode</code> serializes as an <strong>integer</strong> (<code>ApiResultStatusCode</code> enum, swagger <code>:13468-13469</code>) — matches the client's <code>number</code> typing, but worth stating so nobody expects an HTTP-status string.</li><li><strong>Fix:</strong> zero code. Write the confirmation into the REQ and mark it delivered.</li></ul>
<h3 id="req-002-otp-length-expiry-missing">REQ-002 — OTP length + expiry — <strong>Missing</strong></h3>
<ul><li><strong>Expected:</strong> <code>RequestOtpResult { otpSent, resendAvailableInSeconds, codeLength, expiresInSeconds }</code>.</li><li><strong>Actual:</strong> <code>server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs:7</code> — two fields only; handler returns them at <code>Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs:67</code>; swagger agrees (<code>swagger.v1.json:16539</code>). The client hardcodes <code>OTP_CODE_LENGTH = 6</code> (<code>client/src/services/auth/constants.ts:19</code>).</li><li><strong>Fix:</strong> add the two ints (code length is a constant today; TTL from the OTP provider options). S effort.</li></ul>
<h3 id="req-003-machine-readable-verify-otp-errors-missing">REQ-003 — machine-readable verify_otp errors — <strong>Missing</strong></h3>
<ul><li><strong>Expected:</strong> stable <code>code</code> (<code>otp_invalid</code> | <code>otp_expired</code> | <code>otp_locked</code>) + <code>retryAfterSeconds</code> on lockout.</li><li><strong>Actual:</strong> the envelope has no <code>code</code> slot (<code>ApiResult.cs:8</code>; <code>OperationResult</code> carries only boolean flags — <code>Models/Common/OperationResult.cs:14-31</code>). Wrong/expired share one anti-enumeration message (<code>Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs:24,30,34,50</code>); lockout is a different English string only (<code>:38</code>). The client keys off the mock-only <code>otp_locked</code> code (<code>client/src/services/auth/constants.ts:29</code>).</li><li><strong>Fix:</strong> add an optional <code>code</code> (+ optional <code>data</code>) to the failure envelope — a small <code>OperationResult</code>/<code>ApiResult</code> extension — and emit <code>otp_locked</code> + <code>retryAfterSeconds</code> from the lockout branch; keep wrong-vs-expired collapsed if enumeration-safety is preferred (state that in the REQ answer). SM effort (the only REQ touching a cross-cutting type).</li></ul>
<h3 id="req-004-activerole-confirmation-missing-recommend-answer-client-owns-it">REQ-004 — activeRole confirmation — <strong>Missing (recommend: answer "client owns it")</strong></h3>
<ul><li><strong>Actual:</strong> no <code>activeRole</code> on <code>MeResult</code> (<code>server/src/Core/Baya.Application/Models/Identity/MeResult.cs:9</code>) or anywhere in the contract (schema scan). No endpoint persists a current-role choice.</li><li><strong>Fix:</strong> zero code — write the decision (client-owned <code>intended_role</code> stands) into the REQ so the router behavior is contract-blessed. If the product later wants a persisted active role, it's a <code>me/select_role</code> extension.</li></ul>
<h3 id="req-005-patient-relation-conditions-missing">REQ-005 — patient relation + conditions — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> <code>PatientDto</code> ends at <code>InitialMedicalNotes</code>/<code>IsActive</code> (<code>Models/Identity/PatientDto.cs:7,15-16</code>); create/update commands unchanged (<code>Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs:12-19</code>, <code>UpdatePatient/UpdatePatientCommand.cs:8-16</code>); no <code>relation</code>/<code>conditions</code> in any schema.</li><li><strong>Fix:</strong> <code>relation</code> as a nullable code column; <code>conditions</code> as stable codes (JSON column or child table — child table if search/filtering is ever wanted). Gate: flips <code>USE_PATIENTS_MOCK</code> (<code>client/src/services/patients/constants.ts:8</code>). SM effort.</li></ul>
<h3 id="req-006-avatar-upload-avatarurl-missing">REQ-006 — avatar upload + avatarUrl — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> zero <code>IFormFile</code>/multipart/avatar usage in <code>server/src</code> (repo-wide grep); no <code>avatarUrl</code> on <code>NurseProfileDto</code> (swagger <code>:18945</code>) or <code>CustomerProfileDto</code> (<code>Models/Identity/CustomerProfileDto.cs:7</code>). The client's real path deliberately throws 501 (<code>client/src/services/profiles/apis/clientApi.ts:84</code>).</li><li><strong>Fix:</strong> <code>POST api/v1/{nurse|customer}_profiles/avatar</code> (multipart, size/type-validated) storing via <code>IObjectStorage</code> + <code>avatar_url</code> column on both profiles. Note it also feeds REQ-012 (search card avatar) and REQ-013 (nurse avatar on request detail) — deliver before or with those. M effort (first multipart endpoint; pairs with the object-storage swap, plan §5.5).</li></ul>
<h3 id="req-007-customer-name-preferred-language-missing">REQ-007 — customer name + preferred language — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> upsert body is emergency-contact only (<code>Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs:11</code>); <code>MeResult</code> exposes name read-only (<code>MeResult.cs:12</code>); no <code>preferredLanguage</code> anywhere (schema scan).</li><li><strong>Fix:</strong> decide the home (recommend: extend the upsert to write <code>Users.FirstName/LastName</code> + <code>preferred_language</code> on the customer profile) and answer the REQ. S effort.</li></ul>
<h3 id="req-008-accept-the-client-map-pin-missing">REQ-008 — accept the client map pin — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> create/update commands have no coordinates; the server always geocodes (<code>Features/Addresses/Commands/CreateAddress/CreateAddressCommand.cs:9-12</code>, <code>UpdateAddress/UpdateAddressCommand.cs:8-9</code>; swagger <code>:18051</code>). The user's pin is silently discarded on the real path — exactly what the REQ warned. This also degrades <strong>EVV accuracy</strong> (b9 measures distance to the stored coordinate; a mock/geocoded centroid is ±5 km off — <code>CrossCutting/Seams/MockGeocoder.cs:52</code>).</li><li><strong>Fix:</strong> optional <code>latitude</code>/<code>longitude</code> on both bodies; when present store as source <code>user_pin</code>, else geocode as today. S effort; do before the real geocoder swap (plan §5.4).</li></ul>
<h3 id="req-009-provinceid-on-customeraddressdto-missing">REQ-009 — provinceId on CustomerAddressDto — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> DTO fields run <code>Id..RecipientPhone</code> (<code>Models/Addresses/CustomerAddressDto.cs:9-24</code>); no <code>provinceId</code> (swagger <code>:17983</code> — the property exists only on <code>CityDto</code>).</li><li><strong>Fix:</strong> join <code>cities.province_id</code> into the address projections. S effort. Gate (with REQ-008): <code>USE_ADDRESSES_MOCK</code> (<code>client/src/services/addresses/constants.ts:10</code>).</li></ul>
<h3 id="req-010-pagesize-param-name-partial-server-verified-docs-stale">REQ-010 — pageSize param name — <strong>Partial (server verified; docs stale)</strong></h3>
<ul><li><strong>Actual:</strong> every list binds a <code>PageSize</code> record property via <code>[FromQuery]</code> — so the working wire name is camelCase <code>pageSize</code> (case-insensitive), and <code>page_size</code> <strong>silently does not bind</strong>. Verified: <code>Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.cs:8</code>, <code>Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.cs:8</code>, <code>Controllers/V1/NurseServiceAreasController.cs:35</code>, <code>AdminPayoutsController.cs:54</code>; swagger names the parameter <code>pageSize</code> (<code>swagger.v1.json:3104</code>). But the requested deliverable — fixing the docs — never happened: <code>dev/contracts/domains/catalog.md:41</code> and <code>config-reference.md:11</code> (and others, e.g. bookings-evv.md, verification.md) still write <code>page_size</code>.</li><li><strong>Fix:</strong> sweep the contract docs to <code>pageSize</code>, answer the REQ. Zero server code.</li></ul>
<h3 id="req-011-nurse-credential-details-isrequired-missing">REQ-011 — nurse credential_details + isRequired — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> the nurse-facing controller exposes only submit/status/upload_url/documents/run (<code>Controllers/V1/NurseVerificationController.cs:26-57</code>); repo-wide grep for <code>credential_details|SubmitCredential</code> finds nothing. <code>VerificationStepDto</code> has no <code>IsRequired</code> (<code>Models/Verification/VerificationDtos.cs:17</code>); the flag exists only on the admin step-type catalog. Consequence on the real path: the INO number + specialties a nurse types are <strong>silently dropped</strong> (<code>verificationClientApi.submitCredentialDetails</code> no-ops — <code>dev/shared-working-context/reports/frontend-phase-5-report.md:101</code>).</li><li><strong>Fix:</strong> <code>POST api/v1/nurse_verification/credential_details</code> writing the structured <code>nurse_credentials</code> fields (the registry table already stores number/authority/expiry), + project <code>isRequired</code> onto the step DTO. M effort. Gate: <code>USE_VERIFICATION_MOCK</code> (<code>client/src/services/verification/constants.ts:9</code>).</li></ul>
<h3 id="req-012-search-enrichment-public-nurse-profile-missing-highest-leverage-gap">REQ-012 — search enrichment + public nurse profile — <strong>Missing (highest-leverage gap)</strong></h3>
<ul><li><strong>Actual:</strong> <code>NurseSearchResultDto</code> carries ids + price/rating/gender/geo only (<code>Models/Search/NurseSearchResultDto.cs:8-19</code>); the public <code>NursesController</code> has trust_badge, reviews, review_tags — <strong>no <code>/profile</code></strong> (<code>Controllers/V1/NursesController.cs:24-36</code>); no <code>avatarUrl</code>/<code>distanceKm</code> anywhere in the contract (schema scan).</li><li><strong>Why it leads the priority list:</strong> C2/C3 are the trust funnel — the family picks a <em>named, faced, priced</em> nurse here; this single REQ keeps <code>services/search</code> mock-primary (<code>client/src/services/search/constants.ts:9</code>) and blocks the whole discovery→request→booking real-path chain (search feeds C4's nurse/variant ids).</li><li><strong>Fix:</strong> (a) denormalize <code>nurse_name</code>/<code>avatar_url</code> into <code>nurse_search_index</code> (the maintainer already re-derives rows from source — <code>Persistence/Services/Search/SearchIndexMaintainer.cs:25</code>; add columns + reindex-on-profile-change) or join at query time in <code>SqlNurseSearch</code>; <code>distanceKm</code> is optional — the district model makes it derived-if-cheap. (b) an aggregated <code>GET nurses/{id}/profile</code> composing existing reads (profile + variants + trust badge + latest published review). M effort; depends on REQ-006 for the avatar itself.</li></ul>
<h3 id="req-013-variantprice-on-bookingrequestdto-missing">REQ-013 — variantPrice on BookingRequestDto — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> the DTO has <code>VariantLabel</code> + <code>VariantPriceUnit</code> but no price and no nurse avatar (<code>Models/Booking/BookingRequestDto.cs:20-21</code>, full list <code>:11-42</code>; swagger <code>:16674</code>).</li><li><strong>Fix:</strong> join the variant's <code>Price</code> (IRR digit-string, consistent with the money convention) into the projection. The money-free rule stays intact — this is the display <em>rate</em>, not an engagement total (the request row still stores no money). S effort.</li></ul>
<h3 id="req-014-variantlabel-patientage-on-the-inbox-row-missing">REQ-014 — variantLabel/patientAge on the inbox row — <strong>Missing</strong></h3>
<ul><li><strong>Actual:</strong> <code>BookingRequestListItemDto</code> has neither (<code>Models/Booking/BookingRequestListItemDto.cs:10-21</code>; swagger <code>:16903</code>) — the nurse inbox can't show <em>which service</em> was requested without opening the detail.</li><li><strong>Fix:</strong> add <code>variantLabel</code> (already on the detail DTO); <code>patientAge</code> as a coarse band if product wants it. S effort. Gate (with REQ-013): <code>USE_BOOKING_REQUESTS_MOCK</code> (<code>client/src/services/bookingRequests/constants.ts:14</code>) — though that flip also needs the upstream domains real (see below).</li></ul>
<h3 id="req-015-enum-codes-checkinaddressmatch-tri-state-done-verified-needs-a-written-confirmation">REQ-015 — enum codes + checkInAddressMatch tri-state — <strong>Done (verified), needs a written confirmation</strong></h3>
<ul><li><strong>Verified in code:</strong> statuses are stored/projected as snake_case <strong>string constants</strong> — exactly the client unions: <code>Domain/Entities/Booking/BookingStatus.cs:11-30</code>, <code>BookingSessionStatus.cs:10-19</code>, <code>VisitVerificationStatus.cs:11-17</code>; DTOs copy them verbatim (<code>Models/Booking/BookingDtos.cs:97</code>), so no PascalCase/int ever hits the wire. <code>checkInAddressMatch</code> is <code>bool?</code> (<code>BookingDtos.cs:104</code>) assigned only inside the lat/lng-present branch (<code>Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.Handler.cs:64-77</code>) → <strong>null when GPS is absent</strong>; a <code>false</code> is advisory only (support alert + notification, no block — <code>:90-109</code>).</li><li><strong>One nuance to include in the answer:</strong> <code>null</code> also occurs when GPS <em>was</em> sent but the frozen booking address has no resolvable coordinates — the client copy for «موقعیت ثبت نشد» should tolerate that.</li><li><strong>Fix:</strong> zero code; write the confirmation, mark delivered.</li></ul>
<hr>
<h2 id="beyond-the-filed-reqs-what-f9-f15-will-hit">Beyond the filed REQs — what f9f15 will hit</h2>
<p>Frontend phases f0f8 are built (reports exist); <strong>f9f15 are specs only</strong>. Reconciling their declared consumption against the shipped backend:</p>
<div class="tblwrap"><table><thead><tr><th>Upcoming phase</th><th>Consumes</th><th>Backend reality</th><th>Verdict</th></tr></thead><tbody><tr><td>f9 checkout/card</td><td>b10 <code>payments.md</code> + b11 invoice</td><td>endpoints exist (initiate/webhook/<code>GET invoices/{booking_id}</code>), but <strong>no checkout-summary read with the VAT line</strong> — f8 already flagged <code>BookingDetailDto</code> has no tax field (<code>reports/frontend-phase-8-report.md:113</code>); f9's spec expects <code>vat_irr</code>/<code>vat_rate</code>/<code>redirect_url</code> shapes (<code>dev/phases/frontend/frontend-phase-9-b10.md:117,330</code>)</td><td><strong>Partial — pre-file the checkout-summary REQ now</strong></td></tr><tr><td>f10 refund status</td><td>b11 <code>refunds-invoices.md</code></td><td><code>GET refunds/{id}/status</code> + <code>GET invoices/{booking_id}</code> shipped (<code>Controllers/V1/RefundsController</code>, <code>InvoicesController</code>)</td><td>Done (verify shapes when f10 runs)</td></tr><tr><td>f11 BNPL</td><td>b12 <code>bnpl.md</code></td><td>full eligibility→initiate→status surface shipped (<code>CheckoutBnplController</code>)</td><td>Done (verify shapes)</td></tr><tr><td>f12 nurse earnings</td><td>b13 <code>payouts.md</code></td><td><code>nurse_payouts/history</code> + admin console shipped (<code>NursePayoutsController</code>, <code>AdminPayoutsController</code>)</td><td>Done (verify shapes)</td></tr><tr><td>f13 reviews/care records</td><td>b14 <code>reviews-records.md</code></td><td>submit/list/tags/moderation + care records shipped (5 controllers)</td><td>Done (verify shapes)</td></tr><tr><td>f14 tickets + notifications</td><td>b15 + <strong>b1 notifications</strong></td><td>tickets shipped; notifications <strong>verified present</strong>: <code>GET notifications/get_notifications</code>/<code>get_unread_count</code>, <code>POST mark_notification_read</code>/<code>mark_all_read</code> (<code>Controllers/V1/NotificationsController.cs:24-42</code>) — the f14 spec's worry about missing b1 endpoints is unfounded</td><td>Done</td></tr><tr><td>f15 admin/partner consoles</td><td>admin endpoints across b1/b6/b11/b13/b14/b15</td><td>all shipped per the chain (verification queue, refunds, payouts, moderation, config/holidays/audit/support-alerts, partner centers)</td><td>Done (expect shape-polish REQs when f15 runs)</td></tr></tbody></table></div>
<p><strong>Data gap (not a contract gap):</strong> flipping <code>USE_CATALOG_MOCK</code> against a fresh backend yields categories with <strong>no option groups</strong> — only the 5 categories are seeded; groups/values are admin-authored and the admin catalog UI is f15 (<code>reports/frontend-phase-4-report.md:92</code>). Until f15 (or a seed migration), the variant builder's required-option step has nothing to render on the real path. Recommend: a small representative option-group seed, or prioritize the f15 catalog manager.</p>
<p><strong>Tracker hygiene:</strong> all 15 REQs read <code>Status: open</code> (<code>for-backend.md:32…216</code>) and the mocks-registry's early block contradicts its own later rows (see plan §7.6). Whoever lands this batch should update both in the same change.</p>
<hr>
<h2 id="frontend-unblock-priority">Frontend-unblock priority</h2>
<ol><li><strong>REQ-012</strong> (search row enrichment + public profile) — unlocks the discovery funnel; everything downstream needs C2/C3 real. Include the <code>nurse_search_index</code> columns + reindex.</li><li><strong>REQ-005, REQ-008, REQ-009</strong> — the booking-request <em>inputs</em> (patients, addresses) go real; REQ-008 also protects EVV accuracy.</li><li><strong>REQ-013, REQ-014</strong> — the request flow prices/labels itself; with (1)+(2) the whole search→request→accept chain can flip to real.</li><li><strong>REQ-006, REQ-007</strong> — profile/avatar polish; REQ-006 also feeds (1) and (3)'s avatar fields.</li><li><strong>REQ-011</strong> — verification detail capture (stops silent INO/specialty data loss).</li><li><strong>REQ-002, REQ-003</strong> — auth UX polish (real path already works without them).</li><li><strong>Zero-code batch: REQ-001, REQ-004, REQ-010, REQ-015</strong> — written confirmations + contract-doc <code>page_size</code> sweep + tracker statuses.</li><li><strong>Pre-file the f9 checkout-summary REQ</strong> (VAT line, redirect_url, idempotency header echo) so b-side work can precede the f9 build.</li></ol>
</main></body></html>
@@ -0,0 +1,250 @@
# Frontend ↔ backend gaps — REQ reconciliation
**Audit date:** 2026-07-10 · **Sources:** `dev/shared-working-context/frontend/requests/for-backend.md`
(REQ-001…015), the frontend phase reports/STATUS (f0f8), the client code's mock flags, the published
contracts (`dev/contracts/domains/*.md` + `dev/contracts/openapi/swagger.v1.json`), and the server code.
Every verdict was checked against **both** the contract surface and the actual DTO/handler/controller code.
**Headline:** the backend chain is complete (b0b15), but of the 15 filed REQs only **REQ-001** and
**REQ-015** are effectively satisfied and **REQ-010** is a documentation fix — the other **12 are
undelivered**. Every REQ still reads `Status: open` in the tracker. As a direct consequence, **11 of the
client's 12 service domains still default to mock-primary** (only auth is real-default,
`client/src/services/auth/constants.ts:6`). Beyond the filed REQs, the unbuilt frontend phases f9f15 will
consume backend surfaces that mostly exist — with one data gap (catalog option groups) and one pre-flagged
shape gap (checkout VAT line).
## Verdict summary
| REQ | Ask (short) | Verdict | One-line evidence |
| --- | --- | --- | --- |
| REQ-001 | Envelope / casing / pagination shape | **Done** (confirm + caveat) | `ApiResult` + `PagedResult` match the typed shape; `statusCode` is an **integer** enum |
| REQ-002 | `codeLength`/`expiresInSeconds` on RequestOtpResult | **Missing** | `RequestOtpResult.cs:7` has only `OtpSent`, `ResendAvailableInSeconds` |
| REQ-003 | Machine error codes for verify_otp | **Missing** | envelope has no `code` slot; lockout differs only by message text |
| REQ-004 | `activeRole` on MeResult (confirmation) | **Missing** (answer: client owns it) | `MeResult.cs:9` — no ActiveRole anywhere in the contract |
| REQ-005 | Patient `relation` + `conditions` | **Missing** | `PatientDto.cs:7`, create/update commands unchanged |
| REQ-006 | Avatar upload route + `avatarUrl` | **Missing** | zero `IFormFile`/avatar matches in `server/src` |
| REQ-007 | Customer name + preferred language | **Missing** | upsert body is emergency-contact only |
| REQ-008 | Accept client map pin on address create/update | **Missing** | commands have no lat/lng; always geocodes |
| REQ-009 | `provinceId` on CustomerAddressDto | **Missing** | DTO ends at RecipientPhone |
| REQ-010 | pageSize vs page_size | **Partial** | server binds `pageSize` (verified); contract docs still say `page_size` |
| REQ-011 | Nurse credential_details endpoint + `isRequired` | **Missing** | no such route/command; step DTO lacks isRequired |
| REQ-012 | Search row name/avatar/distance + `GET nurses/{id}/profile` | **Missing** | DTO ids-only; no profile action on NursesController |
| REQ-013 | `variantPrice` on BookingRequestDto | **Missing** | DTO has unit without price |
| REQ-014 | `variantLabel`/`patientAge` on list item | **Missing** | list DTO omits both |
| REQ-015 | Status enum codes + `checkInAddressMatch` tri-state (confirmation) | **Done** (verified in code) | snake_case string constants on the wire; null-when-no-GPS confirmed |
---
## Per-REQ detail
### REQ-001 — envelope, casing, pagination — **Done, needs a written confirmation + one caveat**
- **Frontend expects:** payload always under `data` in
`{ isSuccess, statusCode, message, requestId, data }`; camelCase JSON; lists as
`{ items, total, page, pageSize }`.
- **Backend ships:** exactly that. `ApiResult` at
`server/src/Core/Baya.Application/Models/ApiResult/ApiResult.cs:8` (+ generic `Data` at `:28`);
`PagedResult<T>(Items, Total, Page, PageSize)` at
`server/src/Core/Baya.Application/Models/Common/PagedResult.cs:4`; camelCase is the System.Text.Json
default (no naming-policy override exists in `server/src/API`); swagger confirms
(`dev/contracts/openapi/swagger.v1.json:13448` envelope, `:14205` paged shape).
- **Caveat to communicate:** `statusCode` serializes as an **integer** (`ApiResultStatusCode` enum,
swagger `:13468-13469`) — matches the client's `number` typing, but worth stating so nobody expects an
HTTP-status string.
- **Fix:** zero code. Write the confirmation into the REQ and mark it delivered.
### REQ-002 — OTP length + expiry — **Missing**
- **Expected:** `RequestOtpResult { otpSent, resendAvailableInSeconds, codeLength, expiresInSeconds }`.
- **Actual:** `server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs:7` — two fields only;
handler returns them at `Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs:67`; swagger
agrees (`swagger.v1.json:16539`). The client hardcodes `OTP_CODE_LENGTH = 6`
(`client/src/services/auth/constants.ts:19`).
- **Fix:** add the two ints (code length is a constant today; TTL from the OTP provider options). S effort.
### REQ-003 — machine-readable verify_otp errors — **Missing**
- **Expected:** stable `code` (`otp_invalid` | `otp_expired` | `otp_locked`) + `retryAfterSeconds` on
lockout.
- **Actual:** the envelope has no `code` slot (`ApiResult.cs:8`; `OperationResult` carries only boolean
flags — `Models/Common/OperationResult.cs:14-31`). Wrong/expired share one anti-enumeration message
(`Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs:24,30,34,50`); lockout is a different
English string only (`:38`). The client keys off the mock-only `otp_locked` code
(`client/src/services/auth/constants.ts:29`).
- **Fix:** add an optional `code` (+ optional `data`) to the failure envelope — a small
`OperationResult`/`ApiResult` extension — and emit `otp_locked` + `retryAfterSeconds` from the lockout
branch; keep wrong-vs-expired collapsed if enumeration-safety is preferred (state that in the REQ
answer). SM effort (the only REQ touching a cross-cutting type).
### REQ-004 — activeRole confirmation — **Missing (recommend: answer "client owns it")**
- **Actual:** no `activeRole` on `MeResult`
(`server/src/Core/Baya.Application/Models/Identity/MeResult.cs:9`) or anywhere in the contract (schema
scan). No endpoint persists a current-role choice.
- **Fix:** zero code — write the decision (client-owned `intended_role` stands) into the REQ so the router
behavior is contract-blessed. If the product later wants a persisted active role, it's a `me/select_role`
extension.
### REQ-005 — patient relation + conditions — **Missing**
- **Actual:** `PatientDto` ends at `InitialMedicalNotes`/`IsActive`
(`Models/Identity/PatientDto.cs:7,15-16`); create/update commands unchanged
(`Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs:12-19`,
`UpdatePatient/UpdatePatientCommand.cs:8-16`); no `relation`/`conditions` in any schema.
- **Fix:** `relation` as a nullable code column; `conditions` as stable codes (JSON column or child table —
child table if search/filtering is ever wanted). Gate: flips `USE_PATIENTS_MOCK`
(`client/src/services/patients/constants.ts:8`). SM effort.
### REQ-006 — avatar upload + avatarUrl — **Missing**
- **Actual:** zero `IFormFile`/multipart/avatar usage in `server/src` (repo-wide grep); no `avatarUrl` on
`NurseProfileDto` (swagger `:18945`) or `CustomerProfileDto`
(`Models/Identity/CustomerProfileDto.cs:7`). The client's real path deliberately throws 501
(`client/src/services/profiles/apis/clientApi.ts:84`).
- **Fix:** `POST api/v1/{nurse|customer}_profiles/avatar` (multipart, size/type-validated) storing via
`IObjectStorage` + `avatar_url` column on both profiles. Note it also feeds REQ-012 (search card avatar)
and REQ-013 (nurse avatar on request detail) — deliver before or with those. M effort (first multipart
endpoint; pairs with the object-storage swap, plan §5.5).
### REQ-007 — customer name + preferred language — **Missing**
- **Actual:** upsert body is emergency-contact only
(`Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs:11`); `MeResult`
exposes name read-only (`MeResult.cs:12`); no `preferredLanguage` anywhere (schema scan).
- **Fix:** decide the home (recommend: extend the upsert to write `Users.FirstName/LastName` +
`preferred_language` on the customer profile) and answer the REQ. S effort.
### REQ-008 — accept the client map pin — **Missing**
- **Actual:** create/update commands have no coordinates; the server always geocodes
(`Features/Addresses/Commands/CreateAddress/CreateAddressCommand.cs:9-12`,
`UpdateAddress/UpdateAddressCommand.cs:8-9`; swagger `:18051`). The user's pin is silently discarded on
the real path — exactly what the REQ warned. This also degrades **EVV accuracy** (b9 measures distance to
the stored coordinate; a mock/geocoded centroid is ±5 km off — `CrossCutting/Seams/MockGeocoder.cs:52`).
- **Fix:** optional `latitude`/`longitude` on both bodies; when present store as source `user_pin`, else
geocode as today. S effort; do before the real geocoder swap (plan §5.4).
### REQ-009 — provinceId on CustomerAddressDto — **Missing**
- **Actual:** DTO fields run `Id..RecipientPhone` (`Models/Addresses/CustomerAddressDto.cs:9-24`); no
`provinceId` (swagger `:17983` — the property exists only on `CityDto`).
- **Fix:** join `cities.province_id` into the address projections. S effort. Gate (with REQ-008):
`USE_ADDRESSES_MOCK` (`client/src/services/addresses/constants.ts:10`).
### REQ-010 — pageSize param name — **Partial (server verified; docs stale)**
- **Actual:** every list binds a `PageSize` record property via `[FromQuery]` — so the working wire name is
camelCase `pageSize` (case-insensitive), and `page_size` **silently does not bind**. Verified:
`Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.cs:8`,
`Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.cs:8`,
`Controllers/V1/NurseServiceAreasController.cs:35`, `AdminPayoutsController.cs:54`; swagger names the
parameter `pageSize` (`swagger.v1.json:3104`). But the requested deliverable — fixing the docs — never
happened: `dev/contracts/domains/catalog.md:41` and `config-reference.md:11` (and others, e.g.
bookings-evv.md, verification.md) still write `page_size`.
- **Fix:** sweep the contract docs to `pageSize`, answer the REQ. Zero server code.
### REQ-011 — nurse credential_details + isRequired — **Missing**
- **Actual:** the nurse-facing controller exposes only submit/status/upload_url/documents/run
(`Controllers/V1/NurseVerificationController.cs:26-57`); repo-wide grep for
`credential_details|SubmitCredential` finds nothing. `VerificationStepDto` has no `IsRequired`
(`Models/Verification/VerificationDtos.cs:17`); the flag exists only on the admin step-type catalog.
Consequence on the real path: the INO number + specialties a nurse types are **silently dropped**
(`verificationClientApi.submitCredentialDetails` no-ops —
`dev/shared-working-context/reports/frontend-phase-5-report.md:101`).
- **Fix:** `POST api/v1/nurse_verification/credential_details` writing the structured
`nurse_credentials` fields (the registry table already stores number/authority/expiry), + project
`isRequired` onto the step DTO. M effort. Gate: `USE_VERIFICATION_MOCK`
(`client/src/services/verification/constants.ts:9`).
### REQ-012 — search enrichment + public nurse profile — **Missing (highest-leverage gap)**
- **Actual:** `NurseSearchResultDto` carries ids + price/rating/gender/geo only
(`Models/Search/NurseSearchResultDto.cs:8-19`); the public `NursesController` has trust_badge, reviews,
review_tags — **no `/profile`** (`Controllers/V1/NursesController.cs:24-36`); no
`avatarUrl`/`distanceKm` anywhere in the contract (schema scan).
- **Why it leads the priority list:** C2/C3 are the trust funnel — the family picks a *named, faced,
priced* nurse here; this single REQ keeps `services/search` mock-primary
(`client/src/services/search/constants.ts:9`) and blocks the whole discovery→request→booking real-path
chain (search feeds C4's nurse/variant ids).
- **Fix:** (a) denormalize `nurse_name`/`avatar_url` into `nurse_search_index` (the maintainer already
re-derives rows from source — `Persistence/Services/Search/SearchIndexMaintainer.cs:25`; add columns +
reindex-on-profile-change) or join at query time in `SqlNurseSearch`; `distanceKm` is optional — the
district model makes it derived-if-cheap. (b) an aggregated `GET nurses/{id}/profile` composing existing
reads (profile + variants + trust badge + latest published review). M effort; depends on REQ-006 for the
avatar itself.
### REQ-013 — variantPrice on BookingRequestDto — **Missing**
- **Actual:** the DTO has `VariantLabel` + `VariantPriceUnit` but no price and no nurse avatar
(`Models/Booking/BookingRequestDto.cs:20-21`, full list `:11-42`; swagger `:16674`).
- **Fix:** join the variant's `Price` (IRR digit-string, consistent with the money convention) into the
projection. The money-free rule stays intact — this is the display *rate*, not an engagement total (the
request row still stores no money). S effort.
### REQ-014 — variantLabel/patientAge on the inbox row — **Missing**
- **Actual:** `BookingRequestListItemDto` has neither (`Models/Booking/BookingRequestListItemDto.cs:10-21`;
swagger `:16903`) — the nurse inbox can't show *which service* was requested without opening the detail.
- **Fix:** add `variantLabel` (already on the detail DTO); `patientAge` as a coarse band if product wants
it. S effort. Gate (with REQ-013): `USE_BOOKING_REQUESTS_MOCK`
(`client/src/services/bookingRequests/constants.ts:14`) — though that flip also needs the upstream
domains real (see below).
### REQ-015 — enum codes + checkInAddressMatch tri-state — **Done (verified), needs a written confirmation**
- **Verified in code:** statuses are stored/projected as snake_case **string constants** — exactly the
client unions: `Domain/Entities/Booking/BookingStatus.cs:11-30`, `BookingSessionStatus.cs:10-19`,
`VisitVerificationStatus.cs:11-17`; DTOs copy them verbatim (`Models/Booking/BookingDtos.cs:97`), so no
PascalCase/int ever hits the wire. `checkInAddressMatch` is `bool?` (`BookingDtos.cs:104`) assigned only
inside the lat/lng-present branch
(`Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.Handler.cs:64-77`) → **null when GPS is
absent**; a `false` is advisory only (support alert + notification, no block — `:90-109`).
- **One nuance to include in the answer:** `null` also occurs when GPS *was* sent but the frozen booking
address has no resolvable coordinates — the client copy for «موقعیت ثبت نشد» should tolerate that.
- **Fix:** zero code; write the confirmation, mark delivered.
---
## Beyond the filed REQs — what f9f15 will hit
Frontend phases f0f8 are built (reports exist); **f9f15 are specs only**. Reconciling their declared
consumption against the shipped backend:
| Upcoming phase | Consumes | Backend reality | Verdict |
| --- | --- | --- | --- |
| f9 checkout/card | b10 `payments.md` + b11 invoice | endpoints exist (initiate/webhook/`GET invoices/{booking_id}`), but **no checkout-summary read with the VAT line** — f8 already flagged `BookingDetailDto` has no tax field (`reports/frontend-phase-8-report.md:113`); f9's spec expects `vat_irr`/`vat_rate`/`redirect_url` shapes (`dev/phases/frontend/frontend-phase-9-b10.md:117,330`) | **Partial — pre-file the checkout-summary REQ now** |
| f10 refund status | b11 `refunds-invoices.md` | `GET refunds/{id}/status` + `GET invoices/{booking_id}` shipped (`Controllers/V1/RefundsController`, `InvoicesController`) | Done (verify shapes when f10 runs) |
| f11 BNPL | b12 `bnpl.md` | full eligibility→initiate→status surface shipped (`CheckoutBnplController`) | Done (verify shapes) |
| f12 nurse earnings | b13 `payouts.md` | `nurse_payouts/history` + admin console shipped (`NursePayoutsController`, `AdminPayoutsController`) | Done (verify shapes) |
| f13 reviews/care records | b14 `reviews-records.md` | submit/list/tags/moderation + care records shipped (5 controllers) | Done (verify shapes) |
| f14 tickets + notifications | b15 + **b1 notifications** | tickets shipped; notifications **verified present**: `GET notifications/get_notifications`/`get_unread_count`, `POST mark_notification_read`/`mark_all_read` (`Controllers/V1/NotificationsController.cs:24-42`) — the f14 spec's worry about missing b1 endpoints is unfounded | Done |
| f15 admin/partner consoles | admin endpoints across b1/b6/b11/b13/b14/b15 | all shipped per the chain (verification queue, refunds, payouts, moderation, config/holidays/audit/support-alerts, partner centers) | Done (expect shape-polish REQs when f15 runs) |
**Data gap (not a contract gap):** flipping `USE_CATALOG_MOCK` against a fresh backend yields categories
with **no option groups** — only the 5 categories are seeded; groups/values are admin-authored and the
admin catalog UI is f15 (`reports/frontend-phase-4-report.md:92`). Until f15 (or a seed migration), the
variant builder's required-option step has nothing to render on the real path. Recommend: a small
representative option-group seed, or prioritize the f15 catalog manager.
**Tracker hygiene:** all 15 REQs read `Status: open` (`for-backend.md:32…216`) and the mocks-registry's
early block contradicts its own later rows (see plan §7.6). Whoever lands this batch should update both in
the same change.
---
## Frontend-unblock priority
1. **REQ-012** (search row enrichment + public profile) — unlocks the discovery funnel; everything
downstream needs C2/C3 real. Include the `nurse_search_index` columns + reindex.
2. **REQ-005, REQ-008, REQ-009** — the booking-request *inputs* (patients, addresses) go real; REQ-008
also protects EVV accuracy.
3. **REQ-013, REQ-014** — the request flow prices/labels itself; with (1)+(2) the whole
search→request→accept chain can flip to real.
4. **REQ-006, REQ-007** — profile/avatar polish; REQ-006 also feeds (1) and (3)'s avatar fields.
5. **REQ-011** — verification detail capture (stops silent INO/specialty data loss).
6. **REQ-002, REQ-003** — auth UX polish (real path already works without them).
7. **Zero-code batch: REQ-001, REQ-004, REQ-010, REQ-015** — written confirmations + contract-doc
`page_size` sweep + tracker statuses.
8. **Pre-file the f9 checkout-summary REQ** (VAT line, redirect_url, idempotency header echo) so b-side
work can precede the f9 build.
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Post-phase server audit — 2026-07-10</title>
<style>
:root{
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
--warn:#a04b12; color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
--warn:#e09355;
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
p{margin:.6rem 0;}
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
padding: .9rem 1rem;overflow-x:auto;}
pre code{background:none;padding:0;}
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
table{border-collapse:collapse;width:100%;font-size:.92rem;}
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
tbody tr:last-child td{border-bottom:none;}
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
li{margin:.35rem 0;}
li>code:first-child{font-weight:600;}
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
nav.toc strong{display:block;margin-bottom:.4rem;}
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
nav.toc>ul{padding-inline-start:0;}
nav.toc li{margin:.2rem 0;}
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
nav.toc .l3 a{color:var(--muted);}
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
.crumbs a{color:var(--muted);}
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
details.src summary{cursor:pointer;}
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
svg .t2{font-size:11px;fill:var(--muted);}
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
svg .box.mock{stroke-dasharray:5 4;}
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
svg .edge{fill:none;stroke-width:1.7;}
svg .edge.real{stroke:var(--accent);}
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
svg .lbl{font-size:10.5px;fill:var(--muted);}
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
</style>
</head>
<body><main>
<h1 id="post-phase-server-audit-2026-07-10">Post-phase server audit — 2026-07-10</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p>
<p>A read-only audit of the completed backend chain (backend-phase-0 → 15), produced after the final backend phase shipped. Three deliverables, each in canonical Markdown with a matching self-contained HTML view (<code>index.html</code> is the browsable entry point):</p>
<div class="tblwrap"><table><thead><tr><th>Deliverable</th><th>What it answers</th></tr></thead><tbody><tr><td><a href="post-phase-backend-plan.html">post-phase-backend-plan.md</a> · <a href="post-phase-backend-plan.html">html</a></td><td>What backend work remains — 8 prioritized, runnable "post-phases" (security hygiene → money-path fixes → contract batch → scheduler/Redis → trust rails → money rails → observability → later)</td></tr><tr><td><a href="frontend-backend-gaps.html">frontend-backend-gaps.md</a> · <a href="frontend-backend-gaps.html">html</a></td><td>REQ-001…015 reconciled against the shipped contract + code: 2 done, 1 doc-fix, 12 missing; plus what unbuilt f9f15 will hit, and the unblock priority</td></tr><tr><td><a href="runtime-services.html">runtime-services.md</a> · <a href="runtime-services.html">html</a></td><td>The deployment topology: 17 services/rails derived from the seams + config, a dependency graph, per-service defaults/config keys/health notes</td></tr></tbody></table></div>
<p><strong>Executive summary.</strong> The chain is genuinely complete against its own specs — 358 green tests, and the load-bearing invariants (balanced ledger groups, four money DB CHECKs, webhook idempotency, tenancy 404s, forward-only status machines) all verifiably exist in code. The API's only real external dependency today is SQL Server; all 18 vendor/infra seams are deterministic in-process mocks, which is the designed MVP posture. What the audit surfaced beyond that design: <strong>(1)</strong> committed live credentials — a real <code>sa</code> connection string in <code>appsettings*.json</code>, placeholder JWE/encryption keys, and a seeded <code>admin</code>/<code>qw123321</code> user — that block any deployment; <strong>(2)</strong> one genuine money-correctness hole — the BNPL/manual refund settlement path is unreachable (<code>Refund.MarkSucceededAsync</code> has zero callers), so those refunds strand <code>refund_payable</code>/<code>escrow_held</code> forever; <strong>(3)</strong> the frontend is still 11/12 domains mock-primary because 12 of its 15 filed REQs were never delivered and none were answered; <strong>(4)</strong> unattended operation doesn't exist yet — payout batches, credential-expiry scans, no-show sweeps, and Moadian reconciliation are admin-click-only while their cadence config keys sit unread; and <strong>(5)</strong> the promised forward-dep FKs (refunds→tickets, clawbacks→payouts, invoices→partner_centers) were never added after their target tables shipped. Full evidence and the fix-by-fix plan are in the three documents.</p>
</main></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,601 @@
# Post-development backend plan — fixes & improvements
**Audit date:** 2026-07-10 · **Scope:** the completed backend chain (backend-phase-0 → 15) under `server/`
· **Method:** read-only audit of code + `dev/` docs; every finding cites the file/line it was verified at.
Companion documents: [frontend-backend-gaps.md](frontend-backend-gaps.md) (the REQ-by-REQ contract
reconciliation) and [runtime-services.md](runtime-services.md) (the deployment topology).
The chain is genuinely complete against its own specs: all 16 backend phases shipped, 358 tests are green,
and the load-bearing invariants (balanced ledger, DB CHECKs, idempotency uniques, tenancy 404s, forward-only
status machines) verifiably exist in code. What remains falls into eight coherent "post-phases", ordered so
that each is a runnable unit of work: deployment blockers first, then a code-level money-correctness fix,
then the contract batch that unblocks the frontend lane, then the infrastructure and vendor swaps the seam
architecture was built for.
| Bucket | Theme | Blocking what? |
| --- | --- | --- |
| post-phase-1 | Security & config hygiene | Any non-local deployment |
| post-phase-2 | Money-path correctness completion | Ledger ⇄ bank reconciliation |
| post-phase-3 | Frontend-unblock contract batch | 11 of 12 client domains are still mock-primary |
| post-phase-4 | Scheduling, locking & multi-instance readiness | Unattended operation; >1 API instance |
| post-phase-5 | Identity & trust rails go real | Real nurses onboarding (OTP, KYC, docs) |
| post-phase-6 | Money rails go real | Real payments, payouts, tax |
| post-phase-7 | Observability, audit & ops hardening | Production diagnosability |
| post-phase-8 | Scale & later | Search scale, analytics, doc debt |
Status legend used below — **Current state** always cites what the code does *today*.
---
## post-phase-1 — Security & config hygiene (do before anything is deployed)
Everything in this bucket is small (S) and none of it changes behavior — but each item is a deployment
blocker, and two of them are live credential leaks sitting in git today.
### 1.1 Rotate and remove the committed SQL Server `sa` connection string
- **Why:** `appsettings.json` and `appsettings.Development.json` both commit a real connection string —
public IP `87.107.152.16`, login `sa`, plaintext password — for the app DB *and* the log DB. Anyone with
repo access owns the database (all PII ciphertext + the encryption keys sit in the same repo, see 1.2).
This directly violates root `CLAUDE.md` working agreement #6 ("Never commit secrets").
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:3-4` and
`appsettings.Development.json:3-4` (byte-identical files); consumed at
`server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41`
and by the Serilog sink at
`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:44`.
- **Change:** rotate the `sa` password on that server (assume compromised); create a least-privilege app
login; move both connection strings to user-secrets (dev) / environment variables (deploy); commit only a
placeholder. Consider `git filter-repo` history scrubbing, and add a secret-scanning pre-commit hook.
- **Files/layers:** `appsettings*.json` only (config).
- **Effort:** S · **Risk:** low (config move) · **Deps:** none. **Do this first.**
### 1.2 Replace placeholder JWE signing/encryption keys and field-encryption keys
- **Why:** the JWT/JWE `SecretKey`/`Encryptkey` are starter-template placeholders and the PII
field-encryption keys are committed `local-dev-…-change-me` strings. If these defaults reach any shared
environment, every token is forgeable and every encrypted PII column is decryptable. The access-token
lifetime is also ~7 days (`ExpirationMinutes: 10000`) and `RequireHttpsMetadata = false`.
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:7-8` (keys), `:12` (expiry);
`Seams:FieldEncryption` at `appsettings.json:15-18`; the JWE decryption key is wired at
`server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs:135`
and `RequireHttpsMetadata = false` at `:139`. `SymmetricFieldEncryptor` derives a real AES-256-CBC key
from whatever string is configured
(`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs:25`) — the
crypto is fine; the key *management* is dev-grade (mocks-registry row `IFieldEncryptor` 🟡 agrees).
- **Change:** per-environment secrets (env vars / Key Vault / KMS per the registry's "make it real"), a
sane access-token lifetime (≤ 60 min; refresh flow already exists), `RequireHttpsMetadata = true` outside
Development, real `Issuer`/`Audience` values (currently `"MyWebsite"`). Note: rotating the field key
requires a re-encryption migration for existing rows — do it before real PII exists.
- **Effort:** S (config) + M if key-rotation tooling is wanted · **Risk:** medium (existing dev-DB
ciphertext becomes unreadable — acceptable pre-launch) · **Deps:** 1.1.
### 1.3 Remove or environment-gate the seeded `admin` / `qw123321` user
- **Why:** every non-Testing boot creates a well-known admin account with a hardcoded weak password and the
full admin role — in production too.
- **Current state:** `server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs:48`
(`CreateAsync(user, "qw123321")`), invoked from `server/src/API/Baya.Web.Api/Program.cs:102`.
- **Change:** read the bootstrap admin credentials from configuration and only seed when explicitly
configured (or Development-only); force a password change on first login.
- **Effort:** S · **Risk:** low · **Deps:** none.
### 1.4 Environment-gate the auto-seeded sandbox ZarinPal payment gateway
- **Why:** `SeedPaymentGatewaysAsync` idempotently inserts an **active** sandbox ZarinPal gateway row
(all-zeros merchant id) on every boot — a production DB would silently contain an active sandbox money
gateway.
- **Current state:** `server/src/API/Baya.Web.Api/Program.cs:103`
`server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:106-117`
(sandbox row, `config_json` encrypted via the DbContext converter).
- **Change:** seed only in Development/Testing, or seed `is_active = false` and require an admin to
activate a real gateway (`payment_gateways` is already admin data).
- **Effort:** S · **Risk:** low · **Deps:** none; pairs with 6.1.
### 1.5 Fix the Kestrel HTTP/2-only default
- **Why:** `Kestrel:EndpointDefaults:Protocols = "Http2"` (set for the gRPC plugin) makes every endpoint
HTTP/2-only. Browsers can't speak h2c, so any non-TLS hop — container health probes, an HTTP/1.1 reverse
proxy leg, plain-HTTP Swagger — breaks. Locally it only works because `https://localhost:5002` negotiates
via ALPN.
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:29-33` (both files). The gRPC plugin that
motivated it serves a single duplicate OTP/token service
(`server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/UserGrpcServices.cs:18`).
- **Change:** default `Http1AndHttp2`; give gRPC its own `Http2` endpoint if kept (see 7.5 for the
keep-or-remove decision).
- **Effort:** S · **Risk:** low · **Deps:** none.
### 1.6 Make rate limiting proxy-aware and align the two payment webhooks
- **Why:** all rate-limit partitions key on `RemoteIpAddress` and no `ForwardedHeaders` middleware is
registered — behind any reverse proxy every client shares one 100 req/min bucket (self-DoS). Separately,
the two webhook siblings disagree: the BNPL webhook runs the 20/min `sensitive` policy while the card
webhook has only the global 100/min fallback — one of them is wrong on purpose or both by accident.
- **Current state:** partition key at
`server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69`; global limiter
`:35`; `Program.cs` has no `UseForwardedHeaders` (checked `server/src/API/Baya.Web.Api/Program.cs`).
Webhooks: `Controllers/V1/WebhooksBnplController.cs:28` (`sensitive`) vs `Controllers/V1/WebhooksController.cs:25`
(`[AllowAnonymous]`, global only).
- **Change:** add `ForwardedHeaders` middleware (trusting only the known proxy), partition on the resolved
client IP, and pick one deliberate webhook policy (PSP callbacks are bursty — a dedicated `webhook`
policy keyed per-provider is safer than `sensitive`).
- **Effort:** S · **Risk:** lowmedium (limiter behavior changes) · **Deps:** deployment topology decision.
---
## post-phase-2 — Money-path correctness completion
The ledger invariants verified clean (balanced groups throw at
`server/src/Core/Baya.Domain/Entities/Payments/LedgerPosting.cs:26,65,178,204`; the four DB CHECKs exist —
`CK_Bookings_AmountSplit`, `CK_NursePayouts_NetSplit`, `CK_Refunds_LegSplit`, `CK_BnplTransactions_SettleSplit`
— per the EF configs and `Migrations/ApplicationDbContextModelSnapshot.cs:345,3543,3842,206`). This bucket
closes the holes *around* those invariants.
### 2.1 Wire the unreachable BNPL/manual refund settlement (dead-end money state) — **top code fix**
- **Why:** a card refund posts its `refund_payable ↔ escrow_held` clearing immediately. A BNPL-revert or
manual-bank refund is left in `processing` with the clearing "deferred to reconciliation" — but **no
reconciliation path exists anywhere**: `Refund.MarkSucceededAsync` has zero call sites, no admin
endpoint/webhook/job performs `processing → succeeded`, and `LedgerPosting.RefundPayableClearing` has
exactly one call site (the immediate card path). Every BNPL/manual refund permanently overstates
`escrow_held` and strands `refund_payable` — the ledger will never reconcile with the bank.
- **Current state:** `server/src/Core/Baya.Domain/Entities/Refunds/Refund.cs:105` (uncalled);
`server/src/Core/Baya.Application/Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:139-143`
(clearing only when already `Succeeded`); `:226-231` (BNPL/manual → `MarkProcessing`, never succeeded);
the `Processing → Succeeded` edge exists unused in
`server/src/Core/Baya.Domain/Entities/Refunds/RefundTransitions.cs:16`; admin surface is create+list only
(`Controllers/V1/AdminRefundsController.cs:33`). The b11 handoff promised at least a manual trigger
(`dev/shared-working-context/backend/handoff/after-backend-phase-11.md:37`).
- **Change:** a `ConfirmRefundSettlementCommand` (admin `POST admin_refunds/{id}/confirm_settlement`, plus a
BNPL-callback branch when the provider confirms customer cash-back) that transitions
`processing → succeeded`, stamps `settled_at`, and posts `LedgerPosting.RefundPayableClearing` in the same
commit; a `mark_failed` counterpart. Tests for both channels.
- **Files/layers:** Application `Features/Refunds/`, API `AdminRefundsController`, Domain (rename
`MarkSucceededAsync` — it's not async), tests.
- **Effort:** M · **Risk:** medium (money path — but additive) · **Deps:** none; do before real BNPL (6.2).
### 2.2 Add the promised-but-missing FKs on the forward-dep columns
- **Why:** b11 created `refunds.ticket_id`, `nurse_clawbacks.original_payout_id` /
`recovered_in_payout_id`, and `invoices.partner_center_id` as FK-less nullable columns "until the target
table ships". The targets all shipped (b13 `nurse_payouts`, b15 `tickets`/`partner_centers`) and the
**values** are wired, but no phase added the constraints — referential integrity rests on application
discipline, and the config comments are now false.
- **Current state:** stale comment "no FK yet (tickets does not exist)" at
`server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/RefundsConfig/RefundConfig.cs:41`;
`NurseClawbackConfig.cs:12` (comment says b13 "wires the FKs" — it didn't; b13's `NursePayoutEngine`
migration touches no clawback FK); `InvoicesConfig/InvoiceConfig.cs:11` (`partner_center_id` has **no FK
and no index**); the b15 migration adds only `FK_NurseProfiles_PartnerCenters_PartnerCenterId`
(`Migrations/20260709232741_MessagingAndPartnerCenters.cs:293`). Value-side wiring confirmed:
`CreateRefundCommand.Handler.cs:53-61` (auto-ticket), `Refunds/NurseClawback.cs:50` (`MarkRecovered`),
`IssueInvoiceCommand.Handler.cs:63-64` (issuer/center).
- **Change:** one additive migration adding the three FK sets (`ON DELETE NO ACTION`) + an index on
`invoices.partner_center_id`; update the three config comments.
- **Effort:** S · **Risk:** low (data is young; verify no orphans first) · **Deps:** none.
### 2.3 Extend `IAuditable` to the admin-decided money & trust entities
- **Why:** the append-only `audit_logs` diff interceptor covers exactly three entities — `PlatformConfig`,
`PartnerCenter`, `Review`. Admin decisions on refunds (approve/reject), payouts (process/retry/fail), and
**nurse verification** (the trust-critical `is_verified` flip / suspend) leave no audit-diff row. For a
trust-first escrow platform these are precisely the actions an auditor asks about. (The ledger itself is
fine — append-only by construction, `LedgerEntry` is `IEntity`-only at
`server/src/Core/Baya.Domain/Entities/Payments/LedgerEntry.cs:13`.)
- **Current state:** the three implementors — `Domain/Entities/Configuration/PlatformConfig.cs:12`,
`Domain/Entities/PartnerCenters/PartnerCenter.cs:16`, `Domain/Entities/Reviews/Review.cs:15`. `Refund`
(`Domain/Entities/Refunds/Refund.cs:77`) and `NursePayout` (`Domain/Entities/Payouts/NursePayout.cs:62`)
are not `IAuditable`; `NurseVerification` isn't either.
- **Change:** add `IAuditable` to `Refund`, `NurseClawback`, `NursePayout`, `NursePayoutBatch`,
`NurseVerification` (the interceptor at `Persistence/Interceptors/AuditFieldInterceptor` already handles
any `IAuditable`); confirm `[AuditRedacted]` covers `iban_snapshot` before enabling.
- **Effort:** SM · **Risk:** low (write-volume growth on `audit_logs`; see 7.4 archival) · **Deps:** none.
### 2.4 Close the refund channel-execute-before-commit crash window
- **Why:** `CreateRefundCommand` executes the external channel call (PSP refund / BNPL revert) **before**
the first DB commit — a crash between provider success and commit loses the record of an executed refund.
The idempotency key means a *retry* won't double-refund, but nothing retries automatically and no record
exists to reconcile against.
- **Current state:** `Features/Refunds/Commands/CreateRefund/CreateRefundCommand.Handler.cs:106-111`
(channel executes), commit later in the same handler; idempotency key at `:85`.
- **Change:** persist the refund row in `pending` state (commit) *before* the channel call, then execute and
update — the standard two-phase intent/confirm shape the webhook handler already uses
(claim-key-first at `Features/Payments/Commands/HandlePaymentWebhook/HandlePaymentWebhookCommand.Handler.cs:76-88`).
- **Effort:** M · **Risk:** medium (touches the refund state machine; full test pass required) · **Deps:**
do together with 2.1.
### 2.5 Test the untested admin money paths
- **Why:** `WriteOffClawbackCommand` — an admin action that posts a `bad_debt` ledger group — has **zero
tests** (grep across `server/src/Tests` finds no `WriteOff` match). The webhook duplicate-race path
(`DbUpdateException` on a concurrent same-key insert) is only exercised sequentially. Messaging has no
Foundation-level handler tests (the `is_internal` boundary is covered only end-to-end in
`Tests/Baya.Test.Api/MessagingApiTests.cs:33`).
- **Current state:** handler at
`Features/Refunds/Commands/WriteOffClawback/WriteOffClawbackCommand.Handler.cs:26`; coverage inventory:
49 Foundation + 33 Api test files; payout retry / webhook replay / clawback fork / rebuild convergence
**are** tested (`Tests/Baya.Test.Foundation/Payouts/PayoutHandlerTests.cs:211`,
`Payments/PaymentWebhookTests.cs:64`, `Refunds/RefundHandlerTests.cs:81`, `Search/SearchIndexTests.cs:184`).
- **Change:** add write-off unit + API tests (balanced group, idempotency, 404/409 paths), a true racing
webhook-insert test, and Foundation tests for `PostMessage`/`GetTicketThread` internal-flag handling.
- **Effort:** SM · **Risk:** none · **Deps:** none.
### 2.6 Retire the orphaned `refund_ticket_required` config key
- **Why:** b15 superseded the config-gated rule by unconditionally auto-opening a refund ticket, so the
seeded key has zero production consumers and a now-false description ("Off until b15 ships the tickets
table") — a small honesty debt that will mislead an operator.
- **Current state:** seed row at
`Persistence/Configuration/ConfigurationConfig/PlatformConfigConfig.cs:49`; auto-open at
`CreateRefundCommand.Handler.cs:53-61`; only remaining reference is a test stub
(`Tests/Baya.Test.Foundation/Refunds/RefundsTestHost.cs:174`).
- **Change:** delete the seed row (migration) or repurpose it to gate whether a *customer-visible* ticket is
required; update the description either way.
- **Effort:** S · **Risk:** low · **Deps:** none.
---
## post-phase-3 — Frontend-unblock contract batch
Full field-level detail, evidence, and priority ordering live in
[frontend-backend-gaps.md](frontend-backend-gaps.md). Summary: of the 15 filed REQs, **REQ-001 and
REQ-015 are effectively done, REQ-010 is a doc fix, and the other 12 are undelivered**; 11 of the client's
12 service domains still default to mock-primary, most gated on exactly these items. This bucket is one
backend phase-sized batch of small DTO/endpoint additions:
- **3.1 Booking-surface fields (S):** `variantPrice` on `BookingRequestDto`, `variantLabel` (+ optional
`patientAge`) on `BookingRequestListItemDto` (REQ-013/014).
- **3.2 Identity/profile fields (SM):** patient `relation` + `conditions` (REQ-005); customer
name/preferred-language upsert (REQ-007); avatar upload endpoint + `avatarUrl` (REQ-006 — the only item
needing multipart + `IObjectStorage`).
- **3.3 Address fields (S):** accept the client map pin on create/update (REQ-008 — matters for EVV
accuracy later) + `provinceId` on `CustomerAddressDto` (REQ-009).
- **3.4 Search & public profile (M — the single highest-leverage item):** enrich `NurseSearchResultDto`
with `nurseName`/`avatarUrl` (+ optional `distanceKm`) and add the aggregated public
`GET nurses/{id}/profile` (REQ-012). Unblocks the discovery funnel (C2/C3).
- **3.5 Verification details (SM):** nurse-facing `credential_details` command + `isRequired` on
`VerificationStepDto` (REQ-011 — without it the INO number/specialties are silently dropped).
- **3.6 Auth polish (S):** `codeLength`/`expiresInSeconds` on `RequestOtpResult` (REQ-002); machine-readable
`code` (+ `retryAfterSeconds`) on OTP failures (REQ-003 — needs a small `OperationResult`/envelope
extension, the only cross-cutting piece).
- **3.7 Zero-code confirmations & doc fixes (S):** answer REQ-001/004/015 in the tracker; fix the stale
`page_size` occurrences in `dev/contracts/domains/*.md` (REQ-010 — the server verifiably binds camelCase
`pageSize`); mark every delivered REQ `delivered in …` (all 15 currently read `Status: open`).
**Effort:** one M-sized phase overall · **Risk:** low (additive DTO fields; regenerate
`dev/contracts/openapi/swagger.v1.json` after) · **Deps:** none — can run in parallel with post-phase-1/2.
---
## post-phase-4 — Scheduling, locking & multi-instance readiness
### 4.1 Real job scheduler + register the four deferred crons
- **Why:** only two recurring jobs exist (booking-request expiry every 1 min, notification retention every
24 h). The verification credential-expiry scan, the EVV no-show sweep, the **weekly payout batch**, and
the Moadian reconciliation poll are admin-manual-only — their cadence config keys are seeded but nothing
reads them on a schedule. Operationally today: credentials never re-expire, no-shows are never flagged,
and **nurses are not paid unless an operator clicks**.
- **Current state:** the two hosted services at
`Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67` (intervals hardcoded at
`Services/Notifications/NotificationRetentionHostedService.cs:19` and
`Services/Booking/BookingRequestExpiryHostedService.cs:21`). Manual triggers:
`Controllers/V1/AdminVerificationsController.cs:49` (`scan_expiring`), `AdminEvvController.cs:37`
(`detect_no_shows`), `AdminPayoutsController.cs:44` (batch generate). Unconsumed cadence keys:
`verification_expiry_scan_cadence_hours`, `no_show_scan_cadence_hours`, `nurse_payout_interval_days`
(`PlatformConfigConfig.cs:46,48,36`). Note: **no `IJobScheduler` interface exists** — the registry row is
aspirational naming; there is nothing to swap behind, only hosted services to re-home. No
Hangfire/Quartz package is referenced (`server/Directory.Packages.props` — verified absent).
- **Change:** adopt Hangfire (SQL Server storage — no new infra) or Quartz; move the two existing sweeps
and add recurring jobs for expiry-scan, no-show, payout-batch generation (+ 2.1's reconciliation and 6.5's
Moadian poll), each reading its seeded cadence key; keep the admin manual triggers as overrides. Payout
processing (money-moving) can stay human-approved — schedule *generation*, keep `process` manual until
trust is earned.
- **Effort:** ML · **Risk:** medium (new infra dependency in-app; jobs must stay idempotent — they already
are by design) · **Deps:** none hard; pairs with 4.2 for multi-instance.
### 4.2 Redis for `ICacheService` + `IDistributedLock`
- **Why:** both are in-process today. Cache: fine single-instance, silently wrong (stale geo/catalog/config
reads, generation-token invalidation not shared) the moment a second instance runs. Lock: the money-path
mutex (`booking:{id}:payment` / `:refund`) is a per-key `SemaphoreSlim` — no cross-instance protection
(DB uniques remain the correctness backstop, as designed, but the lock is doing nothing across nodes).
- **Current state:** `CrossCutting/Seams/MemoryCacheService.cs:11` and `InProcessDistributedLock.cs:14-22`,
registered at `CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:27,61`. No Redis package
referenced.
- **Change:** add `StackExchange.Redis`; `RedisCacheService` (same key/TTL scheme) +
`RedisDistributedLock` (SET NX PX + token-checked release, lease ≥ the longest money handler);
config-selected registration per the registry's make-it-real steps (rows `ICacheService`,
`IDistributedLock`).
- **Effort:** M · **Risk:** medium (lock semantics under expiry; keep DB uniques authoritative) ·
**Deps:** Redis service (see runtime-services.md).
### 4.3 Separate migrations from boot (multi-instance + least privilege)
- **Why:** every non-Testing boot runs `MigrateAsync` + three seeders — concurrent instance start-ups race
on DDL (no distributed lock exists at boot), and the app login needs permanent DDL rights.
- **Current state:** `server/src/API/Baya.Web.Api/Program.cs:99-104`;
`Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:92` (`MigrateAsync` unconditional).
- **Change:** a deploy-time migration step (`dotnet ef database update` in CI, or a `--migrate` one-shot
mode) and boot-time schema *check* instead of apply; seeders become idempotent deploy steps.
- **Effort:** SM · **Risk:** low · **Deps:** CI/CD pipeline exists.
---
## post-phase-5 — Identity & trust rails go real
Ordered by user impact: nobody can log in without 5.1.
### 5.1 Real SMS gateway behind `ISmsSender` — **launch-critical**
- **Why:** OTP delivery is a log statement; no real user can ever log in. This is the single seam standing
between the platform and its first real session.
- **Current state:** `CrossCutting/Seams/LoggingSmsSender.cs:12-16` (logs the code, phone last-4),
registered at `ServiceCollectionExtension.cs:32`. No `Seams:Sms` options exist yet (verified:
`SeamOptions.cs` has no Sms group).
- **Change:** per registry row `ISmsSender`: pick Kavenegar/Ghasedak/SMS.ir, add `Seams:Sms:{ApiKey,
SenderLine,BaseUrl}` options + package, implement pattern/template OTP send, config-selected
registration. Keep the per-phone resend window and `otp` rate policy untouched.
- **Effort:** M · **Risk:** low (isolated seam) · **Deps:** vendor account.
### 5.2 Real Shahkar + e-KYC vendors (`IShahkarVerifier`, `IIdentityKycProvider`)
- **Why:** nurse verification currently passes any well-formed input (mock passes everything except two
configured magic values) — the trust engine's automated steps assert nothing real.
- **Current state:** `CrossCutting/Seams/MockShahkarVerifier.cs:17-37`, `MockIdentityKycProvider.cs:17-25`;
registrations `:45-46`; config defaults `SeamOptions.cs:157-184`. The pipeline already persists
`external_response_json` and handles shared-SIM/mismatch as explicit states — the handler side is ready.
- **Change:** per registry rows: one Finnotech-style bridge client for both استعلام‌ها; keep shared-SIM as a
handled failure; persist real vendor refs. The phone-change re-trigger already works
(`ShahkarVerifiedAt` reset on phone change — b2).
- **Effort:** ML (vendor onboarding dominates) · **Risk:** medium (real-world failure modes) · **Deps:**
vendor contract; 5.1 not required but sensible first.
### 5.3 Real استعلام شبا (`IBankAccountOwnershipVerifier`)
- **Why:** the b13 first-payout gate (`matched_national_id = 1`) is currently satisfied by a mock that
matches every IBAN except one magic value — real money would flow against unverified account ownership.
- **Current state:** `CrossCutting/Seams/MockBankAccountOwnershipVerifier.cs:17-26` (nurseNationalId
deliberately ignored, comment `:21-22`); registration `:36`; defaults `SeamOptions.cs:204`.
- **Change:** per registry row; verify the payout gate end-to-end (`is_primary=1 AND is_verified=1 AND
matched_national_id=1` skip-with-reason path already exists in b13).
- **Effort:** M · **Risk:** medium (money gate) · **Deps:** same vendor family as 5.2 — bundle them.
### 5.4 Real geocoder (`IGeocoder`) behind Neshan
- **Why:** address coordinates (and therefore the EVV distance check) are deterministic fakes jittered ±5 km
around 8 hardcoded city centroids — EVV mismatch alerts are currently noise. Accepting the client pin
(REQ-008, post-phase-3) reduces but doesn't remove the need.
- **Current state:** `CrossCutting/Seams/MockGeocoder.cs:15-52`; registration `:40`; `Seams:Geocoding` is
one of only three seam sections present in `appsettings.json:22-26`.
- **Change:** per registry row (Neshan client, rate-limit/retry, keep the null-coordinate path).
- **Effort:** M · **Risk:** low · **Deps:** REQ-008 first (pin > geocode for EVV).
### 5.5 Real object storage (`IObjectStorage`) — MinIO/S3/ArvanCloud
- **Why:** verification documents (and future avatars/invoice PDFs) live on the API host's local disk under
a temp root — non-durable, non-shared, and `GetUrl` returns a `file://` URI rather than a presigned URL,
so the b6 "short-lived signed URL" contract is only shape-deep.
- **Current state:** `CrossCutting/Seams/LocalDiskObjectStorage.cs:12-56` (temp-dir fallback `:20`,
`file://` URL); `Seams:ObjectStorage:RootPath` empty in `appsettings.json:19-21`.
- **Change:** per registry row: S3-compatible client, presigned PUT/GET with expiry, bucket + creds from
config; migrate any existing dev files or reset.
- **Effort:** M · **Risk:** low · **Deps:** storage service; REQ-006 (avatar) builds on this.
### 5.6 Accept manual as the real path for MoH/INO credentials and partner licensing (document, don't build)
- **Why:** `ICredentialVerifier` and `ILicenseVerificationService` return "needs manual review" by design —
there is **no public MoH/INO/eNamad B2B API** today. The admin review flows are the real mechanism; the
seams exist so a portal API can slot in if one appears.
- **Current state:** `CrossCutting/Seams/MockCredentialVerifier.cs:18`,
`MockLicenseVerificationService.cs:26`; registry rows agree ("no public B2B API today").
- **Change:** none in code. Mark these 🟡 rows as "manual = intended MVP state" in the registry so they stop
reading as debt.
- **Effort:** S (docs) · **Risk:** none.
---
## post-phase-6 — Money rails go real
The seam shapes are faithful (idempotency keys, server-side re-verify, upsert-first webhooks are already the
handler behavior — verified at `HandlePaymentWebhookCommand.Handler.cs:31-88`), so each swap is an adapter,
not a redesign. **Do 2.1 first** so the BNPL refund path is complete before real money uses it.
### 6.1 Real PSP/IPG + webhook signatures + تسهیم (`IPaymentProvider`, `IWebhookVerifier`, `ISettlementSplitProvider`)
- **Why:** card capture, callback authenticity, and the settlement split are all deterministic mocks
(`VerifyAsync` always succeeds echoing the expected amount; any callback is "validly signed" unless it
contains a magic marker; any balanced split "settles"). The legal تسهیم model (provider splits to
registered IBANs; platform never holds funds) only exists as an interface.
- **Current state:** `CrossCutting/Seams/MockPaymentProvider.cs:14-24`, `MockWebhookVerifier.cs:15-21`,
`MockSettlementSplitProvider.cs:13`; registrations `:58-60`; merchant creds intended to come from the
encrypted `payment_gateways.config_json` (seed at `Persistence/ServiceCollectionExtensions.cs:109-117`).
- **Change:** per registry rows 3941: one acquirer-with-تسهیم (ZarinPal/Sadad/Vandar/Jibit); real
`InitPaymentAsync`/`VerifyAsync`/`RefundAsync`; per-provider HMAC verification of the raw body; a
provider registry/factory selected per gateway row; persist full gateway responses.
- **Effort:** L · **Risk:** high (real money; Shaparak certification lead time) · **Deps:** merchant
registration; 1.4; 2.1; 4.2 (real lock) strongly recommended.
### 6.2 Real BNPL adapters (`IBnplProvider` / `IBnplProviderResolver`)
- **Why:** the full BNPL state machine runs against one mock provider; per-contract commission and
non-instant settlement are simulated by config.
- **Current state:** `CrossCutting/Seams/MockBnplProvider.cs:17-53`, `MockBnplProviderResolver.cs:14-17`;
registrations `:72-74`; `Seams:Bnpl:*` defaults `SeamOptions.cs:98-120`. `bnpl_settlement_entries`
(tranched settlement) is modeled-but-not-built by design
(`dev/shared-working-context/backend/handoff/after-backend-phase-12.md:40`).
- **Change:** per registry row 46: SnappPay (OAuth verb set) and/or Digipay adapters, creds from encrypted
gateway config, Toman↔IRR only via `ICurrencyNormalizer`, per-contract commission read from the settle
response, resolver registered per `provider_code`.
- **Effort:** L · **Risk:** high · **Deps:** 2.1 (revert clearing), 6.1 patterns, provider contracts.
### 6.3 Real PAYA/SATNA payout rail (`IBankTransferProvider`) + async reconciliation
- **Why:** payouts "settle" instantly in the mock, collapsing the real `submitted → paid/failed` async
reconciliation; no money reaches nurses.
- **Current state:** `CrossCutting/Seams/MockBankTransferProvider.cs:18-31`; registration `:82`;
`Seams:BankTransfer` defaults `SeamOptions.cs:61-72`. The status machine, batch idempotency key,
`nurse_payout_booking_links` UNIQUE (`Persistence/Configuration/PayoutsConfig/NursePayoutBookingLinkConfig.cs:22`),
and PAYA/SATNA threshold selection already exist.
- **Change:** per registry row 23: Jibit/Vandar/Sadad payout API, source settlement account config, the
async callback that flips `submitted → paid/failed`, batch caps/minimums. Keep whole-batch/single-row
failure + retry semantics (already tested at `Tests/.../Payouts/PayoutHandlerTests.cs:211`).
- **Effort:** L · **Risk:** high (irreversible transfers — the UNIQUE link + ledger-exists guard are the
backstops, and they're in place) · **Deps:** 5.3 (real ownership check) before real runs; 4.1 for the
weekly trigger.
### 6.4 Remove `IPaymentCaptureSimulator` (the registry's own end-state)
- **Why:** the b9 temporary conversion trigger was supposed to be removed once b10's real capture shipped
(registry row 37, "make it real" step 3). b10 shipped; the seam and registration remain. Harmless as a
test trigger, but it's a second way to mint a booking without a payment row — undesirable once real money
exists.
- **Current state:** registered at `CrossCutting/ServiceConfiguration/ServiceCollectionExtension.cs:52`;
`Seams:PaymentCapture` options `SeamOptions.cs:142`.
- **Change:** move the simulator into the test host (b9's tests are its only legitimate consumer) and drop
the production registration; or gate the registration to Development/Testing.
- **Effort:** S · **Risk:** low (b9 Convert tests must keep a path) · **Deps:** none.
### 6.5 Real Moadian submission + reconciliation (`IMoadianClient`)
- **Why:** e-invoicing to سامانه مودیان is a legal obligation; today every invoice stays
`moadian_status = pending` forever (mock leaves it pending; **no reconciliation job or endpoint exists**
the only `ApplyMoadianResult` caller is issue-time, `Features/Invoices/Commands/IssueInvoice/IssueInvoiceCommand.Handler.cs:72`).
- **Current state:** `CrossCutting/Seams/MockMoadianClient.cs:15-25`; registration `:65`;
`Seams:Moadian:ForceRegistered` default false.
- **Change:** enrollment (memory/economic code + signing cert), real `SubmitAsync`, and the
`pending → submitted → registered/failed` reconciliation job (register it under 4.1). The invoice
number sequence and VAT-on-commission math are already correct and tested.
- **Effort:** ML (enrollment dominates) · **Risk:** medium · **Deps:** 4.1 (scheduler) for the poll.
### 6.6 Decide the partner-center settlement rail (currently: resolver without money)
- **Why:** b15 resolves merchant-of-record per booking and stores each center's encrypted
`settlement_iban` + `commission_rate`, but no money path pays a center or applies its rate — the b15
report itself lists the settlement rail as a follow-up.
- **Current state:** resolver + invoice wiring real
(`Persistence/Repositories/PartnerCenterRepository.cs:118`, `IssueInvoiceCommand.Handler.cs:63`);
`IBankTransferProvider` consumed only by nurse payouts (`Controllers/V1/AdminPayoutsController.cs:44`);
follow-up noted at `dev/shared-working-context/reports/backend-phase-15-report.md:83`.
- **Change:** product decision first (does a merchant-of-record center receive the commission split at
launch, or is it bookkeeping-only?). If money moves: a center-settlement ledger account + payout command
reusing the b13 machinery.
- **Effort:** ML (if built) · **Risk:** medium · **Deps:** product decision; 6.3.
---
## post-phase-7 — Observability, audit & ops hardening
### 7.1 Add tracing and consolidate the two metric stacks
- **Why:** OTel is metrics-only (no `WithTracing`, no OTLP exporter) — cross-service money flows (webhook →
confirm → ledger) can't be traced in production. Two overlapping Prometheus stacks run simultaneously
(OTel's `AddPrometheusExporter` + prometheus-net's `UseMetricServer`/`UseHttpMetrics`).
- **Current state:** `Monitoring/Configurations/OpenTelemetryConfigurations.cs:11-21`;
`PrometheusMetricsConfigurations.cs:11`; W3C activity format set but unexported
(`Program.cs:32`); Serilog already enriches with span ids (`LoggingConfiguration.cs:24`).
- **Change:** add `WithTracing` (AspNetCore + EF instrumentation) exporting OTLP; pick **one** metrics
stack; wire trace-id into the `ApiResult.requestId` for support correlation.
- **Effort:** SM · **Risk:** low · **Deps:** an OTLP-capable collector (optional at MVP; Prometheus alone
is acceptable — see runtime-services.md).
### 7.2 Broaden health checks and split readiness/liveness
- **Why:** the single check is app-DB connectivity; the log DB, object-storage root, and (future)
Redis/PSP get no signal. A deploy can pass health while logging or uploads are broken.
- **Current state:** `Monitoring/Configurations/HealthCheckConfigurations.cs:17` (SQL Server only),
`/HealthCheck` endpoint `:28`, dead `currentUrl` variable `:20`.
- **Change:** add checks for `logDb`, object storage (write probe), Redis when 4.2 lands; tag checks and
expose `/healthz/live` (process) vs `/healthz/ready` (dependencies); remove the dead line.
- **Effort:** S · **Risk:** low · **Deps:** tracks new infra as it arrives.
### 7.3 Revisit production log levels and the notification-channel plan
- **Why:** deployed environments write **only Warning+** to the SQL sink — every Information-level audit
trail (logins, money operations context) is dropped in production while Development keeps it. Also note:
OTP codes are currently logged by design (`LoggingSmsSender`) — that must not survive 5.1.
- **Current state:** `CrossCutting/Logging/LoggingConfiguration.cs:40-53`; Elasticsearch sink referenced
but commented out (`:58-70`; package still pinned at `server/Directory.Packages.props:51`).
- **Change:** Information+ to the sink with table retention (or a file/OTLP sink), structured category
filters; delete the dead Elastic sink block + package (or revive it deliberately); verify no PII is
logged (the SMS mock's OTP log disappears with 5.1).
- **Effort:** S · **Risk:** low · **Deps:** none.
### 7.4 Audit-log growth & archival
- **Why:** `audit_logs` is append-only with no archival or retention (deferred since b1); 2.3 will grow it
faster. The notification purge job is the only retention job in the system.
- **Current state:** deferral recorded at `dev/phases/backend/backend-phase-1.md:124`; no purge/archive job
exists for `ops.AuditLogs` (only `NotificationRetentionHostedService`).
- **Change:** a retention/archival policy (cold table or export) as a 4.1 job; define legal retention for
money/verification audit rows first.
- **Effort:** SM · **Risk:** low · **Deps:** 4.1.
### 7.5 Decide TicketMessage.Body encryption and the gRPC plugin's fate
- **Why (tickets):** ticket messages are the refund/dispute paper trail — users will type phone numbers,
addresses, and clinical details. `TicketMessage.Body` is plaintext with **no documented decision**,
unlike `BookingRequest.CustomerNotes` which carries an explicit "deliberately plaintext" comment
(`Domain/Entities/Booking/BookingRequest.cs:45`).
- **Why (gRPC):** the plugin duplicates the OTP/token flow only, forces the HTTP/2 posture (1.5), and
enables reflection unconditionally — cost without a consumer (the Next.js client is HTTP/JSON only).
- **Current state:** `Domain/Entities/Messaging/TicketMessage.cs:20`;
`Plugins/Baya.Web.Plugins.Grpc/GrpcPluginStartup.cs:14-23`, `Services/UserGrpcServices.cs:18-53`.
- **Change:** (a) either encrypt `Body` via the existing converter pattern (accepting the search/ops cost —
admin thread reads already decrypt per-row elsewhere) or add the explicit "deliberately plaintext"
decision comment + docs; recommend encrypting. (b) remove the gRPC plugin or disable reflection outside
Development and give it a dedicated HTTP/2 endpoint.
- **Effort:** SM · **Risk:** low · **Deps:** 1.5 pairs with (b).
### 7.6 Keep the docs honest (registry + tracker + map)
- **Why:** the mocks-registry contains stale duplicate rows — the early block still says 🔴 "not built" for
`IDistributedLock`/`INurseSearch`/`IPaymentProvider`/`ISettlementSplitProvider`/`IWebhookVerifier`/
`IMoadianClient`/`ILicenseVerificationService` while later rows correct all seven (e.g. rows 15/16 vs
38/42). `IJobScheduler` is listed as a seam but no such interface exists. All 15 REQs read `Status: open`.
Stale instructions are worse than none (root CLAUDE.md rule 7).
- **Current state:** `dev/shared-working-context/reports/mocks-registry.md:15-19,32,36` (stale block) vs
`:38-50` (corrected rows); `dev/shared-working-context/frontend/requests/for-backend.md` (all open).
- **Change:** prune the stale registry block, rename the `IJobScheduler` row to "recurring jobs (hosted
services)", mark delivered/answered REQs, and note `IPaymentCaptureSimulator`'s intended removal (6.4).
- **Effort:** S · **Risk:** none · **Deps:** post-phase-3 outcomes.
---
## post-phase-8 — Scale & later (explicitly not MVP)
- **8.1 Elasticsearch read backend + outbox feeder**`SqlNurseSearch` is real and correct
(`Persistence/Services/Search/SqlNurseSearch.cs:18`); `Search:Backend` fails fast on any non-`sql` value
(`ServiceCollectionExtensions.cs:74-78`). Build `ElasticNurseSearch` + the outbox/CDC feeder per registry
rows 38/43 only when SQL search shows strain. **Effort:** L.
- **8.2 Analytics pipeline**`IAnalyticsSink` writes `ops.SystemEvents` rows fire-and-forget
(`Persistence/Services/Analytics/AnalyticsSink.cs:15-35`); pipe to a warehouse/stream when product needs
it. **Effort:** M.
- **8.3 Holiday-calendar feed** — the table is manually maintained; a lunar-Hijri drift shifts payout dates
(`Persistence/Services/Holidays/HolidayCalendarService.cs:25-45`). A yearly ops checklist item is an
acceptable alternative to a feed. **Effort:** S.
- **8.4 Push/SMS notification channels**`InAppNotificationDispatcher` silently drops non-InApp channels
(`Persistence/Services/Notifications/InAppNotificationDispatcher.cs:17`); add channel fan-out (SMS via
5.1's sender, FCM push) when the mobile/notification UX demands it. **Effort:** M.
- **8.5 Deferred product tables**`organizations`, `organization_nurses`, `fraud_flags`,
`recurring_booking_schedules` (b15), `bnpl_settlement_entries` (b12), nurse availability slots (b5/b8),
customer national-ID KYC (b3), geo bulk import (b4) — all verified absent and all pure additive
migrations when product pulls them (evidence: `dev/phases/backend/backend-phase-15.md:333-336`,
`after-backend-phase-12.md:40`, `backend-phase-3.md:188`, `backend-phase-4.md:194`; model snapshot clean).
---
## Suggested sequencing
```
post-phase-1 (security) ──┬──► post-phase-2 (money correctness) ──► post-phase-6 (money rails)
post-phase-3 (contract batch — parallel, unblocks frontend f9f15)
post-phase-4 (scheduler/Redis) ──► needed by 6.3/6.5 triggers
post-phase-5 (trust rails; 5.1 SMS is launch-critical, schedule early)
post-phase-7 (observability — start anytime, finish before launch)
post-phase-8 (later)
```
The two items that should not wait for their bucket: **1.1 (rotate the committed sa credentials — today)**
and **2.1 (the unreachable refund clearing — before any real BNPL/manual refund exists)**.
+223
View File
@@ -0,0 +1,223 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Runtime services — deployment topology</title>
<style>
:root{
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
--warn:#a04b12; color-scheme: light dark;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
--warn:#e09355;
}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
p{margin:.6rem 0;}
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
padding: .9rem 1rem;overflow-x:auto;}
pre code{background:none;padding:0;}
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
table{border-collapse:collapse;width:100%;font-size:.92rem;}
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
tbody tr:last-child td{border-bottom:none;}
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
li{margin:.35rem 0;}
li>code:first-child{font-weight:600;}
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
nav.toc strong{display:block;margin-bottom:.4rem;}
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
nav.toc>ul{padding-inline-start:0;}
nav.toc li{margin:.2rem 0;}
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
nav.toc .l3 a{color:var(--muted);}
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
.crumbs a{color:var(--muted);}
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
details.src summary{cursor:pointer;}
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
svg .t2{font-size:11px;fill:var(--muted);}
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
svg .box.mock{stroke-dasharray:5 4;}
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
svg .edge{fill:none;stroke-width:1.7;}
svg .edge.real{stroke:var(--accent);}
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
svg .lbl{font-size:10.5px;fill:var(--muted);}
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
</style>
</head>
<body><main>
<div class="crumbs"><a href="index.html">← Post-phase server audit</a></div>
<h1 id="runtime-services-deployment-topology">Runtime services — deployment topology</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p><nav class="toc"><strong>Contents</strong><ul><li class="l2"><a href="#service-inventory">Service inventory</a></li><li class="l2"><a href="#dependency-graph">Dependency graph</a></li><li class="l2"><a href="#per-service-notes">Per-service notes</a></li><li class="l3"><a href="#1-2-sql-server-baya-baya-logs">12 · SQL Server (<code>Baya</code> + <code>Baya_Logs</code>)</a></li><li class="l3"><a href="#3-reverse-proxy-tls">3 · Reverse proxy / TLS</a></li><li class="l3"><a href="#4-prometheus-grafana">4 · Prometheus (+ Grafana)</a></li><li class="l3"><a href="#5-redis">5 · Redis</a></li><li class="l3"><a href="#6-minio-s3-arvancloud">6 · MinIO / S3 / ArvanCloud</a></li><li class="l3"><a href="#7-job-scheduler">7 · Job scheduler</a></li><li class="l3"><a href="#8-sms-gateway">8 · SMS gateway</a></li><li class="l3"><a href="#9-psp-ipg-shaparak-تسهیم">9 · PSP / IPG + Shaparak (تسهیم)</a></li><li class="l3"><a href="#10-bnpl-providers">10 · BNPL providers</a></li><li class="l3"><a href="#11-bank-transfer-rail-paya-satna">11 · Bank-transfer rail (PAYA/SATNA)</a></li><li class="l3"><a href="#12-سامانه-مودیان">12 · سامانه مودیان</a></li><li class="l3"><a href="#13-kyc-bridge-shahkar-e-kyc-استعلام-شبا">13 · KYC bridge (Shahkar / e-KYC / استعلام شبا)</a></li><li class="l3"><a href="#14-geocoding-neshan">14 · Geocoding (Neshan)</a></li><li class="l3"><a href="#15-review-moderation">15 · Review moderation</a></li><li class="l3"><a href="#16-moh-ino-enamad-a-process-not-a-service">16 · MoH / INO / eNamad — a process, not a service</a></li><li class="l3"><a href="#17-elasticsearch-deliberately-later">17 · Elasticsearch — deliberately later</a></li><li class="l2"><a href="#deployment-notes-from-the-code-not-aspiration">Deployment notes (from the code, not aspiration)</a></li></ul></nav>
<p><strong>Audit date:</strong> 2026-07-10 · <strong>Derivation:</strong> every entry below is justified from the code — the DI seam that depends on it, the config key that names it, or the package/startup wiring that talks to it. Nothing is invented; "not needed" claims are backed by the absence of the package/code. Make-it-real steps live in <code>dev/shared-working-context/reports/mocks-registry.md</code> (row references below).</p>
<p><strong>The shape in one sentence:</strong> today the API binary talks to exactly <strong>one external system — SQL Server</strong> (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.</p>
<h2 id="service-inventory">Service inventory</h2>
<div class="tblwrap"><table><thead><tr><th>#</th><th>Service</th><th>Purpose</th><th>Depends via (seam / config)</th><th>MVP?</th><th>Registry row</th></tr></thead><tbody><tr><td>1</td><td><strong>SQL Server</strong> (app DB <code>Baya</code>)</td><td>System of record — 12 schemas (<code>usr ops geo catalog verif search booking payments payouts reviews messaging partner</code>)</td><td>EF Core; <code>ConnectionStrings:SqlServer</code></td><td><strong>Required now</strong></td><td></td></tr><tr><td>2</td><td><strong>SQL Server</strong> (log DB <code>Baya_Logs</code>)</td><td>Serilog sink in deployed envs (Warning+, auto-created <code>log.LogEvents</code>)</td><td><code>ConnectionStrings:logDb</code></td><td><strong>Required now</strong> (deployed)</td><td></td></tr><tr><td>3</td><td><strong>Reverse proxy / TLS</strong> (nginx·caddy·traefik)</td><td>TLS termination, HTTP/1.1+2, forwarded headers</td><td>Kestrel config; JWE bearer</td><td><strong>Required now</strong></td><td></td></tr><tr><td>4</td><td><strong>Prometheus</strong> (+ Grafana)</td><td>Scrapes <code>/metrics</code>; health forwarded to gauges</td><td><code>UseMetricServer</code> + OTel exporter</td><td><strong>Recommended now</strong></td><td></td></tr><tr><td>5</td><td><strong>Redis</strong></td><td><code>ICacheService</code> + <code>IDistributedLock</code> (money-path mutex)</td><td><code>Seams:*</code> (keys TBD; none today)</td><td>Before &gt;1 API instance</td><td>rows 14, 42</td></tr><tr><td>6</td><td><strong>MinIO / S3 / ArvanCloud</strong></td><td><code>IObjectStorage</code> — verification docs, avatars (REQ-006), invoice PDFs</td><td><code>Seams:ObjectStorage:*</code></td><td>Before real verification</td><td>row 13</td></tr><tr><td>7</td><td><strong>Job scheduler</strong> (Hangfire/Quartz, in-app on SQL)</td><td>The deferred crons: payout batch, expiry scan, no-show, Moadian poll</td><td>hosted services (no interface exists)</td><td>Before unattended ops</td><td>row 26</td></tr><tr><td>8</td><td><strong>SMS gateway</strong> (Kavenegar·Ghasedak·SMS.ir)</td><td><code>ISmsSender</code> — OTP delivery (login is impossible without it)</td><td><code>Seams:Sms:*</code> (to be added)</td><td><strong>Launch-critical</strong></td><td>row 12</td></tr><tr><td>9</td><td><strong>PSP / IPG + Shaparak</strong> (ZarinPal·Sadad·Vandar·Jibit)</td><td><code>IPaymentProvider</code> + <code>IWebhookVerifier</code> + <code>ISettlementSplitProvider</code> (تسهیم)</td><td>encrypted <code>payment_gateways.config_json</code></td><td>Real payments</td><td>rows 3941</td></tr><tr><td>10</td><td><strong>BNPL providers</strong> (SnappPay·Digipay)</td><td><code>IBnplProvider</code> / <code>IBnplProviderResolver</code> / <code>ICurrencyNormalizer</code></td><td><code>Seams:Bnpl:*</code>, <code>Seams:Currency:*</code>, gateway config</td><td>Optional at launch</td><td>rows 4647</td></tr><tr><td>11</td><td><strong>Bank-transfer rail</strong> (Jibit·Vandar·Sadad payout API → PAYA/SATNA)</td><td><code>IBankTransferProvider</code> — weekly nurse payouts</td><td><code>Seams:BankTransfer:*</code></td><td>Real payouts</td><td>row 23</td></tr><tr><td>12</td><td><strong>سامانه مودیان</strong> (tax e-invoicing)</td><td><code>IMoadianClient</code> — legal invoice registration</td><td><code>Seams:Moadian:*</code> + signing cert</td><td>Legal — soon after launch</td><td>row 45</td></tr><tr><td>13</td><td><strong>KYC bridge vendor</strong> (Finnotech-class)</td><td><code>IShahkarVerifier</code> + <code>IIdentityKycProvider</code> + <code>IBankAccountOwnershipVerifier</code></td><td><code>Seams:Shahkar:*</code>, <code>Seams:IdentityKyc:*</code>, <code>Seams:BankOwnership:*</code></td><td>Real verification + payout gate</td><td>rows 27, 28, 30</td></tr><tr><td>14</td><td><strong>Geocoding</strong> (Neshan)</td><td><code>IGeocoder</code> — address → coordinates (EVV distance)</td><td><code>Seams:Geocoding:*</code></td><td>With real EVV</td><td>row 31</td></tr><tr><td>15</td><td><strong>Review-moderation classifier</strong> (LLM/API)</td><td><code>IReviewModerationService</code> — AI pre-screen</td><td><code>Seams:ReviewModeration:*</code></td><td>Optional (human queue is default)</td><td>row 33</td></tr><tr><td>16</td><td><strong>MoH / INO / eNamad</strong></td><td><code>ICredentialVerifier</code> + <code>ILicenseVerificationService</code><strong>manual admin process; no public B2B API exists</strong></td><td><code>Seams:LicenseVerification:*</code></td><td>Manual = the MVP design</td><td>rows 29, 50</td></tr><tr><td>17</td><td><strong>Elasticsearch</strong></td><td><code>INurseSearch</code> alt backend + outbox feeder</td><td><code>Search:Backend</code> (non-<code>sql</code> throws today)</td><td><strong>Not MVP</strong> — SQL search is real</td><td>rows 38, 43</td></tr></tbody></table></div>
<p><strong>Explicitly not needed</strong> (verified absent from <code>server/Directory.Packages.props</code> and code): message broker (no RabbitMQ/Kafka), Redis today (no <code>StackExchange.Redis</code>), Hangfire/Quartz today, Elasticsearch client (<code>Elastic.Clients.*</code>), any cloud SDK, any payment/SMS vendor SDK. The only externally-pointing package beyond SQL Server is <code>Serilog.Sinks.Elasticsearch</code> — its wiring is commented out (<code>Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:58-70</code>).</p>
<hr>
<h2 id="dependency-graph">Dependency graph</h2>
<p>Solid edges are live today; dashed edges are behind a mocked seam (the arrow exists in code, the wire does not). One line per node below the graph.</p>
<figure class="diagram"><svg viewBox="0 0 1160 830" role="img" aria-label="Balinyaar runtime dependency graph">
<defs>
<marker id="arr-real" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="arr-real" d="M0,0 L10,5 L0,10 z"/></marker>
<marker id="arr-mock" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="arr-mock" d="M0,0 L10,5 L0,10 z"/></marker>
</defs>
<rect class="box live" x="20" y="150" width="210" height="62" rx="8"/><text x="125" y="178" text-anchor="middle" font-weight="600">Next.js client</text><text class="t2" x="125" y="195" text-anchor="middle">HTTPS · JSON (camelCase)</text>
<rect class="box live" x="20" y="430" width="210" height="62" rx="8"/><text x="125" y="458" text-anchor="middle" font-weight="600">Prometheus (+ Grafana)</text><text class="t2" x="125" y="475" text-anchor="middle">scrapes /metrics + health</text>
<rect class="grp" x="290" y="90" width="270" height="330" rx="12"/>
<text class="grp-title" x="425" y="114" text-anchor="middle">API HOST · Baya.Web.Api (:5002)</text>
<rect class="box live" x="310" y="132" width="230" height="78" rx="8"/><text x="425" y="150" text-anchor="middle" font-weight="600">ASP.NET Core API</text><text class="t2" x="425" y="166" text-anchor="middle">REST /api/v1 · Swagger</text><text class="t2" x="425" y="180" text-anchor="middle">/metrics · /HealthCheck · gRPC</text>
<rect class="box live" x="310" y="250" width="230" height="62" rx="8"/><text x="425" y="278" text-anchor="middle" font-weight="600">In-proc interval jobs</text><text class="t2" x="425" y="295" text-anchor="middle">expiry 1min · retention 24h</text>
<path class="edge real" d="M 425 250 L 425 210"/>
<text class="t2" x="425" y="345" text-anchor="middle">18 vendor seams resolve</text>
<text class="t2" x="425" y="361" text-anchor="middle">in-process (mocks) today —</text>
<text class="t2" x="425" y="377" text-anchor="middle">dashed edges = the wire to build</text>
<rect class="grp" x="640" y="20" width="500" height="216" rx="12"/>
<text class="grp-title" x="660" y="44">DATA &amp; PLATFORM INFRA</text>
<rect class="box live" x="660" y="58" width="225" height="62" rx="8"/><text x="772.5" y="86" text-anchor="middle" font-weight="600">SQL Server «Baya»</text><text class="t2" x="772.5" y="103" text-anchor="middle">12 schemas · system of record</text>
<rect class="box live" x="900" y="58" width="220" height="62" rx="8"/><text x="1010" y="86" text-anchor="middle" font-weight="600">SQL Server «Baya_Logs»</text><text class="t2" x="1010" y="103" text-anchor="middle">Serilog sink (deployed envs)</text>
<rect class="box mock" x="660" y="146" width="225" height="62" rx="8"/><text x="772.5" y="174" text-anchor="middle" font-weight="600">Redis</text><text class="t2" x="772.5" y="191" text-anchor="middle">cache + dist. lock — in-proc now</text>
<rect class="box mock" x="900" y="146" width="220" height="62" rx="8"/><text x="1010" y="174" text-anchor="middle" font-weight="600">MinIO / S3</text><text class="t2" x="1010" y="191" text-anchor="middle">object storage — local disk now</text>
<rect class="grp" x="640" y="260" width="500" height="216" rx="12"/>
<text class="grp-title" x="660" y="284">MONEY RAILS (ALL MOCKED TODAY)</text>
<rect class="box mock" x="660" y="298" width="225" height="62" rx="8"/><text x="772.5" y="326" text-anchor="middle" font-weight="600">PSP / IPG + Shaparak</text><text class="t2" x="772.5" y="343" text-anchor="middle">capture · webhook · تسهیم</text>
<rect class="box mock" x="900" y="298" width="220" height="62" rx="8"/><text x="1010" y="326" text-anchor="middle" font-weight="600">BNPL</text><text class="t2" x="1010" y="343" text-anchor="middle">SnappPay / Digipay</text>
<rect class="box mock" x="660" y="386" width="225" height="62" rx="8"/><text x="772.5" y="414" text-anchor="middle" font-weight="600">PAYA / SATNA rail</text><text class="t2" x="772.5" y="431" text-anchor="middle">weekly nurse payouts</text>
<rect class="box mock" x="900" y="386" width="220" height="62" rx="8"/><text x="1010" y="414" text-anchor="middle" font-weight="600">سامانه مودیان</text><text class="t2" x="1010" y="431" text-anchor="middle">tax e-invoicing</text>
<rect class="grp" x="640" y="500" width="500" height="300" rx="12"/>
<text class="grp-title" x="660" y="524">TRUST &amp; IDENTITY RAILS (ALL MOCKED TODAY)</text>
<rect class="box mock" x="660" y="538" width="225" height="62" rx="8"/><text x="772.5" y="566" text-anchor="middle" font-weight="600">SMS gateway (OTP)</text><text class="t2" x="772.5" y="583" text-anchor="middle">launch-critical — logs only now</text>
<rect class="box mock" x="900" y="538" width="220" height="62" rx="8"/><text x="1010" y="566" text-anchor="middle" font-weight="600">KYC bridge</text><text class="t2" x="1010" y="583" text-anchor="middle">Shahkar · e-KYC · استعلام شبا</text>
<rect class="box mock" x="660" y="626" width="225" height="62" rx="8"/><text x="772.5" y="654" text-anchor="middle" font-weight="600">Neshan geocoding</text><text class="t2" x="772.5" y="671" text-anchor="middle">EVV distance accuracy</text>
<rect class="box mock" x="900" y="626" width="220" height="62" rx="8"/><text x="1010" y="654" text-anchor="middle" font-weight="600">Review moderation</text><text class="t2" x="1010" y="671" text-anchor="middle">optional — human queue default</text>
<rect class="box mock" x="660" y="714" width="460" height="62" rx="8"/><text x="890" y="742" text-anchor="middle" font-weight="600">MoH / INO / eNamad</text><text class="t2" x="890" y="759" text-anchor="middle">manual admin review — no public API exists</text>
<path class="edge real" d="M 230 181 C 270 181, 270 171, 310 171" marker-end="url(#arr-real)"/><text class="lbl" x="270" y="170" text-anchor="middle">HTTPS/JSON</text>
<path class="edge real" d="M 230 455 C 260 455, 260 400, 290 400" marker-end="url(#arr-real)"/><text class="lbl" x="260" y="419.5" text-anchor="middle">scrape</text>
<path class="edge real" d="M 560 130 C 600 130, 600 100, 640 100" marker-end="url(#arr-real)"/><text class="lbl" x="600" y="99" text-anchor="middle">EF Core · Serilog</text>
<path class="edge mock" d="M 560 170 C 600 170, 600 185, 640 185" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="193.5" text-anchor="middle">cache · lock · files</text>
<path class="edge mock" d="M 560 245 C 600 245, 600 320, 640 320" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="268.5" text-anchor="middle">capture · payouts · invoices</text>
<path class="edge mock" d="M 640 395 C 600 395, 600 290, 560 290" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="356.5" text-anchor="middle">webhooks / callbacks</text>
<path class="edge mock" d="M 560 335 C 600 335, 600 610, 640 610" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="466.5" text-anchor="middle">OTP · استعلام · geocode · moderation</text>
<rect class="grp" x="20" y="600" width="210" height="130" rx="12"/>
<text class="grp-title" x="125" y="624" text-anchor="middle">LEGEND</text>
<path class="edge real" d="M 40 646 L 90 646"/><text class="t2" x="100" y="650">live today</text>
<path class="edge mock" d="M 40 674 L 90 674"/><text class="t2" x="100" y="678">mocked seam — to build</text>
<rect class="box mock" x="40" y="694" width="50" height="20" rx="6"/><text class="t2" x="100" y="708">service to provision</text>
</svg></figure>
<details class="src"><summary>Mermaid source (canonical, in the .md)</summary><pre><code>flowchart LR
subgraph fe[Frontend]
WEB["Next.js client"]
end
subgraph host["API host — Baya.Web.Api (:5002)"]
API["ASP.NET Core API\nREST /api/v1 · /metrics · /HealthCheck"]
JOBS["In-proc interval jobs\n(→ Hangfire/Quartz later)"]
end
subgraph data[Data &amp; platform infra]
SQL[("SQL Server 'Baya'\n12 schemas · migrations on boot")]
LOG[("SQL Server 'Baya_Logs'\nSerilog sink, Warning+")]
REDIS[("Redis — cache + dist. lock\n(in-proc today)")]
S3[("MinIO / S3 — object storage\n(local disk today)")]
end
subgraph money["Money rails (all mocked today)"]
PSP["PSP / IPG + Shaparak\ncapture · webhook · تسهیم"]
BNPL["BNPL — SnappPay / Digipay"]
BANK["PAYA / SATNA payout rail"]
MOAD["سامانه مودیان e-invoicing"]
end
subgraph trust["Trust &amp; identity rails (all mocked today)"]
SMS["SMS gateway (OTP)"]
KYC["Shahkar · e-KYC · استعلام شبا"]
GEOC["Neshan geocoding"]
MODAI["Review-moderation classifier"]
MANUAL["MoH / INO / eNamad\n(manual admin review)"]
end
subgraph obs[Observability]
PROM["Prometheus (+ Grafana)"]
end
WEB --&gt;|HTTPS/JSON| API
API --&gt; SQL
API --&gt; LOG
JOBS --- API
API -.-&gt; REDIS
API -.-&gt; S3
API -.-&gt; SMS
API &lt;-.-&gt; PSP
API &lt;-.-&gt; BNPL
API -.-&gt; BANK
API -.-&gt; MOAD
API -.-&gt; KYC
API -.-&gt; GEOC
API -.-&gt; MODAI
MANUAL -.- API
PROM --&gt;|scrape /metrics| API</code></pre></details>
<ul><li><strong>Next.js client</strong> — the only API consumer; reads <code>NEXT_PUBLIC_API_URL</code> (root <code>CLAUDE.md</code>).</li><li><strong>API</strong> — single ASP.NET Core host; all seams resolve in-process today.</li><li><strong>In-proc jobs</strong> — the two <code>BackgroundService</code> sweeps (booking-request expiry 1 min, notification retention 24 h); the scheduler upgrade re-homes them (plan §4.1).</li><li><strong>SQL Server <code>Baya</code></strong> — system of record; migrations + seeding run on every non-Testing boot.</li><li><strong>SQL Server <code>Baya_Logs</code></strong> — deployed-env Serilog sink (auto-creates DB/table).</li><li><strong>Redis</strong> — target for <code>ICacheService</code>/<code>IDistributedLock</code>; nothing speaks Redis yet.</li><li><strong>MinIO/S3</strong> — target for <code>IObjectStorage</code>; local disk + <code>file://</code> URLs today.</li><li><strong>PSP/IPG</strong> — card capture, callback signatures, تسهیم settlement split.</li><li><strong>BNPL</strong> — provider-financed installments; settle/revert callbacks.</li><li><strong>PAYA/SATNA</strong> — weekly nurse payout batches + async reconciliation.</li><li><strong>مودیان</strong> — legal e-invoice registration (pending→registered poll).</li><li><strong>KYC vendor</strong> — Shahkar phone↔NID, identity+liveness, Sheba ownership (payout gate).</li><li><strong>Neshan</strong> — geocoding for address coordinates / EVV distance.</li><li><strong>Moderation classifier</strong> — optional AI pre-screen; human moderation is the default gate.</li><li><strong>MoH/INO/eNamad</strong> — human verification workflows, by design (no API exists).</li><li><strong>Prometheus</strong> — scrapes <code>/metrics</code>; health check results forwarded as gauges.</li></ul>
<hr>
<h2 id="per-service-notes">Per-service notes</h2>
<h3 id="1-2-sql-server-baya-baya-logs">12 · SQL Server (<code>Baya</code> + <code>Baya_Logs</code>)</h3>
<ul><li><strong>Evidence:</strong> <code>UseSqlServer</code> at <code>server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41</code>; sink at <code>…CrossCutting/Logging/LoggingConfiguration.cs:44-45</code> (schema <code>log</code>, auto-create); the 12 schemas via per-entity <code>ToTable(name, schema)</code> (e.g. <code>PaymentsConfig/LedgerEntryConfig.cs:18</code>, <code>PayoutsConfig/NursePayoutConfig.cs:19</code>).</li><li><strong>Default:</strong> <code>mcr.microsoft.com/mssql/server:2022-latest</code> (Developer for dev; licensed edition in prod). Both DBs fit one instance; <code>Baya_Logs</code> can move later.</li><li><strong>Config:</strong> <code>ConnectionStrings:SqlServer</code>, <code>ConnectionStrings:logDb</code><strong>rotate + externalize first</strong> (plan §1.1; live <code>sa</code> credentials are committed today).</li><li><strong>Health/readiness:</strong> the app's only health check (<code>/HealthCheck</code>, <code>Monitoring/Configurations/HealthCheckConfigurations.cs:17</code>); <code>logDb</code> has none (plan §7.2). Boot runs <code>MigrateAsync</code> + 3 seeders (<code>Program.cs:99-104</code>) → the login needs DDL rights and concurrent multi-node boot races (plan §4.3).</li></ul>
<h3 id="3-reverse-proxy-tls">3 · Reverse proxy / TLS</h3>
<ul><li><strong>Evidence of need:</strong> JWE bearer auth (<code>RequireHttpsMetadata</code> must be true in prod — <code>Identity/ServiceConfiguration/ServiceCollectionExtension.cs:139</code>); Kestrel <code>EndpointDefaults=Http2</code> breaks non-TLS HTTP/1.1 (<code>appsettings.json:29-33</code>, plan §1.5); the rate limiter partitions on <code>RemoteIpAddress</code> with <strong>no ForwardedHeaders middleware</strong> (<code>WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69</code>, plan §1.6) — the proxy must pass <code>X-Forwarded-For</code> <em>and</em> the app must be taught to honor it.</li><li><strong>Default:</strong> caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5 lands.</li></ul>
<h3 id="4-prometheus-grafana">4 · Prometheus (+ Grafana)</h3>
<ul><li><strong>Evidence:</strong> <code>/metrics</code> via prometheus-net <code>UseMetricServer</code> + OTel <code>AddPrometheusExporter</code> (two stacks — consolidate, plan §7.1) at <code>Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11</code> and <code>OpenTelemetryConfigurations.cs:21</code>; health forwarded (<code>HealthCheckConfigurations.cs:18</code>).</li><li><strong>Default:</strong> <code>prom/prometheus:v2.53</code> + <code>grafana/grafana:11</code>. No tracing backend exists yet (metrics-only); an OTLP collector becomes relevant with plan §7.1.</li></ul>
<h3 id="5-redis">5 · Redis</h3>
<ul><li><strong>Evidence of the gap:</strong> <code>MemoryCacheService</code> and <code>InProcessDistributedLock</code> (<code>CrossCutting/Seams/MemoryCacheService.cs:11</code>, <code>InProcessDistributedLock.cs:14</code>) — single-process only; the money-path lock convention <code>booking:{id}:payment|refund</code> is already in the handlers.</li><li><strong>Default:</strong> <code>redis:7-alpine</code> (AOF on). <strong>Required the moment a second API instance runs</strong> (shared cache invalidation generation-tokens + cross-instance money mutex). Config keys to be introduced with the swap (plan §4.2); none exist today.</li></ul>
<h3 id="6-minio-s3-arvancloud">6 · MinIO / S3 / ArvanCloud</h3>
<ul><li><strong>Evidence:</strong> <code>LocalDiskObjectStorage</code> writes under a temp root and returns <code>file://</code> URLs (<code>CrossCutting/Seams/LocalDiskObjectStorage.cs:20,56</code>); consumers: b6 verification documents (signed-URL upload flow), future avatars (REQ-006), invoice <code>PdfStorageKey</code> (<code>InvoicesConfig/InvoiceConfig.cs</code><code>pdf_storage_key</code> column).</li><li><strong>Default:</strong> <code>minio/minio:latest</code> (S3-compatible; ArvanCloud object storage is the Iran-hosted option).</li><li><strong>Config:</strong> <code>Seams:ObjectStorage:RootPath</code> today → bucket/endpoint/keys with the swap. Presigned PUT/GET with expiry is the contract the frontend already codes against.</li></ul>
<h3 id="7-job-scheduler">7 · Job scheduler</h3>
<ul><li><strong>Evidence:</strong> two <code>PeriodicTimer</code> hosted services only (<code>Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67</code>); the payout/expiry/no-show/ Moadian crons are admin-manual with seeded-but-unread cadence keys (plan §4.1). <strong>No <code>IJobScheduler</code> interface exists</strong> — the registry name is aspirational.</li><li><strong>Default:</strong> Hangfire on the existing SQL Server (no new container) — dashboard behind admin auth; or Quartz with SQL persistence. Not a separate service to "spin up", but it changes the SQL footprint (schema) and ops (dashboard, retries).</li></ul>
<h3 id="8-sms-gateway">8 · SMS gateway</h3>
<ul><li><strong>Evidence:</strong> <code>LoggingSmsSender</code> logs OTPs instead of sending (<code>CrossCutting/Seams/LoggingSmsSender.cs:16</code>) — <strong>no real user can log in</strong>; per-phone resend window + <code>otp</code> rate policy already enforced upstream.</li><li><strong>Default:</strong> Kavenegar / Ghasedak / SMS.ir (SaaS — API key, no container). Keys to add: <code>Seams:Sms:{ApiKey,SenderLine,BaseUrl}</code> (registry row 12). Template/pattern OTP send for deliverability.</li></ul>
<h3 id="9-psp-ipg-shaparak-تسهیم">9 · PSP / IPG + Shaparak (تسهیم)</h3>
<ul><li><strong>Evidence:</strong> <code>MockPaymentProvider</code> (instant success, <code>VerifyAsync</code> echoes the expected amount — <code>CrossCutting/Seams/MockPaymentProvider.cs:24</code>), <code>MockWebhookVerifier</code> (marker-based "signature" — <code>MockWebhookVerifier.cs:21</code>), <code>MockSettlementSplitProvider</code> (<code>MockSettlementSplitProvider.cs:13</code>). Real merchant credentials belong in the <strong>encrypted</strong> <code>payment_gateways.config_json</code> (seeded sandbox row: <code>Persistence/ServiceCollectionExtensions.cs:109-117</code> — environment-gate it, plan §1.4).</li><li><strong>Default:</strong> SaaS (ZarinPal / Sadad / Vandar / Jibit) — needs merchant + terminal registration and تسهیم (settlement-split) setup to registered IBANs; webhook endpoint is already public (<code>POST webhooks/payments/{provider}</code>) with upsert-first idempotency in place.</li></ul>
<h3 id="10-bnpl-providers">10 · BNPL providers</h3>
<ul><li><strong>Evidence:</strong> <code>MockBnplProvider</code> drives the full verb set + state machine (<code>CrossCutting/Seams/MockBnplProvider.cs:17-53</code>); callback endpoint <code>WebhooksBnplController</code> (signed, rate-limited). <code>Seams:Bnpl:*</code> + <code>Seams:Currency:TomanToIrrMultiplier</code> (<code>SeamOptions.cs:98-120,79</code>).</li><li><strong>Default:</strong> SnappPay and/or Digipay (SaaS, OAuth). <strong>Do plan §2.1 first</strong> — the revert-clearing path is currently unreachable, so real BNPL refunds would strand ledger state.</li></ul>
<h3 id="11-bank-transfer-rail-paya-satna">11 · Bank-transfer rail (PAYA/SATNA)</h3>
<ul><li><strong>Evidence:</strong> <code>MockBankTransferProvider</code> settles every instruction instantly (<code>CrossCutting/Seams/MockBankTransferProvider.cs:18-31</code>); the handler already chooses PAYA vs SATNA by <code>payout_satna_threshold_irr</code> (<code>platform_configs</code> seed row 22) and the irreversibility backstops (unconditional <code>UNIQUE(booking_id)</code> link, forward-only payout machine) are in place.</li><li><strong>Default:</strong> Jibit / Vandar / Sadad payout API (SaaS). Needs the source settlement account + the async <code>submitted → paid/failed</code> reconciliation callback the mock collapses (registry row 23, steps 34).</li></ul>
<h3 id="12-سامانه-مودیان">12 · سامانه مودیان</h3>
<ul><li><strong>Evidence:</strong> <code>MockMoadianClient</code> leaves invoices <code>pending</code> forever (<code>CrossCutting/Seams/MockMoadianClient.cs:21</code>); no reconciliation job exists (plan §6.5). VAT-on-commission + sequential invoice numbers are already correct server-side.</li><li><strong>Default:</strong> government SaaS — enrollment (memory/economic code) + a signing certificate; the certificate is a deploy-time secret.</li></ul>
<h3 id="13-kyc-bridge-shahkar-e-kyc-استعلام-شبا">13 · KYC bridge (Shahkar / e-KYC / استعلام شبا)</h3>
<ul><li><strong>Evidence:</strong> three deterministic mocks with magic-value failure cases (<code>MockShahkarVerifier.cs:26-37</code>, <code>MockIdentityKycProvider.cs:25</code>, <code>MockBankAccountOwnershipVerifier.cs:26</code>); handlers already persist <code>external_response_json</code> and treat shared-SIM as a handled state. The Sheba-ownership result gates first payouts (<code>matched_national_id</code>).</li><li><strong>Default:</strong> one Finnotech-class bridge covers all three استعلام‌ها (SaaS; API keys under <code>Seams:{Shahkar,IdentityKyc,BankOwnership}:*</code>).</li></ul>
<h3 id="14-geocoding-neshan">14 · Geocoding (Neshan)</h3>
<ul><li><strong>Evidence:</strong> <code>MockGeocoder</code> jitters ±5 km around 8 hardcoded centroids (<code>MockGeocoder.cs:52</code>) — EVV distance checks are noise until this (and/or REQ-008's user pin) is real. <code>Seams:Geocoding:*</code> is one of only three seam sections present in <code>appsettings.json</code> (<code>:22-26</code>).</li><li><strong>Default:</strong> Neshan (Iran coverage; SaaS API key), rate-limit/retry per registry row 31.</li></ul>
<h3 id="15-review-moderation">15 · Review moderation</h3>
<ul><li><strong>Evidence:</strong> keyword-list mock; clean text stays in the human queue by default (<code>MockReviewModerationService.cs:23</code>; <code>Seams:ReviewModeration:*</code>). Human <code>ModerateReviewCommand</code> retains decision authority — so this can stay mocked indefinitely at low volume.</li><li><strong>Default:</strong> any text-moderation API / LLM endpoint when review volume outgrows the human queue.</li></ul>
<h3 id="16-moh-ino-enamad-a-process-not-a-service">16 · MoH / INO / eNamad — a process, not a service</h3>
<ul><li><strong>Evidence:</strong> <code>MockCredentialVerifier</code> always returns <code>RequiresManualReview</code> (<code>MockCredentialVerifier.cs:18</code>); <code>MockLicenseVerificationService</code> likewise (<code>MockLicenseVerificationService.cs:26</code>). The registry itself records <strong>no public B2B API exists</strong> — the admin review queue <em>is</em> the real implementation. Provision: admin staffing + the f15 console.</li></ul>
<h3 id="17-elasticsearch-deliberately-later">17 · Elasticsearch — deliberately later</h3>
<ul><li><strong>Evidence:</strong> <code>SqlNurseSearch</code> is the real MVP backend; <code>Search:Backend</code><code>sql</code> throws at startup (<code>Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:74-78</code>); no ES client package.</li><li><strong>Default when needed:</strong> <code>elasticsearch:8.x</code> + the outbox feeder (registry rows 38/43; plan §8.1).</li></ul>
<hr>
<h2 id="deployment-notes-from-the-code-not-aspiration">Deployment notes (from the code, not aspiration)</h2>
<ol><li><strong>Boot = migrate + seed.</strong> Every non-Testing start applies EF migrations and seeds roles, the <code>admin</code>/<code>qw123321</code> user, and an <strong>active sandbox ZarinPal gateway</strong> (<code>Program.cs:99-104</code>). Until plan §1.3/§1.4/§4.3 land: single-instance start-up, DDL-privileged login, and clean up the seeded credentials per environment.</li><li><strong>Environment files:</strong> <code>appsettings.json</code><code>appsettings.Development.json</code> (byte-identical); <strong>no Production/Staging file exists.</strong> All non-secret env differences ride on ~14 <code>Seams:*</code> groups whose defaults live in code (<code>SeamOptions.cs</code>), not in config files.</li><li><strong>HTTP posture:</strong> HTTP/2-only Kestrel default (plan §1.5), gRPC plugin + reflection always on (plan §7.5), TLS required for JWE sanity.</li><li><strong>Single-instance constraints today:</strong> in-memory cache, in-proc money lock, in-proc sweeps, per-instance rate-limit buckets. Scaling past one instance requires plan §4.2 (Redis) + §4.3 (migrations) first — the DB uniques keep money <em>correct</em> either way, but locks/cache/limits silently degrade.</li><li><strong>Logs:</strong> deployed envs write Warning+ to <code>Baya_Logs</code> only (Information dropped — plan §7.3); dev writes console + <code>logs/log.json</code>.</li><li><strong>Client:</strong> the Next.js app needs <code>NEXT_PUBLIC_API_URL</code> pointing at the proxy; wire casing camelCase; snake_case routes.</li></ol>
</main></body></html>
+280
View File
@@ -0,0 +1,280 @@
# Runtime services — deployment topology
**Audit date:** 2026-07-10 · **Derivation:** every entry below is justified from the code — the DI seam
that depends on it, the config key that names it, or the package/startup wiring that talks to it. Nothing
is invented; "not needed" claims are backed by the absence of the package/code. Make-it-real steps live in
`dev/shared-working-context/reports/mocks-registry.md` (row references below).
**The shape in one sentence:** today the API binary talks to exactly **one external system — SQL Server**
(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.
## Service inventory
| # | Service | Purpose | Depends via (seam / config) | MVP? | Registry row |
| --- | --- | --- | --- | --- | --- |
| 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) | — |
| 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** | — |
| 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 |
| 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 |
| 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 |
| 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 4647 |
| 11 | **Bank-transfer rail** (Jibit·Vandar·Sadad payout API → PAYA/SATNA) | `IBankTransferProvider` — weekly nurse payouts | `Seams:BankTransfer:*` | Real payouts | row 23 |
| 12 | **سامانه مودیان** (tax e-invoicing) | `IMoadianClient` — legal invoice registration | `Seams:Moadian:*` + signing cert | Legal — soon after launch | row 45 |
| 13 | **KYC bridge vendor** (Finnotech-class) | `IShahkarVerifier` + `IIdentityKycProvider` + `IBankAccountOwnershipVerifier` | `Seams:Shahkar:*`, `Seams:IdentityKyc:*`, `Seams:BankOwnership:*` | Real verification + payout gate | rows 27, 28, 30 |
| 14 | **Geocoding** (Neshan) | `IGeocoder` — address → coordinates (EVV distance) | `Seams:Geocoding:*` | With real EVV | row 31 |
| 15 | **Review-moderation classifier** (LLM/API) | `IReviewModerationService` — AI pre-screen | `Seams:ReviewModeration:*` | Optional (human queue is default) | row 33 |
| 16 | **MoH / INO / eNamad** | `ICredentialVerifier` + `ILicenseVerificationService`**manual admin process; no public B2B API exists** | `Seams:LicenseVerification:*` | Manual = the MVP design | rows 29, 50 |
| 17 | **Elasticsearch** | `INurseSearch` alt backend + outbox feeder | `Search:Backend` (non-`sql` throws today) | **Not MVP** — SQL search is real | rows 38, 43 |
**Explicitly not needed** (verified absent from `server/Directory.Packages.props` and code): message
broker (no RabbitMQ/Kafka), Redis today (no `StackExchange.Redis`), Hangfire/Quartz today, Elasticsearch
client (`Elastic.Clients.*`), any cloud SDK, any payment/SMS vendor SDK. The only externally-pointing
package beyond SQL Server is `Serilog.Sinks.Elasticsearch` — its wiring is commented out
(`Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:58-70`).
---
## Dependency graph
Solid edges are live today; dashed edges are behind a mocked seam (the arrow exists in code, the wire does
not). One line per node below the graph.
```mermaid
flowchart LR
subgraph fe[Frontend]
WEB["Next.js client"]
end
subgraph host["API host — Baya.Web.Api (:5002)"]
API["ASP.NET Core API\nREST /api/v1 · /metrics · /HealthCheck"]
JOBS["In-proc interval jobs\n(→ Hangfire/Quartz later)"]
end
subgraph data[Data & platform infra]
SQL[("SQL Server 'Baya'\n12 schemas · migrations on boot")]
LOG[("SQL Server 'Baya_Logs'\nSerilog sink, Warning+")]
REDIS[("Redis — cache + dist. lock\n(in-proc today)")]
S3[("MinIO / S3 — object storage\n(local disk today)")]
end
subgraph money["Money rails (all mocked today)"]
PSP["PSP / IPG + Shaparak\ncapture · webhook · تسهیم"]
BNPL["BNPL — SnappPay / Digipay"]
BANK["PAYA / SATNA payout rail"]
MOAD["سامانه مودیان e-invoicing"]
end
subgraph trust["Trust & identity rails (all mocked today)"]
SMS["SMS gateway (OTP)"]
KYC["Shahkar · e-KYC · استعلام شبا"]
GEOC["Neshan geocoding"]
MODAI["Review-moderation classifier"]
MANUAL["MoH / INO / eNamad\n(manual admin review)"]
end
subgraph obs[Observability]
PROM["Prometheus (+ Grafana)"]
end
WEB -->|HTTPS/JSON| API
API --> SQL
API --> LOG
JOBS --- API
API -.-> REDIS
API -.-> S3
API -.-> SMS
API <-.-> PSP
API <-.-> BNPL
API -.-> BANK
API -.-> MOAD
API -.-> KYC
API -.-> GEOC
API -.-> MODAI
MANUAL -.- API
PROM -->|scrape /metrics| API
```
- **Next.js client** — the only API consumer; reads `NEXT_PUBLIC_API_URL` (root `CLAUDE.md`).
- **API** — single ASP.NET Core host; all seams resolve in-process today.
- **In-proc jobs** — the two `BackgroundService` sweeps (booking-request expiry 1 min, notification
retention 24 h); the scheduler upgrade re-homes them (plan §4.1).
- **SQL Server `Baya`** — system of record; migrations + seeding run on every non-Testing boot.
- **SQL Server `Baya_Logs`** — deployed-env Serilog sink (auto-creates DB/table).
- **Redis** — target for `ICacheService`/`IDistributedLock`; nothing speaks Redis yet.
- **MinIO/S3** — target for `IObjectStorage`; local disk + `file://` URLs today.
- **PSP/IPG** — card capture, callback signatures, تسهیم settlement split.
- **BNPL** — provider-financed installments; settle/revert callbacks.
- **PAYA/SATNA** — weekly nurse payout batches + async reconciliation.
- **مودیان** — legal e-invoice registration (pending→registered poll).
- **KYC vendor** — Shahkar phone↔NID, identity+liveness, Sheba ownership (payout gate).
- **Neshan** — geocoding for address coordinates / EVV distance.
- **Moderation classifier** — optional AI pre-screen; human moderation is the default gate.
- **MoH/INO/eNamad** — human verification workflows, by design (no API exists).
- **Prometheus** — scrapes `/metrics`; health check results forwarded as gauges.
---
## Per-service notes
### 12 · SQL Server (`Baya` + `Baya_Logs`)
- **Evidence:** `UseSqlServer` at
`server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41`;
sink at `…CrossCutting/Logging/LoggingConfiguration.cs:44-45` (schema `log`, auto-create); the 12 schemas
via per-entity `ToTable(name, schema)` (e.g. `PaymentsConfig/LedgerEntryConfig.cs:18`,
`PayoutsConfig/NursePayoutConfig.cs:19`).
- **Default:** `mcr.microsoft.com/mssql/server:2022-latest` (Developer for dev; licensed edition in prod).
Both DBs fit one instance; `Baya_Logs` can move later.
- **Config:** `ConnectionStrings:SqlServer`, `ConnectionStrings:logDb` — **rotate + externalize first**
(plan §1.1; live `sa` credentials are committed today).
- **Health/readiness:** the app's only health check (`/HealthCheck`,
`Monitoring/Configurations/HealthCheckConfigurations.cs:17`); `logDb` has none (plan §7.2). Boot runs
`MigrateAsync` + 3 seeders (`Program.cs:99-104`) → the login needs DDL rights and concurrent multi-node
boot races (plan §4.3).
### 3 · Reverse proxy / TLS
- **Evidence of need:** JWE bearer auth (`RequireHttpsMetadata` must be true in prod —
`Identity/ServiceConfiguration/ServiceCollectionExtension.cs:139`); Kestrel `EndpointDefaults=Http2`
breaks non-TLS HTTP/1.1 (`appsettings.json:29-33`, plan §1.5); the rate limiter partitions on
`RemoteIpAddress` with **no ForwardedHeaders middleware**
(`WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69`, plan §1.6) — the proxy must pass
`X-Forwarded-For` *and* the app must be taught to honor it.
- **Default:** caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5
lands.
### 4 · Prometheus (+ Grafana)
- **Evidence:** `/metrics` via prometheus-net `UseMetricServer` + OTel `AddPrometheusExporter` (two stacks —
consolidate, plan §7.1) at `Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11` and
`OpenTelemetryConfigurations.cs:21`; health forwarded (`HealthCheckConfigurations.cs:18`).
- **Default:** `prom/prometheus:v2.53` + `grafana/grafana:11`. No tracing backend exists yet (metrics-only);
an OTLP collector becomes relevant with plan §7.1.
### 5 · Redis
- **Evidence of the gap:** `MemoryCacheService` and `InProcessDistributedLock`
(`CrossCutting/Seams/MemoryCacheService.cs:11`, `InProcessDistributedLock.cs:14`) — single-process only;
the money-path lock convention `booking:{id}:payment|refund` is already in the handlers.
- **Default:** `redis:7-alpine` (AOF on). **Required the moment a second API instance runs** (shared cache
invalidation generation-tokens + cross-instance money mutex). Config keys to be introduced with the swap
(plan §4.2); none exist today.
### 6 · MinIO / S3 / ArvanCloud
- **Evidence:** `LocalDiskObjectStorage` writes under a temp root and returns `file://` URLs
(`CrossCutting/Seams/LocalDiskObjectStorage.cs:20,56`); consumers: b6 verification documents
(signed-URL upload flow), future avatars (REQ-006), invoice `PdfStorageKey`
(`InvoicesConfig/InvoiceConfig.cs``pdf_storage_key` column).
- **Default:** `minio/minio:latest` (S3-compatible; ArvanCloud object storage is the Iran-hosted option).
- **Config:** `Seams:ObjectStorage:RootPath` today → bucket/endpoint/keys with the swap. Presigned PUT/GET
with expiry is the contract the frontend already codes against.
### 7 · Job scheduler
- **Evidence:** two `PeriodicTimer` hosted services only
(`Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67`); the payout/expiry/no-show/
Moadian crons are admin-manual with seeded-but-unread cadence keys (plan §4.1). **No `IJobScheduler`
interface exists** — the registry name is aspirational.
- **Default:** Hangfire on the existing SQL Server (no new container) — dashboard behind admin auth; or
Quartz with SQL persistence. Not a separate service to "spin up", but it changes the SQL footprint
(schema) and ops (dashboard, retries).
### 8 · SMS gateway
- **Evidence:** `LoggingSmsSender` logs OTPs instead of sending (`CrossCutting/Seams/LoggingSmsSender.cs:16`)
**no real user can log in**; per-phone resend window + `otp` rate policy already enforced upstream.
- **Default:** Kavenegar / Ghasedak / SMS.ir (SaaS — API key, no container). Keys to add:
`Seams:Sms:{ApiKey,SenderLine,BaseUrl}` (registry row 12). Template/pattern OTP send for deliverability.
### 9 · PSP / IPG + Shaparak (تسهیم)
- **Evidence:** `MockPaymentProvider` (instant success, `VerifyAsync` echoes the expected amount —
`CrossCutting/Seams/MockPaymentProvider.cs:24`), `MockWebhookVerifier` (marker-based "signature" —
`MockWebhookVerifier.cs:21`), `MockSettlementSplitProvider` (`MockSettlementSplitProvider.cs:13`). Real
merchant credentials belong in the **encrypted** `payment_gateways.config_json` (seeded sandbox row:
`Persistence/ServiceCollectionExtensions.cs:109-117` — environment-gate it, plan §1.4).
- **Default:** SaaS (ZarinPal / Sadad / Vandar / Jibit) — needs merchant + terminal registration and
تسهیم (settlement-split) setup to registered IBANs; webhook endpoint is already public
(`POST webhooks/payments/{provider}`) with upsert-first idempotency in place.
### 10 · BNPL providers
- **Evidence:** `MockBnplProvider` drives the full verb set + state machine
(`CrossCutting/Seams/MockBnplProvider.cs:17-53`); callback endpoint `WebhooksBnplController` (signed,
rate-limited). `Seams:Bnpl:*` + `Seams:Currency:TomanToIrrMultiplier` (`SeamOptions.cs:98-120,79`).
- **Default:** SnappPay and/or Digipay (SaaS, OAuth). **Do plan §2.1 first** — the revert-clearing path is
currently unreachable, so real BNPL refunds would strand ledger state.
### 11 · Bank-transfer rail (PAYA/SATNA)
- **Evidence:** `MockBankTransferProvider` settles every instruction instantly
(`CrossCutting/Seams/MockBankTransferProvider.cs:18-31`); the handler already chooses PAYA vs SATNA by
`payout_satna_threshold_irr` (`platform_configs` seed row 22) and the irreversibility backstops
(unconditional `UNIQUE(booking_id)` link, forward-only payout machine) are in place.
- **Default:** Jibit / Vandar / Sadad payout API (SaaS). Needs the source settlement account + the async
`submitted → paid/failed` reconciliation callback the mock collapses (registry row 23, steps 34).
### 12 · سامانه مودیان
- **Evidence:** `MockMoadianClient` leaves invoices `pending` forever (`CrossCutting/Seams/MockMoadianClient.cs:21`);
no reconciliation job exists (plan §6.5). VAT-on-commission + sequential invoice numbers are already
correct server-side.
- **Default:** government SaaS — enrollment (memory/economic code) + a signing certificate; the certificate
is a deploy-time secret.
### 13 · KYC bridge (Shahkar / e-KYC / استعلام شبا)
- **Evidence:** three deterministic mocks with magic-value failure cases
(`MockShahkarVerifier.cs:26-37`, `MockIdentityKycProvider.cs:25`,
`MockBankAccountOwnershipVerifier.cs:26`); handlers already persist `external_response_json` and treat
shared-SIM as a handled state. The Sheba-ownership result gates first payouts (`matched_national_id`).
- **Default:** one Finnotech-class bridge covers all three استعلام‌ها (SaaS; API keys under
`Seams:{Shahkar,IdentityKyc,BankOwnership}:*`).
### 14 · Geocoding (Neshan)
- **Evidence:** `MockGeocoder` jitters ±5 km around 8 hardcoded centroids (`MockGeocoder.cs:52`) — EVV
distance checks are noise until this (and/or REQ-008's user pin) is real. `Seams:Geocoding:*` is one of
only three seam sections present in `appsettings.json` (`:22-26`).
- **Default:** Neshan (Iran coverage; SaaS API key), rate-limit/retry per registry row 31.
### 15 · Review moderation
- **Evidence:** keyword-list mock; clean text stays in the human queue by default
(`MockReviewModerationService.cs:23`; `Seams:ReviewModeration:*`). Human `ModerateReviewCommand` retains
decision authority — so this can stay mocked indefinitely at low volume.
- **Default:** any text-moderation API / LLM endpoint when review volume outgrows the human queue.
### 16 · MoH / INO / eNamad — a process, not a service
- **Evidence:** `MockCredentialVerifier` always returns `RequiresManualReview`
(`MockCredentialVerifier.cs:18`); `MockLicenseVerificationService` likewise
(`MockLicenseVerificationService.cs:26`). The registry itself records **no public B2B API exists**
the admin review queue *is* the real implementation. Provision: admin staffing + the f15 console.
### 17 · Elasticsearch — deliberately later
- **Evidence:** `SqlNurseSearch` is the real MVP backend; `Search:Backend``sql` throws at startup
(`Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:74-78`); no ES client package.
- **Default when needed:** `elasticsearch:8.x` + the outbox feeder (registry rows 38/43; plan §8.1).
---
## Deployment notes (from the code, not aspiration)
1. **Boot = migrate + seed.** Every non-Testing start applies EF migrations and seeds roles, the
`admin`/`qw123321` user, and an **active sandbox ZarinPal gateway** (`Program.cs:99-104`). Until plan
§1.3/§1.4/§4.3 land: single-instance start-up, DDL-privileged login, and clean up the seeded credentials
per environment.
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
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
(plan §7.5), TLS required for JWE sanity.
4. **Single-instance constraints today:** in-memory cache, in-proc money lock, in-proc sweeps, per-instance
rate-limit buckets. Scaling past one instance requires plan §4.2 (Redis) + §4.3 (migrations) first — the
DB uniques keep money *correct* either way, but locks/cache/limits silently degrade.
5. **Logs:** deployed envs write Warning+ to `Baya_Logs` only (Information dropped — plan §7.3); dev writes
console + `logs/log.json`.
6. **Client:** the Next.js app needs `NEXT_PUBLIC_API_URL` pointing at the proxy; wire casing camelCase;
snake_case routes.
@@ -12,6 +12,41 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no) - **Requests filed:** frontend/requests/for-backend.md (yes/no)
--> -->
## frontend-phase-9-b10 — Checkout, card payment & invoice — 2026-07-10
- **Shipped:** the money moment — a **new `services/payment` domain** (types/keys/constants/
apis[client+mock]/invalidations/5 hooks + barrel) and the customer checkout flow: **C6 خلاصه و پرداخت**
`/bookings/checkout?request_id=` (acceptance badge, served **reconciling** service-cost/کارمزد/مالیات/مبلغ کل
breakdown, verbatim **escrow notice**, payment-window countdown, «ادامه پرداخت ←» with an
**idempotency-key-per-attempt**, disabled BNPL seam for f11), the **card states** (initiating → redirect →
dev **mock-gateway harness** `/bookings/checkout/gateway` → return `/bookings/checkout/return` with a
**backoff pending-callback poll** → succeeded/failed/expired), the **confirmation**
`/bookings/checkout/confirmation` («مشاهده رزرو» + «دانلود فاکتور»), and the **invoice**
`/bookings/[id]/invoice` (VAT-on-commission line, read-only مودیان state, pdfUrl download or print
receipt). Three shared tested composites: `PriceBreakdown`, `EscrowNotice`, `PaymentStatusBadge`. New
`payment` i18n namespace (53 keys, both locales).
- **Load-bearing rules honored:** money = IRR digit-strings, **BigInt only** (integer parts-per-10000 rate
math in the mock — zero floats); the breakdown **must reconcile** (`PriceBreakdown` dev-guards it; rows
are served, VAT never derived client-side); **VAT on the commission only** (invoice line labelled
accordingly); escrow copy **verbatim** in fa (pinned by test against fa.json), info tone never error;
**409 = benign convergence** (re-reads the outcome, never an error toast); poll uses **geometric backoff,
stops on terminal + bounded attempts**; success flips the booking **by cache invalidation** (exact keys,
no refetch storm).
- **Consumes:** dev/contracts/domains/payments.md (b10 — initiate route + `Idempotency-Key` header +
`InitiatePaymentResult`; status enum `pending|succeeded|failed`) and the invoice slice of
refunds-invoices.md (b11 — `GET invoices/{bookingId}`, `InvoiceDto`), casing verified against
swagger.v1.json. **Not served by any contract:** a checkout summary, a client transaction read, and the
converted request's booking id → REQ-016/017/018.
- **Mocked client-side:** `services/payment` via `paymentMockApi` (**USE_PAYMENT_MOCK=true, primary**) —
it is the missing **conversion trigger bridging the f7 ↔ f8 mock stores**: capture converts the request
(`converted` + client-augmented `bookingId`), inserts a **confirmed** booking into the f8 store, and
auto-issues the b11-shaped invoice, so C5 → C6 → gateway → confirmation → booking detail → invoice runs
end-to-end in one session. The dev **mock-gateway page is a test harness, not a product feature**. Real
`paymentClientApi`: initiate/invoice = published contract; summary targets the REQ-016 proposed slug;
outcome maps `booking_requests/get` (REQ-017).
- **Gate:** npm run check green · npm run test:ci green (204 tests, +9) · production build green.
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-016 checkout summary, REQ-017 payment
outcome + bookingId, REQ-018 customer invoice availability post-capture).
## frontend-phase-8-b9 — Booking detail, sessions & nurse EVV — 2026-07-10 ## frontend-phase-8-b9 — Booking detail, sessions & nurse EVV — 2026-07-10
- **Shipped:** the post-payment engagement — a **new** `services/bookings` domain (the sibling of - **Shipped:** the post-payment engagement — a **new** `services/bookings` domain (the sibling of
`bookingRequests`, NOT a rename): types/keys/constants/apis[client(1:1 b9)+mock+serverApi]/8 hooks + `bookingRequests`, NOT a rename): types/keys/constants/apis[client(1:1 b9)+mock+serverApi]/8 hooks +
@@ -214,3 +214,52 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
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:** open
## 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
`accepted_awaiting_payment` request: the three b10 amounts (`grossPriceIrr`,
`balinyaarCommissionIrr`, `nursePayoutAmount`) **plus the display decomposition** — service cost,
commission **net of VAT**, `vatIrr` + `vatRate` — and the nurse/variant/schedule mini-info +
`paymentDeadlineAt`.
- **Why:** The b8 `BookingRequestDto` is money-free by design and no checkout-summary endpoint exists,
but C6 must show a breakdown that **reconciles to the rial** (service + commission + VAT = total) and
the client is forbidden from deriving commission or tax itself (no float math, rates are server
config). Today the whole C6 money surface is mocked (`services/payment` mock computes the split from
the mock variant price at the configured 12% fee / 10% VAT).
- **Proposed shape:** `GET api/v1/booking_requests/checkout_summary/{id}` (owner-scoped) →
`{ bookingRequestId, requestStatus, nurseName, patientName, variantLabel, variantPriceUnit,
sessionCount, requestedDate, requestedTimeStart, requestedTimeEnd, paymentDeadlineAt,
serviceCostIrr, commissionIrr, vatIrr, vatRate, totalIrr, grossPriceIrr, balinyaarCommissionIrr,
nursePayoutAmount }` — the client's real `paymentClientApi.getCheckoutSummary` already targets this
slug and unwraps this exact shape (`client/src/services/payment/types.ts: CheckoutSummaryDto`).
- **Status:** open
## 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
status (`pending|succeeded|failed`) and (b) **which booking** the capture created. Either a
transaction read (e.g. `GET api/v1/payment_transactions/{id}` or
`GET api/v1/bookings/{bookingRequestId}/payments/latest`, owner-scoped) or, minimally, a
`bookingId` field on `BookingRequestDto` once `status = converted`.
- **Why:** b10 confirms captures inside the PSP webhook (correct — the client is never trusted), so the
frontend's pending-callback state can only poll `booking_requests/get/{id}` and map
`converted → succeeded` / `payment_deadline_expired → failed`. That works, but it cannot distinguish a
*declined* payment (still `accepted_awaiting_payment`, retry allowed) from a *slow* callback, and the
confirmation screen cannot deep-link «مشاهده رزرو» or «دانلود فاکتور» because the converted request
never reveals its booking id. The client DTO already carries a client-augmented
`bookingId: number | null` (mock fills it; real path returns null and the UI falls back to the
bookings list, hiding the invoice link).
- **Proposed shape:** add `bookingId: long?` to `BookingRequestDto` (null until converted) **and/or**
`GET api/v1/bookings/{bookingRequestId}/payments/latest` → `{ transactionId, status,
gatewayReferenceCode, bookingId? }`.
- **Status:** open
## 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
commission invoice on card capture (idempotent per booking, as `POST admin_invoices` already is), or
allow the owning customer to trigger the idempotent issue on first `GET api/v1/invoices/{bookingId}`.
- **Why:** The f9 confirmation screen offers «دانلود فاکتور», but b11 issues invoices only via the
admin-only `POST api/v1/admin_invoices`, so a customer's `GET invoices/{bookingId}` 404s until an
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
state.
- **Status:** open
@@ -0,0 +1,165 @@
# Frontend phase 9 report — Checkout, card payment & invoice (consumes b10 + the invoice slice of b11)
**Status:** complete · gate green (`npm run check`, `npm run test:ci` — 204 tests, production build) · 2026-07-10
**Scope shipped:** the C6 خلاصه و پرداخت checkout, the card-payment state machine (initiate → redirect →
pending-callback → succeeded→confirmed / failed→retry), the confirmation screen, the invoice view, the
`services/payment` domain, and three shared money composites.
---
## 1. What was built
### `services/payment` (new domain — mirrors the `auth`/`bookings` shape)
| File | What it is |
| --- | --- |
| `types.ts` | Contract-derived DTOs + the `PaymentApi` seam. `PaymentTransactionStatus = pending\|succeeded\|failed` (the b10 enum — the phase file's illustrative `initiated/cancelled` states do **not** exist on the wire and were not used). `CheckoutSummaryDto` (REQ-016 shape), `InitiatePaymentResult` (b10 swagger), `PaymentOutcomeDto` (poll target), `InvoiceDto` (b11 swagger, flat totals — **no line-items array exists in the contract**). |
| `keys.ts` | `paymentKeys.summary(requestId)` / `.outcome(requestId)` / `.invoice(bookingId)`. |
| `constants.ts` | `USE_PAYMENT_MOCK=true` (why documented in-file), `BNPL_ENABLED=false` (the f11 seam gate), poll backoff tuning, checkout query-param names, mock money rates. |
| `apis/clientApi.ts` | Real impl: **initiate** = `POST api/v1/bookings/{id}/payments` + **`Idempotency-Key` header** (no body — contract-exact); **invoice** = `GET api/v1/invoices/{bookingId}`; **summary** targets the REQ-016 proposed slug; **outcome** maps `GET booking_requests/get/{id}` (`converted`→succeeded, `payment_deadline_expired`→failed, else pending — REQ-017). |
| `apis/mockApi.ts` | Mock-primary state machine — see §3. |
| `invalidations.ts` | `invalidateAfterPaymentSuccess` — the one post-capture cache transition (request detail/lists + bookings lists/detail + this request's summary/outcome). Exact keys, never a blanket refetch. |
| `hooks/` | `useCheckoutSummary` (short stale), `useInitiatePayment` (caller owns the per-attempt key), `useConfirmGatewayReturn` (primes the outcome key; invalidates on immediate success), `usePaymentOutcome` (**geometric-backoff poll**: 2s → ×1.5 → cap 15s, stops on terminal outcome or 40 attempts; exports `isTerminalPaymentOutcome`), `useInvoice` (immutable → long stale; **404 = "not issued", not retried**). |
### Screens (customer shell)
- **C6** `/bookings/checkout?request_id=` — «✓ پرستار تایید کرد» badge (reuses `booking.accepted_badge`),
nurse/service/schedule mini-summary, the payment-window `CountdownTimer`, the **served reconciling
breakdown** (هزینه خدمت / کارمزد بالین‌یار / مالیات بر ارزش افزوده / **مبلغ کل**) via `PriceBreakdown`,
the verbatim `EscrowNotice`, «ادامه پرداخت ←» (terracotta `secondary`), and the **BNPL seam** — a
disabled outlined «یا پرداخت اقساطی» + "coming soon" caption gated by `BNPL_ENABLED` for f11 to wire to D1.
Non-payable statuses render convergence cards (already-paid → outcome; window-expired / other terminal → back to C5).
- **Gateway harness** `/bookings/checkout/gateway`**test-only** fake PSP (labelled آزمایشی): success +
failure buttons so both return branches are drivable without a gateway. The phase file suggested
auto-success; buttons were chosen instead so §7 step 5 (failed attempt → new idempotency key) is testable by a human.
- **Return** `/bookings/checkout/return` — fires `useConfirmGatewayReturn` once per mount (ref-guarded; a
refresh replays it and converges idempotently), then the pending-callback poll. Succeeded → invalidate +
`router.replace` to confirmation (once, ref-guarded, no double invalidation between mutation and poll
paths); failed → retry (back to a fresh C6 mount = **new attempt, new key**); window lapsed → back to C5.
A manual «بررسی دوباره» covers the bounded poll giving up.
- **Confirmation** `/bookings/checkout/confirmation` — success state, amount-paid card, «مشاهده رزرو» →
`/bookings/{bookingId}` (falls back to the list without a bookingId — REQ-017), «دانلود فاکتور» →
`/bookings/{bookingId}/invoice` (hidden without a bookingId).
- **Invoice** `/bookings/[id]/invoice``invoiceNumber` + Shamsi issue date, `PriceBreakdown` rows where
the **service line is the exact integer remainder** (gross commission VAT, via `parseIrr` BigInt) so
the lines reconcile by construction, the VAT line labelled **«مالیات بر ارزش افزوده (بر کارمزد
بالین‌یار)»** (product rule: the nurse is never implied to be taxed), read-only **مودیان** state chip
(`moadian_*`), and `pdfUrl` download **or** a print receipt (`window.print()` + a print-scoped
visibility rule isolating the invoice card). 404 renders «فاکتور هنوز صادر نشده است» (REQ-018).
### Shared composites (each with a co-located test)
- **`PriceBreakdown`** — typed rows + total, all IRR digit-strings through `formatIrrToToman`; dev-guard
`console.error`s if rows ≠ total (test proves rows render, total = Σ rows, and the guard fires on mismatch).
- **`EscrowNotice`** — wraps `AppAlert` (info severity, `--bal-primary` text on `--bal-primary-soft`, lock icon). Its test mocks next-intl to
read **the real `fa.json`**, pinning the mandated copy verbatim: a rewording fails the suite.
- **`PaymentStatusBadge`** — full `PaymentTransactionStatus``StatusChip` kind map (`Record` typed, so an
enum change breaks the build); labels from `payment.pstatus_*`.
### Extensions to prior phases (in place, per operating rules)
- `services/bookingRequests`: client-augmented **`bookingId: number | null`** on `BookingRequestDto`
(REQ-017 twin of REQ-013's `variantPrice`; real client maps it to `null`), and the mock-only
`mockMarkBookingRequestConverted(id, bookingId)` capture bridge.
- `services/bookings/apis/mockApi.ts`: mock-only `mockInsertConvertedBooking(seed)` — inserts a confirmed
single-session booking (ids 6001+/80001+, distinct from the 5001/5002 seeds).
- C5 (`bookings/request/[id]`): the `converted` terminal card now deep-links the booking when
`bookingId` is present (list fallback otherwise).
- `constants/routes.ts`: `CHECKOUT_GATEWAY/RETURN/CONFIRMATION` + `bookingInvoicePath()`.
- i18n: new **`payment`** namespace — 53 keys, both locales, inserted textually (no reformat of existing lines).
## 2. Contract deltas the implementation honors (vs the phase file's illustrative design)
The phase file sketched `getCheckoutSummary`/`verifyPayment`/`getTransaction` endpoints and an
`initiated…cancelled` status enum. The **published contract wins**:
1. **Status enum is `pending|succeeded|failed`** (b10 `payment_transactions.status`). Client unions match.
2. **There is no client verify/transaction endpoint.** The server re-verifies inside the webhook handler;
"verify on return" is therefore modelled as `confirmGatewayReturn` (real impl = an outcome *read* — the
PSP already hit the webhook before redirecting) + the outcome poll. Filed as REQ-017.
3. **There is no checkout-summary endpoint** and the b8 request DTO is money-free — the C6 breakdown
cannot be served today. Filed as REQ-016; mocked behind the seam; the real client targets the proposed slug.
4. **The invoice is flat totals** (`grossIrr`/`platformCommissionIrr`/`vatRate`/`vatIrr`…, no line-items
array) and only exists after the **admin-only** issue action. Filed as REQ-018; the UI has a not-issued state.
5. **Idempotency is a header** (`Idempotency-Key`), not a body field — the contract's exact casing.
## 3. Mocks in this phase (recorded in mocks-registry.md)
- **`paymentMockApi` (mock-primary)** — the missing **conversion trigger between the f7 and f8 mock
stores** (their `converted`/seeded-booking states were previously unconnected): capture flips the f7
request to `converted` + stamps `bookingId`, inserts a **confirmed** f8 booking, and auto-issues the
b11-shaped invoice. Money split uses integer parts-per-10000 BigInt math (12% fee, 10% VAT,
`vat = commission_net × rate` per b11) so `service + commission + vat = total` **exactly** and
`gross = balinyaarCommission + payout` holds. Idempotency mirrors b10: same-key retry reuses the
attempt, post-capture initiate → `409`, replayed returns converge.
- **The mock-gateway page** — test harness only (see registry row for the deletion story).
- **Why mock-primary:** upstream ids are mock-primary (f7), REQ-016/017 are unserved, and nothing fires
the PSP webhook in dev (b10's "webhook simulator" is a manual server-side POST — after a real initiate,
nothing would ever confirm). Swap = deliver REQ-016/017/018 + real upstreams, then `USE_PAYMENT_MOCK=false`.
## 4. What is now testable, exactly (mock path — `npm run dev`)
Prereq: an `accepted_awaiting_payment` request — either seed-driven (open `/fa/nurse/requests`, accept a
seeded pending request) or full-flow (C4 create → nurse accept). Same browser tab throughout (module-singleton mocks).
1. **C6:** from C5's «ادامه پرداخت» (or `/fa/bookings/checkout?request_id={id}`) — badge, mini-summary,
30-min countdown, breakdown that sums to the rial (2,800,000×1 IRR gross → service 2,436,000 +
commission 336,000 VAT split), escrow notice in info tone. `/en` flips `dir`, translates, still Toman.
2. **Pay:** «ادامه پرداخت ←» → spinner → the آزمایشی gateway → «پرداخت موفق» → return surface briefly shows
«در حال تایید پرداخت…» → confirmation screen.
3. **Booking flips:** «مشاهده رزرو» lands on `/bookings/{id}` showing **confirmed** (React Query Devtools:
only request-detail/lists, bookings lists/detail, and this request's payment keys invalidated). C5 now
shows the converted card deep-linking the same booking; the bookings list has the new row.
4. **Invoice:** «دانلود فاکتور» → number `INV-…`, Shamsi date, service/commission/VAT-on-commission/total
reconciling to C6, مودیان «در انتظار ثبت», print button (mock serves no `pdfUrl` so the print path runs).
5. **Idempotency/retry:** double-tap pay (one attempt, same key — mock returns the same transaction);
re-initiate after success → `409` → converges to confirmation, no error toast. Gateway «شبیه‌سازی
پرداخت ناموفق» → failed card → «تلاش دوباره» → fresh C6 mount issues a **new** key (observable in the
mock's `gatewayReferenceCode` suffix).
6. **Window expiry:** wait out the 30-min window (or re-enter later) → C6/return show «مهلت پرداخت به
پایان رسید» and C5 shows its terminal card; initiate after expiry → `409` handled as state, not error.
7. **Invoice not-issued state:** `/fa/bookings/5001/invoice` (a seeded booking that never went through
checkout) → «فاکتور هنوز صادر نشده است».
## 5. Follow-ups for the next phases
- **f10 (refunds/cancellation):** reuse `PriceBreakdown` (fee disclosure), `EscrowNotice` (identical trust
copy), `PaymentStatusBadge`; `RefundStatusDto`/`refunds/{id}/status` is live in b11 and unconsumed;
`refundableAmountIrr`/`cancellationRefundPercentage` already ride on `BookingDetailDto`.
- **f11 (BNPL):** flip `BNPL_ENABLED` and wire the C6 secondary to D1. The b12 contract
(`checkout_bnpl/eligibility|initiate|{id}` + `Idempotency-Key` header) parallels this domain's shapes;
`InvoiceDto.bnplCommissionIrr` is already typed. The gateway-harness pattern extends to the BNPL redirect.
- **Backend:** REQ-016 (checkout summary), REQ-017 (outcome/bookingId — until then the real poll can't
distinguish *declined* from *slow*, and the confirmation can't deep-link), REQ-018 (invoice reachable
post-capture). Also note: the PSP's return-URL config must deep-link `/{locale}/bookings/checkout/return`.
## 6. Post-review hardening (multi-agent adversarial review before close)
A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
- **Stale-outcome guard (major):** the return surface now trusts the outcome cache only after *this*
mount's return report settles — a previous attempt's cached `failed` outcome can no longer flash a
false «پرداخت ناموفق» (with a live retry) while the current attempt's capture is in flight.
- **No dead-end retries:** malformed `request_id`/booking-id links render a navigation card instead of a
`refetch()` that bypasses `enabled` and would request `checkout_summary/undefined`.
- **Unpriced request fails loudly:** the mock throws `409 unpriced_request` instead of silently serving a
reconciling 0-rial checkout when `variantPrice` is null (REQ-013 edge).
- **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;
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
wordmark/product-spelling divergence worth a product decision).
- **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
light around `window.print()` (restored on `afterprint`) so a dark-mode user prints paper colors.
- **Contract hygiene:** the domain barrel is hooks-only again (`isTerminalPaymentOutcome` moved to
`types.ts`, mirroring `isTerminalBookingRequestStatus`); the gateway harness scopes `dir="ltr"` to the
reference code, not the Persian label; the invoice VAT percent formats fractional rates
(`maximumFractionDigits: 2`).
## 7. Gate
`npm run check` green · `npm run test:ci` green (46 suites, 204 tests — +9: PriceBreakdown 4, EscrowNotice 2,
PaymentStatusBadge 3) · `npm run build` green. `en.json`/`fa.json` in sync (53-key `payment` namespace).
`client/CLAUDE.md` Project Structure updated (checkout subtree, invoice route, `services/payment`, three
components, `payment` namespace entry). Gotcha for future phases: **BigInt literals (`0n`) don't compile**
(tsconfig target ES2017) — use the `BigInt(...)` constructor like `utils/money.ts`.
@@ -71,3 +71,5 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `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 | 🟡 |
| `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 | 🟡 |
| `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 | 🟡 |
| 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 | 🟡 |