diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 7376011..bc84723 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -129,8 +129,13 @@ client/ │ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail │ │ │ │ ├── [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/[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 - │ │ │ │ └── checkout/page.tsx # /bookings/checkout — pay & confirm handoff target (DEFERRED→f9 stub; C5 accept CTA lands here with request_id) + │ │ │ │ ├── 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) + │ │ │ │ ├── [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) │ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary) │ │ │ ├── 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) │ ├── 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) + │ ├── 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) │ ├── 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 @@ -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 │ ├── 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 + │ ├── 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}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── 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 - `'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 +- `'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) **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale diff --git a/client/messages/en.json b/client/messages/en.json index 03d2762..4a585e1 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -558,6 +558,61 @@ "list_error": "Couldn't load your bookings.", "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": { "customer_title": "Sign in to Balinyaar", "customer_subtitle": "Sign in with your mobile number", diff --git a/client/messages/fa.json b/client/messages/fa.json index 96daadf..13b6030 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -558,6 +558,61 @@ "list_error": "بارگذاری رزروها ممکن نشد.", "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": { "customer_title": "ورود به بلینیار", "customer_subtitle": "با شماره موبایل خود وارد شوید", diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx new file mode 100644 index 0000000..042b9e4 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/invoice/page.tsx @@ -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 = { + 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 ( + + + + + {t('error_title')} + + + {t('invalid_link_body')} + + router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}> + {t('view_booking')} + + + + ); + } + + if (isLoading) { + return ( + + + + + + ); + } + + if (!invoice) { + const notIssued = error instanceof ApiError && error.status === 404; + return ( + + + + + {notIssued ? t('invoice_not_issued_title') : t('error_title')} + + + {notIssued ? t('invoice_not_issued_body') : t('error_body')} + + {notIssued ? ( + router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}> + {t('view_booking')} + + ) : ( + refetch()} sx={{ m: 0 }}> + {tc('retry')} + + )} + + + ); + } + + // 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 ( + + + + + + + + {t('invoice_title')} + + {/* The issuer line uses the product spelling «بالین‌یار» — fa `common.brand` currently reads + «بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */} + + {t('issuer_platform')} + + + + + + + + + + + + {invoice.moadianStatus ? ( + + + {t('moadian_label')} + + + + ) : null} + + + + + {invoice.pdfUrl ? ( + + {t('download_invoice')} + + ) : ( + + {t('print_invoice')} + + )} + router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}> + {t('view_booking')} + + + + ); +} + +function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) { + return ( + + + {label} + + + {value} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx new file mode 100644 index 0000000..f86f01d --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/confirmation/page.tsx @@ -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 ( + }> + + + ); +} + +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 ( + + + + + {t('confirm_title')} + + + {t('confirm_subtitle')} + + + + {summary ? ( + + + + {t('total_paid_label')} + + + {formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')} + + + {summary.variantLabel} · {summary.nurseName} + + + + ) : null} + + + + router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`) + } + sx={{ m: 0 }} + > + {t('view_booking')} + + {bookingId != null ? ( + router.push(`/${locale}${bookingInvoicePath(bookingId)}`)} + sx={{ m: 0 }} + > + {t('download_invoice')} + + ) : null} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx new file mode 100644 index 0000000..f648efc --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx @@ -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 ( + }> + + + ); +} + +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 ( + + + + + {t('gateway_title')} + + + {t('gateway_hint')} + + {transactionId ? ( + + {/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */} + {t('gateway_reference_label')}:{' '} + + #{transactionId} + + + ) : null} + returnWith('success')} sx={{ m: 0 }}> + {t('gateway_pay_success')} + + returnWith('failure')} sx={{ m: 0 }}> + {t('gateway_pay_fail')} + + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx index 9f7c4d8..f95f2a1 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/page.tsx @@ -1,25 +1,299 @@ 'use client'; -import { Suspense } from 'react'; -import { useTranslations } from 'next-intl'; -import { useSearchParams } from 'next/navigation'; -import { AppLoading, PlaceholderScreen } from '@/components'; +import { Suspense, useRef } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { useRouter, useSearchParams } from 'next/navigation'; +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 - * the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder - * confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary. + * C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling + * service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the + * «ادامه پرداخت ←» 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() { return ( }> - + ); } -function CheckoutDeferred() { - const t = useTranslations('booking'); +function CheckoutScreen() { + const t = useTranslations('payment'); + const tb = useTranslations('booking'); + const tc = useTranslations('common'); + const locale = useLocale(); + const router = useRouter(); const params = useSearchParams(); - const requestId = params.get('request_id') ?? '—'; - return ; + + 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(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 ( + router.replace(`/${locale}${ROUTES.BOOKINGS}`)} + /> + ); + } + if (isError) { + return ( + refetch()} + /> + ); + } + if (isLoading || !summary) return ; + + const returnUrl = (extra?: Record) => { + 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 ( + router.replace(returnUrl())} + /> + ); + } + if (summary.requestStatus !== 'accepted_awaiting_payment') { + const expired = summary.requestStatus === 'payment_deadline_expired'; + return ( + 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 ( + + + + + {t('title_checkout')} + + + + + + {summary.paymentDeadlineAt ? ( + + refetch()} + /> + + ) : null} + + + + + + {inlineError ? ( + + {inlineError} + + ) : null} + + + + {initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')} + + {/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */} + + {t('bnpl_option')} + + {!BNPL_ENABLED ? ( + + {tc('coming_soon')} + + ) : null} + + + ); +} + +/** 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 ( + + + + {summary.variantLabel} + + + {summary.nurseName} · {summary.patientName} + + + {formatShamsiDate(start, locale)} · {timeFmt.format(start)} – {timeFmt.format(end)} + + + + ); +} + +function MessageCard({ + icon, + tone, + title, + body, + ctaLabel, + onCta, +}: { + icon: string; + tone: string; + title: string; + body?: string; + ctaLabel: string; + onCta: () => void; +}) { + return ( + + + + {title} + + {body ? ( + + {body} + + ) : null} + + {ctaLabel} + + + ); +} + +function CheckoutSkeleton() { + return ( + + + + + + + + + + + + ); } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/return/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/return/page.tsx new file mode 100644 index 0000000..6e9c3b4 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/return/page.tsx @@ -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 ( + }> + + + ); +} + +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 ( + + router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}> + {t('view_booking')} + + + ); + } + + if (windowExpired) { + return ( + + router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} + sx={{ m: 0 }} + > + {t('back_to_request')} + + + ); + } + + if (failed) { + return ( + + + + router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`) + } + sx={{ m: 0 }} + > + {t('retry_payment')} + + router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)} + sx={{ m: 0 }} + > + {t('back_to_request')} + + + ); + } + + // Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state. + return ( + + + + {/* Manual re-check — covers the bounded poll giving up on a very slow callback. */} + outcomeQuery.refetch()} sx={{ m: 0 }}> + {t('check_again')} + + + ); +} + +function StateCard({ + icon, + tone, + title, + body, + children, +}: { + icon: string; + tone: string; + title: string; + body?: string; + children?: ReactNode; +}) { + return ( + + + + + {title} + + {body ? ( + + {body} + + ) : null} + {children} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx index 5287aa3..780b06f 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/request/[id]/page.tsx @@ -120,7 +120,12 @@ export default function BookingRequestStatusPage() { tone="var(--bal-success)" title={t('converted_title')} 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}`, + ) + } /> ); diff --git a/client/src/components/EscrowNotice/EscrowNotice.test.tsx b/client/src/components/EscrowNotice/EscrowNotice.test.tsx new file mode 100644 index 0000000..6f8f6da --- /dev/null +++ b/client/src/components/EscrowNotice/EscrowNotice.test.tsx @@ -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>; + return messages[namespace][key]; + }, +})); + +import EscrowNotice from './EscrowNotice'; + +function renderNotice() { + return render( + + + , + ); +} + +describe(' 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'); + }); +}); diff --git a/client/src/components/EscrowNotice/EscrowNotice.tsx b/client/src/components/EscrowNotice/EscrowNotice.tsx new file mode 100644 index 0000000..d533c7a --- /dev/null +++ b/client/src/components/EscrowNotice/EscrowNotice.tsx @@ -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 ( + } + 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')} + + ); +}; + +export default EscrowNotice; diff --git a/client/src/components/EscrowNotice/index.tsx b/client/src/components/EscrowNotice/index.tsx new file mode 100644 index 0000000..042f06f --- /dev/null +++ b/client/src/components/EscrowNotice/index.tsx @@ -0,0 +1 @@ +export { default } from './EscrowNotice'; diff --git a/client/src/components/PaymentStatusBadge/PaymentStatusBadge.test.tsx b/client/src/components/PaymentStatusBadge/PaymentStatusBadge.test.tsx new file mode 100644 index 0000000..3f6d38c --- /dev/null +++ b/client/src/components/PaymentStatusBadge/PaymentStatusBadge.test.tsx @@ -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( + + + , + ); +} + +// 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(' 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(); + }); +}); diff --git a/client/src/components/PaymentStatusBadge/PaymentStatusBadge.tsx b/client/src/components/PaymentStatusBadge/PaymentStatusBadge.tsx new file mode 100644 index 0000000..abf4378 --- /dev/null +++ b/client/src/components/PaymentStatusBadge/PaymentStatusBadge.tsx @@ -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 = { + pending: 'pending', + succeeded: 'verified', + failed: 'rejected', +}; + +export interface PaymentStatusBadgeProps extends Omit { + 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 = ({ status, ...rest }) => { + const t = useTranslations('payment'); + return ; +}; + +export default PaymentStatusBadge; diff --git a/client/src/components/PaymentStatusBadge/index.tsx b/client/src/components/PaymentStatusBadge/index.tsx new file mode 100644 index 0000000..14d43c1 --- /dev/null +++ b/client/src/components/PaymentStatusBadge/index.tsx @@ -0,0 +1,2 @@ +export { default } from './PaymentStatusBadge'; +export type { PaymentStatusBadgeProps } from './PaymentStatusBadge'; diff --git a/client/src/components/PriceBreakdown/PriceBreakdown.test.tsx b/client/src/components/PriceBreakdown/PriceBreakdown.test.tsx new file mode 100644 index 0000000..4c523d7 --- /dev/null +++ b/client/src/components/PriceBreakdown/PriceBreakdown.test.tsx @@ -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 = (props) => ( + + + +); + +// 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(' component', () => { + it('renders every row label with its Toman-formatted amount', () => { + render(); + 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(); + 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(); + 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(); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('must reconcile')); + errorSpy.mockRestore(); + }); +}); diff --git a/client/src/components/PriceBreakdown/PriceBreakdown.tsx b/client/src/components/PriceBreakdown/PriceBreakdown.tsx new file mode 100644 index 0000000..123ac56 --- /dev/null +++ b/client/src/components/PriceBreakdown/PriceBreakdown.tsx @@ -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 = ({ 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 ( + + + {rows.map((row) => ( + + + {row.label} + + + {formatIrrToToman(row.amountIrr, locale)} + + + ))} + + + + {totalLabel} + + + {formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')} + + + + + ); +}; + +export default PriceBreakdown; diff --git a/client/src/components/PriceBreakdown/index.tsx b/client/src/components/PriceBreakdown/index.tsx new file mode 100644 index 0000000..c9df408 --- /dev/null +++ b/client/src/components/PriceBreakdown/index.tsx @@ -0,0 +1,2 @@ +export { default } from './PriceBreakdown'; +export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown'; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index f044951..3171ca7 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -21,6 +21,9 @@ import NurseResultCard from './NurseResultCard'; import ServicePriceRow from './ServicePriceRow'; import CountdownTimer from './CountdownTimer'; import BookingRequestSummaryCard from './BookingRequestSummaryCard'; +import PriceBreakdown from './PriceBreakdown'; +import EscrowNotice from './EscrowNotice'; +import PaymentStatusBadge from './PaymentStatusBadge'; export { UserInfo, @@ -44,6 +47,9 @@ export { ServicePriceRow, CountdownTimer, BookingRequestSummaryCard, + PriceBreakdown, + EscrowNotice, + PaymentStatusBadge, }; export type { PlaceholderScreenProps } from './PlaceholderScreen'; export type { OtpInputProps } from './OtpInput'; @@ -65,3 +71,5 @@ export type { NurseResultCardProps } from './NurseResultCard'; export type { ServicePriceRowProps } from './ServicePriceRow'; export type { CountdownTimerProps } from './CountdownTimer'; export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard'; +export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown'; +export type { PaymentStatusBadgeProps } from './PaymentStatusBadge'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index d881e1d..35a6b0e 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -18,8 +18,14 @@ export const ROUTES = { BOOKING_REQUEST: '/bookings/request', // C5 awaiting-acceptance base — append `/{id}`; create navigates here, the id keys the polled status. 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', + // 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', // Address book — cascading region dropdowns + map-pin picker; reached from the profile hub. ADDRESSES: '/addresses', @@ -49,5 +55,9 @@ export const ROUTES = { ADMIN_NOTIFICATIONS: '/admin/notifications', } 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. */ export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN]; diff --git a/client/src/services/bookingRequests/apis/clientApi.ts b/client/src/services/bookingRequests/apis/clientApi.ts index 7783d76..4e42e49 100644 --- a/client/src/services/bookingRequests/apis/clientApi.ts +++ b/client/src/services/bookingRequests/apis/clientApi.ts @@ -14,13 +14,14 @@ const BASE = '/api/v1/booking_requests'; /** * The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice` - * (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price). + * (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; +type BookingRequestWireDto = Omit; -/** 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 { - return { ...wire, variantPrice: null }; + return { ...wire, variantPrice: null, bookingId: null }; } /** diff --git a/client/src/services/bookingRequests/apis/mockApi.ts b/client/src/services/bookingRequests/apis/mockApi.ts index 03f5dca..9eb339b 100644 --- a/client/src/services/bookingRequests/apis/mockApi.ts +++ b/client/src/services/bookingRequests/apis/mockApi.ts @@ -36,6 +36,7 @@ function seedRow(overrides: Partial): BookingRequestDto { return { id, status: 'pending_nurse_response', + bookingId: null, nurseId: 1, nurseName: 'مریم رضایی', nurseRating: 4.9, @@ -167,6 +168,7 @@ function buildFromContext( return { id, status: 'pending_nurse_response', + bookingId: null, nurseId: payload.nurseId, nurseName: context?.nurseName ?? '', nurseRating: context?.nurseRating ?? 0, @@ -283,3 +285,20 @@ export const bookingRequestsMockApi: BookingRequestsApi = { 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; +} diff --git a/client/src/services/bookingRequests/types.ts b/client/src/services/bookingRequests/types.ts index f8d92a9..3df0985 100644 --- a/client/src/services/bookingRequests/types.ts +++ b/client/src/services/bookingRequests/types.ts @@ -59,10 +59,17 @@ export function isTerminalBookingRequestStatus(status: BookingRequestStatus): bo * `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 * 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 { id: number; status: BookingRequestStatus; + /** Client-augmented (REQ-017): the converted booking's id, or `null` until converted / on the real path. */ + bookingId: number | null; nurseId: number; nurseName: string; nurseRating: number; diff --git a/client/src/services/bookings/apis/mockApi.ts b/client/src/services/bookings/apis/mockApi.ts index c07ae12..cc30db5 100644 --- a/client/src/services/bookings/apis/mockApi.ts +++ b/client/src/services/bookings/apis/mockApi.ts @@ -380,3 +380,84 @@ export const bookingsMockApi: BookingsApi = { 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); +} diff --git a/client/src/services/payment/apis/clientApi.ts b/client/src/services/payment/apis/clientApi.ts new file mode 100644 index 0000000..59d5268 --- /dev/null +++ b/client/src/services/payment/apis/clientApi.ts @@ -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>( + `${BOOKING_REQUESTS}/checkout_summary/${bookingRequestId}`, + ), + ), + + initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) => + unwrap( + await clientFetch>(`${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>>( + `${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>(`${INVOICES}/${bookingId}`)), +}; diff --git a/client/src/services/payment/apis/index.ts b/client/src/services/payment/apis/index.ts new file mode 100644 index 0000000..2db0ad6 --- /dev/null +++ b/client/src/services/payment/apis/index.ts @@ -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; diff --git a/client/src/services/payment/apis/mockApi.ts b/client/src/services/payment/apis/mockApi.ts new file mode 100644 index 0000000..306465f --- /dev/null +++ b/client/src/services/payment/apis/mockApi.ts @@ -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 = {}; +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, + }; +} diff --git a/client/src/services/payment/constants.ts b/client/src/services/payment/constants.ts new file mode 100644 index 0000000..4237660 --- /dev/null +++ b/client/src/services/payment/constants.ts @@ -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 (D1–D5); 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; diff --git a/client/src/services/payment/hooks/useCheckoutSummary.ts b/client/src/services/payment/hooks/useCheckoutSummary.ts new file mode 100644 index 0000000..ef54294 --- /dev/null +++ b/client/src/services/payment/hooks/useCheckoutSummary.ts @@ -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, + }); +} diff --git a/client/src/services/payment/hooks/useConfirmGatewayReturn.ts b/client/src/services/payment/hooks/useConfirmGatewayReturn.ts new file mode 100644 index 0000000..74f2cc8 --- /dev/null +++ b/client/src/services/payment/hooks/useConfirmGatewayReturn.ts @@ -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); + } + }, + }); +} diff --git a/client/src/services/payment/hooks/useInitiatePayment.ts b/client/src/services/payment/hooks/useInitiatePayment.ts new file mode 100644 index 0000000..cde1eeb --- /dev/null +++ b/client/src/services/payment/hooks/useInitiatePayment.ts @@ -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), + }); +} diff --git a/client/src/services/payment/hooks/useInvoice.ts b/client/src/services/payment/hooks/useInvoice.ts new file mode 100644 index 0000000..b29db0b --- /dev/null +++ b/client/src/services/payment/hooks/useInvoice.ts @@ -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; + }, + }); +} diff --git a/client/src/services/payment/hooks/usePaymentOutcome.ts b/client/src/services/payment/hooks/usePaymentOutcome.ts new file mode 100644 index 0000000..4ff27e5 --- /dev/null +++ b/client/src/services/payment/hooks/usePaymentOutcome.ts @@ -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, + ); + }, + }); +} diff --git a/client/src/services/payment/index.ts b/client/src/services/payment/index.ts new file mode 100644 index 0000000..2b2127e --- /dev/null +++ b/client/src/services/payment/index.ts @@ -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'; diff --git a/client/src/services/payment/invalidations.ts b/client/src/services/payment/invalidations.ts new file mode 100644 index 0000000..50fc1d1 --- /dev/null +++ b/client/src/services/payment/invalidations.ts @@ -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) }); +} diff --git a/client/src/services/payment/keys.ts b/client/src/services/payment/keys.ts new file mode 100644 index 0000000..e5689d1 --- /dev/null +++ b/client/src/services/payment/keys.ts @@ -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, +}; diff --git a/client/src/services/payment/types.ts b/client/src/services/payment/types.ts new file mode 100644 index 0000000..56c6ac1 --- /dev/null +++ b/client/src/services/payment/types.ts @@ -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`). + * + * 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; + initiatePayment(input: InitiatePaymentInput): Promise; + confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise; + getPaymentOutcome(bookingRequestId: number): Promise; + getInvoice(bookingId: number): Promise; +} diff --git a/dev/post-phase/server/README.md b/dev/post-phase/server/README.md new file mode 100644 index 0000000..8f2bd60 --- /dev/null +++ b/dev/post-phase/server/README.md @@ -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 f9–f15 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. diff --git a/dev/post-phase/server/frontend-backend-gaps.html b/dev/post-phase/server/frontend-backend-gaps.html new file mode 100644 index 0000000..64b6e7c --- /dev/null +++ b/dev/post-phase/server/frontend-backend-gaps.html @@ -0,0 +1,122 @@ + + + + + +Frontend ↔ backend gaps — REQ reconciliation + + +
+ +

Frontend ↔ backend gaps — REQ reconciliation

Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.

+

Audit date: 2026-07-10 · Sources: dev/shared-working-context/frontend/requests/for-backend.md (REQ-001…015), the frontend phase reports/STATUS (f0–f8), 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 (b0–b15), 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 f9–f15 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

+
REQAsk (short)VerdictOne-line evidence
REQ-001Envelope / casing / pagination shapeDone (confirm + caveat)ApiResult + PagedResult match the typed shape; statusCode is an integer enum
REQ-002codeLength/expiresInSeconds on RequestOtpResultMissingRequestOtpResult.cs:7 has only OtpSent, ResendAvailableInSeconds
REQ-003Machine error codes for verify_otpMissingenvelope has no code slot; lockout differs only by message text
REQ-004activeRole on MeResult (confirmation)Missing (answer: client owns it)MeResult.cs:9 — no ActiveRole anywhere in the contract
REQ-005Patient relation + conditionsMissingPatientDto.cs:7, create/update commands unchanged
REQ-006Avatar upload route + avatarUrlMissingzero IFormFile/avatar matches in server/src
REQ-007Customer name + preferred languageMissingupsert body is emergency-contact only
REQ-008Accept client map pin on address create/updateMissingcommands have no lat/lng; always geocodes
REQ-009provinceId on CustomerAddressDtoMissingDTO ends at RecipientPhone
REQ-010pageSize vs page_sizePartialserver binds pageSize (verified); contract docs still say page_size
REQ-011Nurse credential_details endpoint + isRequiredMissingno such route/command; step DTO lacks isRequired
REQ-012Search row name/avatar/distance + GET nurses/{id}/profileMissingDTO ids-only; no profile action on NursesController
REQ-013variantPrice on BookingRequestDtoMissingDTO has unit without price
REQ-014variantLabel/patientAge on list itemMissinglist DTO omits both
REQ-015Status 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). S–M 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). S–M 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 f9–f15 will hit

+

Frontend phases f0–f8 are built (reports exist); f9–f15 are specs only. Reconciling their declared consumption against the shipped backend:

+
Upcoming phaseConsumesBackend realityVerdict
f9 checkout/cardb10 payments.md + b11 invoiceendpoints 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 statusb11 refunds-invoices.mdGET refunds/{id}/status + GET invoices/{booking_id} shipped (Controllers/V1/RefundsController, InvoicesController)Done (verify shapes when f10 runs)
f11 BNPLb12 bnpl.mdfull eligibility→initiate→status surface shipped (CheckoutBnplController)Done (verify shapes)
f12 nurse earningsb13 payouts.mdnurse_payouts/history + admin console shipped (NursePayoutsController, AdminPayoutsController)Done (verify shapes)
f13 reviews/care recordsb14 reviews-records.mdsubmit/list/tags/moderation + care records shipped (5 controllers)Done (verify shapes)
f14 tickets + notificationsb15 + b1 notificationstickets 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 unfoundedDone
f15 admin/partner consolesadmin endpoints across b1/b6/b11/b13/b14/b15all 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.
+
\ No newline at end of file diff --git a/dev/post-phase/server/frontend-backend-gaps.md b/dev/post-phase/server/frontend-backend-gaps.md new file mode 100644 index 0000000..f0de296 --- /dev/null +++ b/dev/post-phase/server/frontend-backend-gaps.md @@ -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 (f0–f8), 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 (b0–b15), 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 f9–f15 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(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). S–M 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`). S–M 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 f9–f15 will hit + +Frontend phases f0–f8 are built (reports exist); **f9–f15 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. diff --git a/dev/post-phase/server/index.html b/dev/post-phase/server/index.html new file mode 100644 index 0000000..c83a0e4 --- /dev/null +++ b/dev/post-phase/server/index.html @@ -0,0 +1,80 @@ + + + + + +Post-phase server audit — 2026-07-10 + + +
+ +

Post-phase server audit — 2026-07-10

Generated from the canonical Markdown — do not hand-edit. Audit date 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):

+
DeliverableWhat it answers
post-phase-backend-plan.md · htmlWhat 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 · htmlREQ-001…015 reconciled against the shipped contract + code: 2 done, 1 doc-fix, 12 missing; plus what unbuilt f9–f15 will hit, and the unblock priority
runtime-services.md · htmlThe 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.

+
\ No newline at end of file diff --git a/dev/post-phase/server/post-phase-backend-plan.html b/dev/post-phase/server/post-phase-backend-plan.html new file mode 100644 index 0000000..4b8300c --- /dev/null +++ b/dev/post-phase/server/post-phase-backend-plan.html @@ -0,0 +1,182 @@ + + + + + +Post-development backend plan — fixes & improvements + + +
+ +

Post-development backend plan — fixes & improvements

Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.

+

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 (the REQ-by-REQ contract reconciliation) and 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.

+
BucketThemeBlocking what?
post-phase-1Security & config hygieneAny non-local deployment
post-phase-2Money-path correctness completionLedger ⇄ bank reconciliation
post-phase-3Frontend-unblock contract batch11 of 12 client domains are still mock-primary
post-phase-4Scheduling, locking & multi-instance readinessUnattended operation; >1 API instance
post-phase-5Identity & trust rails go realReal nurses onboarding (OTP, KYC, docs)
post-phase-6Money rails go realReal payments, payouts, tax
post-phase-7Observability, audit & ops hardeningProduction diagnosability
post-phase-8Scale & laterSearch 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:103server/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: low–medium (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: S–M · 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: S–M · 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. 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 (S–M): 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 (S–M): 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: M–L · 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: S–M · 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 ISmsSenderlaunch-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: M–L (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 39–41: 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: M–L (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: M–L (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: S–M · 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: S–M · 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: S–M · 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 feederSqlNurseSearch 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 pipelineIAnalyticsSink 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 channelsInAppNotificationDispatcher 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 tablesorganizations, 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 f9–f15)
+                           │
+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).

+
\ No newline at end of file diff --git a/dev/post-phase/server/post-phase-backend-plan.md b/dev/post-phase/server/post-phase-backend-plan.md new file mode 100644 index 0000000..7e2f655 --- /dev/null +++ b/dev/post-phase/server/post-phase-backend-plan.md @@ -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:** low–medium (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:** S–M · **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:** S–M · **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 (S–M):** 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 (S–M):** 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:** M–L · **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:** S–M · **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:** M–L (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 39–41: 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:** M–L (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:** M–L (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:** S–M · **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:** S–M · **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:** S–M · **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 f9–f15) + │ +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)**. diff --git a/dev/post-phase/server/runtime-services.html b/dev/post-phase/server/runtime-services.html new file mode 100644 index 0000000..d809f8b --- /dev/null +++ b/dev/post-phase/server/runtime-services.html @@ -0,0 +1,223 @@ + + + + + +Runtime services — deployment topology + + +
+ +

Runtime services — deployment topology

Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.

+

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

+
#ServicePurposeDepends via (seam / config)MVP?Registry row
1SQL Server (app DB Baya)System of record — 12 schemas (usr ops geo catalog verif search booking payments payouts reviews messaging partner)EF Core; ConnectionStrings:SqlServerRequired now
2SQL Server (log DB Baya_Logs)Serilog sink in deployed envs (Warning+, auto-created log.LogEvents)ConnectionStrings:logDbRequired now (deployed)
3Reverse proxy / TLS (nginx·caddy·traefik)TLS termination, HTTP/1.1+2, forwarded headersKestrel config; JWE bearerRequired now
4Prometheus (+ Grafana)Scrapes /metrics; health forwarded to gaugesUseMetricServer + OTel exporterRecommended now
5RedisICacheService + IDistributedLock (money-path mutex)Seams:* (keys TBD; none today)Before >1 API instancerows 14, 42
6MinIO / S3 / ArvanCloudIObjectStorage — verification docs, avatars (REQ-006), invoice PDFsSeams:ObjectStorage:*Before real verificationrow 13
7Job scheduler (Hangfire/Quartz, in-app on SQL)The deferred crons: payout batch, expiry scan, no-show, Moadian pollhosted services (no interface exists)Before unattended opsrow 26
8SMS gateway (Kavenegar·Ghasedak·SMS.ir)ISmsSender — OTP delivery (login is impossible without it)Seams:Sms:* (to be added)Launch-criticalrow 12
9PSP / IPG + Shaparak (ZarinPal·Sadad·Vandar·Jibit)IPaymentProvider + IWebhookVerifier + ISettlementSplitProvider (تسهیم)encrypted payment_gateways.config_jsonReal paymentsrows 39–41
10BNPL providers (SnappPay·Digipay)IBnplProvider / IBnplProviderResolver / ICurrencyNormalizerSeams:Bnpl:*, Seams:Currency:*, gateway configOptional at launchrows 46–47
11Bank-transfer rail (Jibit·Vandar·Sadad payout API → PAYA/SATNA)IBankTransferProvider — weekly nurse payoutsSeams:BankTransfer:*Real payoutsrow 23
12سامانه مودیان (tax e-invoicing)IMoadianClient — legal invoice registrationSeams:Moadian:* + signing certLegal — soon after launchrow 45
13KYC bridge vendor (Finnotech-class)IShahkarVerifier + IIdentityKycProvider + IBankAccountOwnershipVerifierSeams:Shahkar:*, Seams:IdentityKyc:*, Seams:BankOwnership:*Real verification + payout gaterows 27, 28, 30
14Geocoding (Neshan)IGeocoder — address → coordinates (EVV distance)Seams:Geocoding:*With real EVVrow 31
15Review-moderation classifier (LLM/API)IReviewModerationService — AI pre-screenSeams:ReviewModeration:*Optional (human queue is default)row 33
16MoH / INO / eNamadICredentialVerifier + ILicenseVerificationServicemanual admin process; no public B2B API existsSeams:LicenseVerification:*Manual = the MVP designrows 29, 50
17ElasticsearchINurseSearch alt backend + outbox feederSearch:Backend (non-sql throws today)Not MVP — SQL search is realrows 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.

+
+ + + + + +Next.js clientHTTPS · JSON (camelCase) +Prometheus (+ Grafana)scrapes /metrics + health + + +API HOST · Baya.Web.Api (:5002) +ASP.NET Core APIREST /api/v1 · Swagger/metrics · /HealthCheck · gRPC +In-proc interval jobsexpiry 1min · retention 24h + +18 vendor seams resolve +in-process (mocks) today — +dashed edges = the wire to build + + +DATA & PLATFORM INFRA +SQL Server «Baya»12 schemas · system of record +SQL Server «Baya_Logs»Serilog sink (deployed envs) +Rediscache + dist. lock — in-proc now +MinIO / S3object storage — local disk now + + +MONEY RAILS (ALL MOCKED TODAY) +PSP / IPG + Shaparakcapture · webhook · تسهیم +BNPLSnappPay / Digipay +PAYA / SATNA railweekly nurse payouts +سامانه مودیانtax e-invoicing + + +TRUST & IDENTITY RAILS (ALL MOCKED TODAY) +SMS gateway (OTP)launch-critical — logs only now +KYC bridgeShahkar · e-KYC · استعلام شبا +Neshan geocodingEVV distance accuracy +Review moderationoptional — human queue default +MoH / INO / eNamadmanual admin review — no public API exists + +HTTPS/JSON +scrape +EF Core · Serilog +cache · lock · files +capture · payouts · invoices +webhooks / callbacks +OTP · استعلام · geocode · moderation + + +LEGEND +live today +mocked seam — to build +service to provision +
+
Mermaid source (canonical, in the .md)
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

+

1–2 · 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:logDbrotate + 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.cspdf_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 3–4).
+

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:Backendsql 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.jsonappsettings.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.
+
\ No newline at end of file diff --git a/dev/post-phase/server/runtime-services.md b/dev/post-phase/server/runtime-services.md new file mode 100644 index 0000000..508e7ae --- /dev/null +++ b/dev/post-phase/server/runtime-services.md @@ -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 39–41 | +| 10 | **BNPL providers** (SnappPay·Digipay) | `IBnplProvider` / `IBnplProviderResolver` / `ICurrencyNormalizer` | `Seams:Bnpl:*`, `Seams:Currency:*`, gateway config | Optional at launch | rows 46–47 | +| 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 + +### 1–2 · 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 3–4). + +### 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. diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 9853ece..bb5a3f2 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,41 @@ for awareness. - **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 - **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 + diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 39066dc..2c76743 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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), but worth locking before f9/f13 consume the same shapes. - **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 diff --git a/dev/shared-working-context/reports/frontend-phase-9-report.md b/dev/shared-working-context/reports/frontend-phase-9-report.md new file mode 100644 index 0000000..00ad4cf --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-9-report.md @@ -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`. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index db7f557..96e668b 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 | 🟡 | | `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 | 🟡 | +| `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 | 🟡 |