frontend phase 9
This commit is contained in:
+12
-2
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "با شماره موبایل خود وارد شوید",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { useInvoice } from '@/services/payment';
|
||||
import type { MoadianStatus } from '@/services/payment/types';
|
||||
|
||||
/** The printable region — everything else is hidden by the print rules below. */
|
||||
const PRINT_AREA_CLASS = 'invoice-print-area';
|
||||
|
||||
// مودیان registration is a backend concern — surfaced strictly read-only (contract exposes the state).
|
||||
const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
submitted: 'info',
|
||||
registered: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
|
||||
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product
|
||||
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the
|
||||
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt
|
||||
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util — no float math;
|
||||
* the service line is the exact integer remainder of served amounts (gross − commission − VAT).
|
||||
*/
|
||||
export default function BookingInvoicePage() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const bookingId = Number(params.id);
|
||||
const validId = Number.isInteger(bookingId) && bookingId > 0;
|
||||
|
||||
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
|
||||
|
||||
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
|
||||
if (!validId) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={44} color="var(--bal-error)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('invalid_link_body')}
|
||||
</Typography>
|
||||
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="text" width="40%" height={36} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
const notIssued = error instanceof ApiError && error.status === 404;
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{notIssued ? t('invoice_not_issued_title') : t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
|
||||
</Typography>
|
||||
{notIssued ? (
|
||||
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton variant="contained" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// The receipt must print on paper colors: the tokens are attribute-driven, so a dark-scheme user
|
||||
// would otherwise print cream-on-dark. Flip to the light tokens for the print dialog and restore
|
||||
// after it closes (afterprint) — no hard-coded colors, the same token system does the work.
|
||||
const handlePrint = () => {
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-mui-color-scheme');
|
||||
if (previous === 'dark') {
|
||||
const restore = () => {
|
||||
root.setAttribute('data-mui-color-scheme', previous);
|
||||
window.removeEventListener('afterprint', restore);
|
||||
};
|
||||
window.addEventListener('afterprint', restore);
|
||||
root.setAttribute('data-mui-color-scheme', 'light');
|
||||
}
|
||||
window.print();
|
||||
};
|
||||
|
||||
// Display rows from served amounts only, integer-safe: the service line is the exact remainder, so
|
||||
// service + commission + VAT reconciles to the gross total by construction.
|
||||
const serviceIrr = (
|
||||
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
|
||||
).toString();
|
||||
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
|
||||
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(invoice.vatRate);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'@media print': {
|
||||
'body *': { visibility: 'hidden' },
|
||||
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
|
||||
[`.${PRINT_AREA_CLASS}`]: { position: 'absolute', top: 0, insetInlineStart: 0, width: '100%' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
className={PRINT_AREA_CLASS}
|
||||
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('invoice_title')}
|
||||
</Typography>
|
||||
{/* The issuer line uses the product spelling «بالینیار» — fa `common.brand` currently reads
|
||||
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
|
||||
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{t('issuer_platform')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
|
||||
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
|
||||
</Stack>
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'service_cost', label: t('row_service_cost'), amountIrr: serviceIrr },
|
||||
{ key: 'commission', label: t('row_commission'), amountIrr: invoice.platformCommissionIrr },
|
||||
{
|
||||
key: 'vat',
|
||||
label: `${t('invoice_vat_on_commission')} (${vatPercent})`,
|
||||
amountIrr: invoice.vatIrr,
|
||||
},
|
||||
]}
|
||||
totalLabel={t('row_total')}
|
||||
totalAmountIrr={invoice.grossIrr}
|
||||
/>
|
||||
|
||||
{invoice.moadianStatus ? (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('moadian_label')}
|
||||
</Typography>
|
||||
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{invoice.pdfUrl ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab sx={{ m: 0 }}>
|
||||
{t('download_invoice')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint} sx={{ m: 0 }}>
|
||||
{t('print_invoice')}
|
||||
</AppButton>
|
||||
)}
|
||||
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }} dir={ltr ? 'ltr' : undefined}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { bookingInvoicePath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCheckoutSummary } from '@/services/payment';
|
||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
|
||||
/**
|
||||
* Post-payment confirmation — the booking is now **confirmed** (flipped by cache invalidation on the
|
||||
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
|
||||
* the invoice («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
|
||||
* to the bookings list and the invoice link is hidden — the invoice route needs the booking id.
|
||||
*/
|
||||
export default function CheckoutConfirmationPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<ConfirmationScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
|
||||
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
|
||||
|
||||
const { data: summary } = useCheckoutSummary(
|
||||
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
|
||||
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('confirm_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('confirm_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{summary ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_paid_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.nurseName}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1, width: '100%' }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
|
||||
}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
{bookingId != null ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="document"
|
||||
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('download_invoice')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
CHECKOUT_QUERY_OUTCOME,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/payment/constants';
|
||||
import type { GatewayReturnOutcome } from '@/services/payment/types';
|
||||
|
||||
/**
|
||||
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
|
||||
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
|
||||
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
|
||||
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
|
||||
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
|
||||
*/
|
||||
export default function MockGatewayPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<MockGatewayScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MockGatewayScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
|
||||
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
|
||||
|
||||
const returnWith = (outcome: GatewayReturnOutcome) => {
|
||||
const query = new URLSearchParams({
|
||||
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
|
||||
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
|
||||
[CHECKOUT_QUERY_OUTCOME]: outcome,
|
||||
});
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('gateway_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('gateway_hint')}
|
||||
</Typography>
|
||||
{transactionId ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
|
||||
{t('gateway_reference_label')}:{' '}
|
||||
<Box component="span" dir="ltr">
|
||||
#{transactionId}
|
||||
</Box>
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
|
||||
{t('gateway_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
|
||||
{t('gateway_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,299 @@
|
||||
'use client';
|
||||
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 (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<CheckoutDeferred />
|
||||
<CheckoutScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
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 <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />;
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const validId = Number.isInteger(requestId) && requestId > 0;
|
||||
const { data: summary, isLoading, isError, refetch } = useCheckoutSummary(validId ? requestId : undefined);
|
||||
const initiate = useInitiatePayment();
|
||||
|
||||
// One idempotency key per payment ATTEMPT: created lazily on the first tap and reused across retries
|
||||
// of the same attempt (double-tap, transient network error). A new attempt — after a failed outcome the
|
||||
// user comes back to a fresh mount of this screen — gets a new key.
|
||||
const attemptKeyRef = useRef<string | null>(null);
|
||||
|
||||
// A malformed/missing request_id can never load — navigation, not a retry that would fire
|
||||
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
|
||||
if (!validId) {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="error"
|
||||
tone="var(--bal-error)"
|
||||
title={t('error_title')}
|
||||
body={t('invalid_link_body')}
|
||||
ctaLabel={tb('bd_my_bookings')}
|
||||
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="error"
|
||||
tone="var(--bal-error)"
|
||||
title={t('error_title')}
|
||||
body={t('error_body')}
|
||||
ctaLabel={tc('retry')}
|
||||
onCta={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading || !summary) return <CheckoutSkeleton />;
|
||||
|
||||
const returnUrl = (extra?: Record<string, string>) => {
|
||||
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(requestId), ...extra });
|
||||
return `/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`;
|
||||
};
|
||||
|
||||
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
|
||||
if (summary.requestStatus === 'converted') {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="verified"
|
||||
tone="var(--bal-success)"
|
||||
title={t('already_paid_title')}
|
||||
body={t('already_paid_body')}
|
||||
ctaLabel={tb('converted_cta')}
|
||||
onCta={() => router.replace(returnUrl())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (summary.requestStatus !== 'accepted_awaiting_payment') {
|
||||
const expired = summary.requestStatus === 'payment_deadline_expired';
|
||||
return (
|
||||
<MessageCard
|
||||
icon="pending"
|
||||
tone="var(--bal-warning)"
|
||||
title={expired ? t('window_expired_title') : t('not_payable_title')}
|
||||
body={expired ? t('window_expired_body') : undefined}
|
||||
ctaLabel={t('back_to_request')}
|
||||
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePay = () => {
|
||||
attemptKeyRef.current ??= crypto.randomUUID();
|
||||
initiate.mutate(
|
||||
{ bookingRequestId: requestId, idempotencyKey: attemptKeyRef.current },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (!result.redirectUrl) {
|
||||
// No gateway hop to make — read the outcome directly.
|
||||
router.push(returnUrl({ [CHECKOUT_QUERY_TRANSACTION_ID]: String(result.transactionId) }));
|
||||
return;
|
||||
}
|
||||
if (/^https?:\/\//i.test(result.redirectUrl)) {
|
||||
// The real PSP page — a full navigation, outside the app router.
|
||||
window.location.assign(result.redirectUrl);
|
||||
return;
|
||||
}
|
||||
router.push(`/${locale}${result.redirectUrl}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// "Already paid / already in progress / window lapsed" — benign convergence, never a toast:
|
||||
// the return surface reads the actual outcome and routes accordingly.
|
||||
router.replace(returnUrl());
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const busy = initiate.isPending || initiate.isSuccess;
|
||||
// ApiError.message is a raw server/network string, never localized — always show the i18n copy.
|
||||
const inlineError =
|
||||
initiate.error && !(initiate.error instanceof ApiError && initiate.error.status === 409)
|
||||
? t('initiate_failed')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
|
||||
<StatusChip status="verified" label={tb('accepted_badge')} />
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('title_checkout')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<EngagementSummary summary={summary} locale={locale} />
|
||||
|
||||
{summary.paymentDeadlineAt ? (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={summary.paymentDeadlineAt}
|
||||
label={tb('payment_countdown_label')}
|
||||
elapsedText={tb('payment_elapsed')}
|
||||
urgent
|
||||
onElapsed={() => refetch()}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{
|
||||
key: 'service_cost',
|
||||
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
|
||||
// quantity that always matches the charged gross (variant price × session count).
|
||||
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
|
||||
amountIrr: summary.serviceCostIrr,
|
||||
},
|
||||
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
|
||||
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
|
||||
]}
|
||||
totalLabel={t('row_total')}
|
||||
totalAmountIrr={summary.totalIrr}
|
||||
/>
|
||||
|
||||
<EscrowNotice />
|
||||
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
onClick={handlePay}
|
||||
sx={{ m: 0, py: 1.25 }}
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
{/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */}
|
||||
<AppButton variant="outlined" color="primary" disabled={!BNPL_ENABLED} sx={{ m: 0 }}>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
{!BNPL_ENABLED ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{tc('coming_soon')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
|
||||
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
|
||||
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
|
||||
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{summary.variantLabel}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.nurseName} · {summary.patientName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} – {timeFmt.format(end)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
ctaLabel,
|
||||
onCta,
|
||||
}: {
|
||||
icon: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
ctaLabel: string;
|
||||
onCta: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<Skeleton variant="rounded" width={140} height={24} />
|
||||
<Skeleton variant="text" width="50%" height={32} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
<Skeleton variant="rounded" height={48} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
|
||||
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
|
||||
import {
|
||||
CHECKOUT_QUERY_BOOKING_ID,
|
||||
CHECKOUT_QUERY_OUTCOME,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/payment/constants';
|
||||
|
||||
/**
|
||||
* Return-from-gateway surface — drives the tail of the checkout state machine: report the return
|
||||
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
|
||||
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" — pending is
|
||||
* normal, reflected calmly) until a terminal outcome: succeeded → invalidate the booking/request caches
|
||||
* and hand off to the confirmation screen; failed → a retry affordance (a fresh C6 mount = a new attempt
|
||||
* with a NEW idempotency key); window lapsed → back to the request's terminal card.
|
||||
*/
|
||||
export default function CheckoutReturnPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<ReturnScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ReturnScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const validId = Number.isInteger(requestId) && requestId > 0;
|
||||
const transactionIdParam = params.get(CHECKOUT_QUERY_TRANSACTION_ID);
|
||||
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
|
||||
const gatewayOutcome = params.get(CHECKOUT_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
|
||||
|
||||
const confirm = useConfirmGatewayReturn();
|
||||
const { mutate: confirmMutate } = confirm;
|
||||
|
||||
// Fire the return report exactly once per mount — a browser refresh on this page replays it, which the
|
||||
// backend/mock treat as a duplicate (idempotent convergence, per the webhook-dedup rule).
|
||||
const confirmFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (confirmFiredRef.current || !validId) return;
|
||||
confirmFiredRef.current = true;
|
||||
confirmMutate({ bookingRequestId: requestId, transactionId, outcome: gatewayOutcome });
|
||||
}, [confirmMutate, validId, requestId, transactionId, gatewayOutcome]);
|
||||
|
||||
// The poll takes over once the return report settles (its result is primed into the outcome key).
|
||||
const confirmSettled = confirm.isSuccess || confirm.isError;
|
||||
const outcomeQuery = usePaymentOutcome(validId ? requestId : undefined, {
|
||||
enabled: confirmSettled,
|
||||
});
|
||||
// Trust the outcome cache only after THIS mount's report settled — a previous attempt's failed outcome
|
||||
// survives in the cache and would otherwise flash a false "payment failed" (with a live retry CTA)
|
||||
// while the current attempt's capture is still in flight.
|
||||
const outcome = confirmSettled ? outcomeQuery.data : undefined;
|
||||
|
||||
const succeeded = outcome?.transactionStatus === 'succeeded' || outcome?.requestStatus === 'converted';
|
||||
const windowExpired = outcome?.requestStatus === 'payment_deadline_expired';
|
||||
const failed = !succeeded && !windowExpired && outcome?.transactionStatus === 'failed';
|
||||
|
||||
// Hand off to the confirmation exactly once. The confirm mutation already invalidated on an immediate
|
||||
// success; a success that arrived later through the poll invalidates here instead (never twice).
|
||||
const navigatedRef = useRef(false);
|
||||
const confirmSawSuccess = confirm.data?.transactionStatus === 'succeeded';
|
||||
useEffect(() => {
|
||||
if (!succeeded || navigatedRef.current || !outcome) return;
|
||||
navigatedRef.current = true;
|
||||
if (!confirmSawSuccess) {
|
||||
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
|
||||
}
|
||||
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(outcome.bookingRequestId) });
|
||||
if (outcome.bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(outcome.bookingId));
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
|
||||
}, [succeeded, outcome, confirmSawSuccess, queryClient, router, locale]);
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (windowExpired) {
|
||||
return (
|
||||
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
|
||||
<PaymentStatusBadge status="failed" />
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
|
||||
}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('retry_payment')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="text"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
|
||||
return (
|
||||
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}>
|
||||
<CircularProgress color="primary" size="2.5rem" />
|
||||
<PaymentStatusBadge status="pending" />
|
||||
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
|
||||
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()} sx={{ m: 0 }}>
|
||||
{t('check_again')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StateCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
icon: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,12 @@ export default function BookingRequestStatusPage() {
|
||||
tone="var(--bal-success)"
|
||||
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}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import faMessages from '../../../messages/fa.json';
|
||||
|
||||
// next-intl mocked to read the REAL fa message file, so the test pins the product-mandated escrow copy
|
||||
// as it actually ships — a rewording in fa.json (or a broken key) fails here as a conscious change.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string) => {
|
||||
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
|
||||
return messages[namespace][key];
|
||||
},
|
||||
}));
|
||||
|
||||
import EscrowNotice from './EscrowNotice';
|
||||
|
||||
function renderNotice() {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<EscrowNotice />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EscrowNotice/> component', () => {
|
||||
it('renders the mandated escrow copy verbatim from fa.json', () => {
|
||||
renderNotice();
|
||||
expect(
|
||||
screen.getByText('مبلغ بهصورت امانی نزد بالینیار میماند و پس از پایان ویزیت آزاد میشود'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(faMessages.payment.escrow_notice)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an info (trust) surface, never an error tone', () => {
|
||||
renderNotice();
|
||||
const alert = screen.getByTestId('escrow-notice');
|
||||
expect(alert.className).toContain('MuiAlert-colorInfo');
|
||||
expect(alert.className).not.toContain('MuiAlert-colorError');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
/**
|
||||
* The escrow trust callout — product-mandated copy («مبلغ بهصورت امانی نزد بالینیار میماند و پس از
|
||||
* پایان ویزیت آزاد میشود»), rendered **verbatim in fa** as an info/trust surface (teal `--bal-info`
|
||||
* tokens, a lock — never an error tone). It is *why* the family pays on-platform, so f10/f11 must reuse
|
||||
* this exact component rather than re-writing the message. Self-translating (the copy is fixed), no props.
|
||||
* @component EscrowNotice
|
||||
*/
|
||||
const EscrowNotice: FunctionComponent = () => {
|
||||
const t = useTranslations('payment');
|
||||
return (
|
||||
<AppAlert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
icon={<AppIcon icon="lock" size={20} color="var(--bal-primary)" />}
|
||||
data-testid="escrow-notice"
|
||||
sx={{
|
||||
marginY: 0,
|
||||
// --bal-primary, not --bal-info: the info token is an alert *background* color and is too dark
|
||||
// to read as text on the dark scheme; primary is the same deep teal in light and lifts in dark.
|
||||
borderColor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary)',
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('escrow_notice')}
|
||||
</AppAlert>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscrowNotice;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EscrowNotice';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
// next-intl mocked to echo keys — each status must resolve its own pstatus_* label key.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
|
||||
function renderBadge(status: PaymentTransactionStatus) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<PaymentStatusBadge status={status} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Every b10 wire status → its label key + the semantic chip kind it must map to.
|
||||
const CASES: Array<{ status: PaymentTransactionStatus; kind: string }> = [
|
||||
{ status: 'pending', kind: 'pending' },
|
||||
{ status: 'succeeded', kind: 'verified' },
|
||||
{ status: 'failed', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<PaymentStatusBadge/> component', () => {
|
||||
it.each(CASES)('renders the $status status with its label key and chip kind', ({ status, kind }) => {
|
||||
const { container } = renderBadge(status);
|
||||
expect(screen.getByText(`pstatus_${status}`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { ChipProps } from '@mui/material/Chip';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
// The full b10 enum → chip kind; a status missing here fails the type check, so a contract enum change
|
||||
// surfaces at build time instead of rendering an unmapped chip.
|
||||
const STATUS_KIND: Record<PaymentTransactionStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
succeeded: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
export interface PaymentStatusBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
status: PaymentTransactionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment-transaction status chip: maps the b10 wire code to a `--bal-*` semantic StatusChip variant and
|
||||
* an i18n label (`payment.pstatus_*` — labels are keys off the code, never derived from it). Shared by
|
||||
* the checkout return surface now and the f10 refund / f11 BNPL surfaces later.
|
||||
* @component PaymentStatusBadge
|
||||
*/
|
||||
const PaymentStatusBadge: FunctionComponent<PaymentStatusBadgeProps> = ({ status, ...rest }) => {
|
||||
const t = useTranslations('payment');
|
||||
return <StatusChip status={STATUS_KIND[status]} label={t(`pstatus_${status}`)} {...rest} />;
|
||||
};
|
||||
|
||||
export default PaymentStatusBadge;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PaymentStatusBadge';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import PriceBreakdown, { PriceBreakdownProps } from './PriceBreakdown';
|
||||
|
||||
const ComponentToTest: FunctionComponent<PriceBreakdownProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<PriceBreakdown {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Served-shaped figures: service + commission + VAT = total (the phase's reconciliation rule).
|
||||
const ROWS = [
|
||||
{ key: 'service_cost', label: 'Service cost', amountIrr: '39060000' },
|
||||
{ key: 'commission', label: 'Balinyaar fee', amountIrr: '5400000' },
|
||||
{ key: 'vat', label: 'VAT', amountIrr: '540000' },
|
||||
];
|
||||
const TOTAL = '45000000';
|
||||
|
||||
describe('<PriceBreakdown/> component', () => {
|
||||
it('renders every row label with its Toman-formatted amount', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
for (const row of ROWS) {
|
||||
expect(screen.getByText(row.label)).toBeInTheDocument();
|
||||
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a total equal to the integer sum of the served rows', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
const sum = ROWS.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
expect(sum.toString()).toBe(TOTAL);
|
||||
expect(screen.getByText(new RegExp(formatIrrToToman(TOTAL, 'en')))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes each row via a data attribute', () => {
|
||||
const { container } = render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
expect(container.querySelector('[data-row="service_cost"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns loudly in dev when the rows do not reconcile to the total', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr="45000001" />);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
export interface PriceBreakdownRow {
|
||||
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
|
||||
key: string;
|
||||
/** Display label — already translated by the caller (labels are i18n keys, never derived from codes). */
|
||||
label: string;
|
||||
/** IRR digit-string, straight off the wire. */
|
||||
amountIrr: string;
|
||||
}
|
||||
|
||||
export interface PriceBreakdownProps {
|
||||
rows: PriceBreakdownRow[];
|
||||
totalLabel: string;
|
||||
/** IRR digit-string. Must equal the integer sum of `rows` — see the reconciliation guard below. */
|
||||
totalAmountIrr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconciling money breakdown (C6 checkout, invoice; f10/f11 reuse it for refunds/BNPL). Rows and
|
||||
* total are **served amounts** — this component only formats (BigInt-safe, Toman display via the money
|
||||
* util) and never computes a figure. The one thing it enforces is the phase's hard rule: the displayed
|
||||
* rows must sum to the displayed total. A mismatch is a data bug upstream, so it is surfaced loudly in
|
||||
* dev (console.error) rather than silently rendered.
|
||||
* @component PriceBreakdown
|
||||
*/
|
||||
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
|
||||
const locale = useLocale();
|
||||
const tc = useTranslations('common');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
if (sum !== parseIrr(totalAmountIrr)) {
|
||||
console.error(
|
||||
`PriceBreakdown: rows sum to ${sum} but total is ${totalAmountIrr} — a breakdown must reconcile to the rial.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{rows.map((row) => (
|
||||
<Stack key={row.key} data-row={row.key} direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
<Divider />
|
||||
<Stack data-row="total" direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{totalLabel}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceBreakdown;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PriceBreakdown';
|
||||
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<BookingRequestDto, 'variantPrice'>;
|
||||
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
|
||||
|
||||
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */
|
||||
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
|
||||
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
|
||||
return { ...wire, variantPrice: null };
|
||||
return { ...wire, variantPrice: null, bookingId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ function seedRow(overrides: Partial<BookingRequestDto>): 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import type { BookingRequestDto } from '@/services/bookingRequests/types';
|
||||
import type {
|
||||
CheckoutSummaryDto,
|
||||
ConfirmGatewayReturnInput,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentResult,
|
||||
InvoiceDto,
|
||||
PaymentApi,
|
||||
PaymentOutcomeDto,
|
||||
} from '../types';
|
||||
|
||||
const BOOKINGS = '/api/v1/bookings';
|
||||
const BOOKING_REQUESTS = '/api/v1/booking_requests';
|
||||
const INVOICES = '/api/v1/invoices';
|
||||
|
||||
/** The header b10's initiate (and b12's BNPL initiate) read the per-attempt idempotency key from. */
|
||||
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `PaymentApi` seam. `initiatePayment` and `getInvoice` map the
|
||||
* published b10/b11 contracts 1:1. The other two calls cover contract gaps the frontend filed:
|
||||
* - `getCheckoutSummary` targets the **REQ-016 proposed route** (no checkout-summary endpoint exists;
|
||||
* the b8 request DTO is money-free, and the client must never derive commission/VAT itself).
|
||||
* - `getPaymentOutcome`/`confirmGatewayReturn` poll the existing `booking_requests/get/{id}` and map its
|
||||
* status (`converted` → succeeded, `payment_deadline_expired` → failed, else pending) — the b10
|
||||
* contract has no client transaction read (REQ-017); the server verifies captures inside the webhook
|
||||
* handler, so on return from the PSP there is nothing to "verify" client-side, only an outcome to read.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_PAYMENT_MOCK = true`) — see `constants.ts` for why.
|
||||
*/
|
||||
export const paymentClientApi: PaymentApi = {
|
||||
getCheckoutSummary: async (bookingRequestId: number) =>
|
||||
// REQ-016 proposed slug (action-style, mirrors b8's `[controller]/[action]` routing). 404s until the
|
||||
// backend delivers it — which is why the domain stays mock-primary.
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CheckoutSummaryDto>>(
|
||||
`${BOOKING_REQUESTS}/checkout_summary/${bookingRequestId}`,
|
||||
),
|
||||
),
|
||||
|
||||
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<InitiatePaymentResult>>(`${BOOKINGS}/${bookingRequestId}/payments`, {
|
||||
method: 'POST',
|
||||
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||
}),
|
||||
),
|
||||
|
||||
confirmGatewayReturn: async ({ bookingRequestId }: ConfirmGatewayReturnInput) =>
|
||||
// The PSP hit the webhook before redirecting back — there is nothing to submit; read the outcome.
|
||||
paymentClientApi.getPaymentOutcome(bookingRequestId),
|
||||
|
||||
getPaymentOutcome: async (bookingRequestId: number) => {
|
||||
const request = unwrap(
|
||||
await clientFetch<ApiEnvelope<Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>>>(
|
||||
`${BOOKING_REQUESTS}/get/${bookingRequestId}`,
|
||||
),
|
||||
);
|
||||
const outcome: PaymentOutcomeDto = {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
transactionStatus:
|
||||
request.status === 'converted'
|
||||
? 'succeeded'
|
||||
: request.status === 'payment_deadline_expired'
|
||||
? 'failed'
|
||||
: 'pending',
|
||||
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
|
||||
// to the bookings list until the field lands.
|
||||
bookingId: null,
|
||||
};
|
||||
return outcome;
|
||||
},
|
||||
|
||||
getInvoice: async (bookingId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,267 @@
|
||||
import { multiplyIrr, parseIrr, sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
bookingRequestsMockApi,
|
||||
mockMarkBookingRequestConverted,
|
||||
} from '@/services/bookingRequests/apis/mockApi';
|
||||
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
|
||||
import type { BookingRequestDto } from '@/services/bookingRequests/types';
|
||||
import {
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
MOCK_PLATFORM_FEE_RATE,
|
||||
MOCK_VAT_RATE,
|
||||
} from '../constants';
|
||||
import type {
|
||||
CheckoutSummaryDto,
|
||||
ConfirmGatewayReturnInput,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentResult,
|
||||
InvoiceDto,
|
||||
PaymentApi,
|
||||
PaymentOutcomeDto,
|
||||
PaymentTransactionStatus,
|
||||
} from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
/** A request is a single visit until multi-session requests exist (b8 carries one date/time window). */
|
||||
const SESSION_COUNT = 1;
|
||||
|
||||
// Integer-only rate math: rates as parts-per-10000 so the money path never touches a float.
|
||||
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
|
||||
const RATE_SCALE = BigInt(10_000);
|
||||
const FEE_RATE_PPM = BigInt(Math.round(MOCK_PLATFORM_FEE_RATE * Number(RATE_SCALE)));
|
||||
const VAT_RATE_PPM = BigInt(Math.round(MOCK_VAT_RATE * Number(RATE_SCALE)));
|
||||
|
||||
/**
|
||||
* The C6 money decomposition, all BigInt (mirrors what REQ-016 asks the server to serve):
|
||||
* commission (net) = gross × feeRate; VAT = commission × vatRate (b11 rule: VAT is computed ON the
|
||||
* commission, the platform's taxable supply); service cost = the remainder (= the nurse payout leg).
|
||||
* Rows reconcile by construction: service + commission + vat = gross, and the b10 three-amount
|
||||
* invariant holds with balinyaarCommission = commission + vat.
|
||||
*/
|
||||
function splitAmounts(grossIrr: string) {
|
||||
const gross = parseIrr(grossIrr);
|
||||
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
|
||||
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
|
||||
const serviceCost = gross - commissionNet - vat;
|
||||
return {
|
||||
grossPriceIrr: gross.toString(),
|
||||
commissionIrr: commissionNet.toString(),
|
||||
vatIrr: vat.toString(),
|
||||
serviceCostIrr: serviceCost.toString(),
|
||||
balinyaarCommissionIrr: (commissionNet + vat).toString(),
|
||||
nursePayoutAmount: serviceCost.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
interface MockTransaction {
|
||||
transactionId: number;
|
||||
bookingRequestId: number;
|
||||
idempotencyKey: string;
|
||||
status: PaymentTransactionStatus;
|
||||
gatewayReferenceCode: string;
|
||||
grossPriceIrr: string;
|
||||
bookingId: number | null;
|
||||
}
|
||||
|
||||
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
|
||||
// checkout, the C5 poll, and the bookings list all observe the same capture.
|
||||
let transactions: MockTransaction[] = [];
|
||||
const invoices: Record<number, InvoiceDto> = {};
|
||||
let nextTransactionId = 42_001;
|
||||
let nextInvoiceId = 101;
|
||||
|
||||
function latestTransactionFor(bookingRequestId: number): MockTransaction | undefined {
|
||||
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
|
||||
}
|
||||
|
||||
function requestGross(request: BookingRequestDto): string {
|
||||
// The frozen gross the server would charge: variant price × session count, never client-supplied.
|
||||
// A request without a price (REQ-013 unmet) must fail loudly — never a reconciling 0-rial checkout.
|
||||
if (request.variantPrice == null) {
|
||||
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
|
||||
}
|
||||
return multiplyIrr(request.variantPrice, SESSION_COUNT);
|
||||
}
|
||||
|
||||
function toOutcome(request: BookingRequestDto, transaction: MockTransaction | undefined): PaymentOutcomeDto {
|
||||
return {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
transactionStatus: transaction?.status ?? null,
|
||||
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The webhook-confirm stand-in: flip the transaction, convert the request, insert the booking, invoice it. */
|
||||
function capture(request: BookingRequestDto, transaction: MockTransaction): PaymentOutcomeDto {
|
||||
const amounts = splitAmounts(transaction.grossPriceIrr);
|
||||
const booking = mockInsertConvertedBooking({
|
||||
bookingRequestId: request.id,
|
||||
nurseId: request.nurseId,
|
||||
nurseName: request.nurseName,
|
||||
patientId: request.patientId,
|
||||
patientName: request.patientName,
|
||||
variantId: request.variantId,
|
||||
variantLabel: request.variantLabel,
|
||||
variantPriceUnit: request.variantPriceUnit,
|
||||
customerAddressId: request.customerAddressId,
|
||||
addressSnapshotJson: JSON.stringify({
|
||||
title: request.addressTitle,
|
||||
city: request.cityNameFa,
|
||||
district: request.districtNameFa,
|
||||
line: request.addressLine,
|
||||
postalCode: request.postalCode,
|
||||
}),
|
||||
grossPriceIrr: amounts.grossPriceIrr,
|
||||
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
|
||||
nursePayoutAmount: amounts.nursePayoutAmount,
|
||||
// The PSP's cut is a platform expense outside the three-amount split; 2% matches the f8 seeds.
|
||||
pspFeeAmount: ((parseIrr(amounts.grossPriceIrr) * BigInt(200)) / RATE_SCALE).toString(),
|
||||
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
|
||||
scheduledDate: request.requestedDate,
|
||||
scheduledTimeStart: request.requestedTimeStart,
|
||||
scheduledTimeEnd: request.requestedTimeEnd,
|
||||
});
|
||||
|
||||
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
|
||||
transaction.status = 'succeeded';
|
||||
transaction.bookingId = booking.id;
|
||||
|
||||
invoices[booking.id] = {
|
||||
id: nextInvoiceId,
|
||||
bookingId: booking.id,
|
||||
invoiceNumber: `INV-${String(nextInvoiceId).padStart(10, '0')}`,
|
||||
issuingEntityType: 'platform',
|
||||
grossIrr: amounts.grossPriceIrr,
|
||||
platformCommissionIrr: amounts.commissionIrr,
|
||||
bnplCommissionIrr: null,
|
||||
vatRate: MOCK_VAT_RATE,
|
||||
vatIrr: amounts.vatIrr,
|
||||
moadianReferenceNumber: null,
|
||||
moadianStatus: 'pending',
|
||||
pdfUrl: null,
|
||||
issuedAt: new Date().toISOString(),
|
||||
};
|
||||
nextInvoiceId += 1;
|
||||
|
||||
return toOutcome(converted, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `PaymentApi` seam — the missing conversion trigger between the f7 and f8
|
||||
* mock stores. It serves the unserved C6 summary (REQ-016), plays the PSP + webhook roles (initiate →
|
||||
* app-relative gateway-harness redirect → capture on the success return), enforces b10's idempotency
|
||||
* semantics (same-key retry reuses the attempt; a repeat initiate after capture is a `409`; a replayed
|
||||
* return is a no-op), and issues the b11-shaped invoice at capture. Swap to the real `clientApi` once
|
||||
* the upstream domains are real and REQ-016/017 land (`USE_PAYMENT_MOCK = false`).
|
||||
*/
|
||||
export const paymentMockApi: PaymentApi = {
|
||||
getCheckoutSummary: async (bookingRequestId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
const amounts = splitAmounts(requestGross(request));
|
||||
const summary: CheckoutSummaryDto = {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
nurseName: request.nurseName,
|
||||
patientName: request.patientName,
|
||||
variantLabel: request.variantLabel,
|
||||
variantPriceUnit: request.variantPriceUnit,
|
||||
sessionCount: SESSION_COUNT,
|
||||
requestedDate: request.requestedDate,
|
||||
requestedTimeStart: request.requestedTimeStart,
|
||||
requestedTimeEnd: request.requestedTimeEnd,
|
||||
paymentDeadlineAt: request.paymentDeadlineAt,
|
||||
serviceCostIrr: amounts.serviceCostIrr,
|
||||
commissionIrr: amounts.commissionIrr,
|
||||
vatIrr: amounts.vatIrr,
|
||||
vatRate: MOCK_VAT_RATE,
|
||||
totalIrr: amounts.grossPriceIrr,
|
||||
grossPriceIrr: amounts.grossPriceIrr,
|
||||
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
|
||||
nursePayoutAmount: amounts.nursePayoutAmount,
|
||||
};
|
||||
return summary;
|
||||
},
|
||||
|
||||
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
|
||||
const existing = latestTransactionFor(bookingRequestId);
|
||||
if (request.status === 'converted' || existing?.status === 'succeeded') {
|
||||
throw new ApiError(409, 'Request already paid', 'already_paid');
|
||||
}
|
||||
if (request.status !== 'accepted_awaiting_payment') {
|
||||
// Covers the lapsed 30-minute window (`payment_deadline_expired`) and every other non-payable state.
|
||||
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
|
||||
}
|
||||
// b10 idempotency: a retried start with the same `Idempotency-Key` reuses the attempt/reference.
|
||||
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'pending') {
|
||||
return toInitiateResult(existing);
|
||||
}
|
||||
|
||||
const transaction: MockTransaction = {
|
||||
transactionId: nextTransactionId++,
|
||||
bookingRequestId,
|
||||
idempotencyKey,
|
||||
status: 'pending',
|
||||
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
|
||||
grossPriceIrr: requestGross(request),
|
||||
bookingId: null,
|
||||
};
|
||||
transactions = [transaction, ...transactions];
|
||||
return toInitiateResult(transaction);
|
||||
},
|
||||
|
||||
confirmGatewayReturn: async ({ bookingRequestId, transactionId, outcome }: ConfirmGatewayReturnInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
const transaction =
|
||||
(transactionId != null ? transactions.find((t) => t.transactionId === transactionId) : undefined) ??
|
||||
latestTransactionFor(bookingRequestId);
|
||||
|
||||
// Replays / double-taps converge (the webhook dedup analogue): if capture already happened, report it.
|
||||
if (request.status === 'converted' || transaction?.status === 'succeeded') {
|
||||
return toOutcome(request, transaction);
|
||||
}
|
||||
if (!transaction) throw new ApiError(404, 'Payment transaction not found', 'not_found');
|
||||
|
||||
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
|
||||
// A declined gateway, or the payment window lapsed while the customer was at the gateway.
|
||||
transaction.status = 'failed';
|
||||
return toOutcome(request, transaction);
|
||||
}
|
||||
return capture(request, transaction);
|
||||
},
|
||||
|
||||
getPaymentOutcome: async (bookingRequestId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
return toOutcome(request, latestTransactionFor(bookingRequestId));
|
||||
},
|
||||
|
||||
getInvoice: async (bookingId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const invoice = invoices[bookingId];
|
||||
// b11: the invoice exists only once issued — a clean 404 until then (the UI shows "not issued yet").
|
||||
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
|
||||
return { ...invoice };
|
||||
},
|
||||
};
|
||||
|
||||
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
|
||||
const query = new URLSearchParams();
|
||||
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
|
||||
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
|
||||
return {
|
||||
transactionId: transaction.transactionId,
|
||||
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
|
||||
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
|
||||
gatewayReferenceCode: transaction.gatewayReferenceCode,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The C6 payload — badge status, nurse/service mini-summary, and the served, reconciling money rows
|
||||
* (service + commission + VAT = total). Short `staleTime`: the price/deadline can change between visits,
|
||||
* and after a capture the summary's `requestStatus` must flip promptly (it is also invalidated by
|
||||
* `invalidateAfterPaymentSuccess`). Enabled only when an id is present.
|
||||
*/
|
||||
export function useCheckoutSummary(bookingRequestId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.summary(bookingRequestId ?? -1),
|
||||
queryFn: () => paymentApi.getCheckoutSummary(bookingRequestId as number),
|
||||
enabled: bookingRequestId != null && bookingRequestId > 0,
|
||||
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { invalidateAfterPaymentSuccess } from '../invalidations';
|
||||
import type { ConfirmGatewayReturnInput } from '../types';
|
||||
|
||||
/**
|
||||
* Report the return from the gateway and read the outcome. On the real path the PSP already confirmed
|
||||
* via the webhook, so this is a read; in the mock it is the capture trigger. When the outcome is already
|
||||
* `succeeded`, the affected booking/request caches are invalidated here (the booking flips to confirmed
|
||||
* by cache invalidation, never a blanket refetch); a still-`pending` outcome is primed into the outcome
|
||||
* key so the pending-callback poll (`usePaymentOutcome`) starts from fresh data.
|
||||
*/
|
||||
export function useConfirmGatewayReturn() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: ConfirmGatewayReturnInput) => paymentApi.confirmGatewayReturn(input),
|
||||
onSuccess: (outcome) => {
|
||||
queryClient.setQueryData(paymentKeys.outcome(outcome.bookingRequestId), outcome);
|
||||
if (outcome.transactionStatus === 'succeeded') {
|
||||
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import type { InitiatePaymentInput } from '../types';
|
||||
|
||||
/**
|
||||
* Start a card payment for an accepted request. The caller owns the **per-attempt idempotency key**
|
||||
* (generated once per attempt and reused across retries of that attempt — see the C6 page), because only
|
||||
* the UI knows when a *new* attempt starts (after a failed/cancelled outcome). On success the caller
|
||||
* navigates to `redirectUrl`. A `409` here means "already paid / already in progress / window lapsed" —
|
||||
* the page converges (routes to the return surface to read the outcome) instead of toasting; other
|
||||
* domain 4xx render inline via `mutation.error` (the fetch layer owns 401/403/5xx toasts).
|
||||
*/
|
||||
export function useInitiatePayment() {
|
||||
return useMutation({
|
||||
mutationFn: (input: InitiatePaymentInput) => paymentApi.initiatePayment(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { INVOICE_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The booking's commission invoice (b11). An issued invoice is immutable — long `staleTime`, no polling.
|
||||
* A 404 is a domain state, not a transient failure ("not issued yet" — b11 issues via an admin action;
|
||||
* auto-issue on capture is filed as REQ-018), so it renders as an empty state and is **not retried**.
|
||||
*/
|
||||
export function useInvoice(bookingId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.invoice(bookingId ?? -1),
|
||||
queryFn: () => paymentApi.getInvoice(bookingId as number),
|
||||
enabled: bookingId != null && bookingId > 0,
|
||||
staleTime: INVOICE_STALE_TIME,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import {
|
||||
PAYMENT_OUTCOME_POLL_BASE_MS,
|
||||
PAYMENT_OUTCOME_POLL_GROWTH,
|
||||
PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS,
|
||||
PAYMENT_OUTCOME_POLL_MAX_MS,
|
||||
} from '../constants';
|
||||
import { isTerminalPaymentOutcome } from '../types';
|
||||
|
||||
/**
|
||||
* The pending-callback poll. "PSP received ≠ cash in bank" — a pending state after the gateway return is
|
||||
* normal, so this polls **with geometric backoff** (base → ×growth → cap) and **stops** on any terminal
|
||||
* outcome or after a bounded number of attempts — never a tight loop, never hammering the endpoint. The
|
||||
* return surface offers a manual re-check once the budget is spent. `dataUpdateCount` is the attempt
|
||||
* counter (it increments per successful fetch, which is exactly the cadence backoff should follow).
|
||||
*/
|
||||
export function usePaymentOutcome(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.outcome(bookingRequestId ?? -1),
|
||||
queryFn: () => paymentApi.getPaymentOutcome(bookingRequestId as number),
|
||||
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
|
||||
refetchInterval: (query) => {
|
||||
const outcome = query.state.data;
|
||||
if (outcome && isTerminalPaymentOutcome(outcome)) return false;
|
||||
const attempt = query.state.dataUpdateCount;
|
||||
if (attempt >= PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS) return false;
|
||||
return Math.min(
|
||||
PAYMENT_OUTCOME_POLL_BASE_MS * PAYMENT_OUTCOME_POLL_GROWTH ** attempt,
|
||||
PAYMENT_OUTCOME_POLL_MAX_MS,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Payment domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
|
||||
* Import types/keys/apis directly from their files when needed.
|
||||
*/
|
||||
export { useCheckoutSummary } from './hooks/useCheckoutSummary';
|
||||
export { useInitiatePayment } from './hooks/useInitiatePayment';
|
||||
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
|
||||
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
|
||||
export { useInvoice } from './hooks/useInvoice';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { bookingKeys } from '@/services/bookings/keys';
|
||||
import { bookingRequestKeys } from '@/services/bookingRequests/keys';
|
||||
import { paymentKeys } from './keys';
|
||||
|
||||
/**
|
||||
* The one cache transition a successful capture causes: the request flipped `converted` and a confirmed
|
||||
* booking now exists. Invalidate exactly the affected keys — the request detail + inboxes, the bookings
|
||||
* lists (a new row), the specific booking detail when known, and this request's checkout summary/outcome
|
||||
* — never a blanket refetch (phase rule: the booking flips to confirmed *by cache invalidation*, no
|
||||
* refetch storm). Shared by `useConfirmGatewayReturn` (immediate success) and the pending-callback poll
|
||||
* (late success).
|
||||
*/
|
||||
export function invalidateAfterPaymentSuccess(
|
||||
queryClient: QueryClient,
|
||||
bookingRequestId: number,
|
||||
bookingId: number | null,
|
||||
): void {
|
||||
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(bookingRequestId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
if (bookingId != null) {
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: paymentKeys.summary(bookingRequestId) });
|
||||
queryClient.invalidateQueries({ queryKey: paymentKeys.outcome(bookingRequestId) });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
import { isTerminalBookingRequestStatus, type BookingRequestStatus } from '@/services/bookingRequests/types';
|
||||
|
||||
/**
|
||||
* Payment domain — the checkout/card-capture layer (b10) plus the customer invoice read (b11). Shapes
|
||||
* mirror the published contracts (`dev/contracts/domains/payments.md`, `refunds-invoices.md`; camelCase
|
||||
* wire, `clientFetch` unwraps the `ApiEnvelope<T>`).
|
||||
*
|
||||
* Load-bearing semantics (contracts + phase §5):
|
||||
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
|
||||
* `@/utils` BigInt helpers, display as Toman via `formatIrrToToman`. No float math on the money path.
|
||||
* - **Payment is initiated against the accepted REQUEST, not a booking** — the `bookings` row is created
|
||||
* & confirmed on capture (the PSP webhook), never on initiate.
|
||||
* - **Idempotency is per attempt.** One stable `Idempotency-Key` (header) per payment attempt, reused
|
||||
* across retries of that attempt; a NEW attempt gets a new key. A `409` on initiate means "already in
|
||||
* progress / already captured" — a benign convergence, never an error toast.
|
||||
* - **There is no client verify endpoint.** The server re-verifies inside the webhook handler; the client
|
||||
* learns the outcome by polling (`getPaymentOutcome`). On the real path that poll maps the request
|
||||
* status (`converted` → succeeded); a first-class transaction-status read is filed as REQ-017.
|
||||
* - **VAT is on Balinyaar's commission only** (the platform's taxable supply) — never the nurse payout.
|
||||
*/
|
||||
|
||||
/** `payment_transactions.status` (b10 contract enum): no other states cross the wire. */
|
||||
export type PaymentTransactionStatus = 'pending' | 'succeeded' | 'failed';
|
||||
|
||||
/** `invoices.moadian_status` (b11) — the e-invoicing registration state, surfaced read-only. */
|
||||
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
|
||||
|
||||
/** What the dev gateway round-trip reports back (mock harness); the real PSP signals via the webhook. */
|
||||
export type GatewayReturnOutcome = 'success' | 'failure';
|
||||
|
||||
/**
|
||||
* The C6 payload. **Not yet served by the backend** (REQ-016): the b8 `BookingRequestDto` is money-free
|
||||
* and no checkout-summary endpoint exists, so the mock builds this and the real `clientApi` targets the
|
||||
* proposed route until it lands. Display rows are **served, never client-derived** — in particular
|
||||
* `vatIrr` (the client must not derive tax with a float rate) — and reconcile by construction:
|
||||
* `serviceCostIrr + commissionIrr + vatIrr = totalIrr`, with `totalIrr = grossPriceIrr` and the b10
|
||||
* three-amount invariant `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
|
||||
*/
|
||||
export interface CheckoutSummaryDto {
|
||||
bookingRequestId: number;
|
||||
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
|
||||
requestStatus: BookingRequestStatus;
|
||||
nurseName: string;
|
||||
patientName: string;
|
||||
variantLabel: string;
|
||||
variantPriceUnit: PriceUnit;
|
||||
sessionCount: number;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
requestedDate: string;
|
||||
/** `HH:mm:ss`. */
|
||||
requestedTimeStart: string;
|
||||
requestedTimeEnd: string;
|
||||
/** Server-frozen UTC end of the 30-minute payment window. */
|
||||
paymentDeadlineAt: string | null;
|
||||
/** Display row: the nursing service itself (= the nurse payout leg). IRR digit-string. */
|
||||
serviceCostIrr: string;
|
||||
/** Display row: Balinyaar's commission **net of VAT**. IRR digit-string. */
|
||||
commissionIrr: string;
|
||||
/** Display row: VAT on the commission (the served figure — never derived client-side). */
|
||||
vatIrr: string;
|
||||
/** Snapshotted VAT rate (decimal, e.g. 0.1) — informational; the rial figure is `vatIrr`. */
|
||||
vatRate: number;
|
||||
/** Display total (= `grossPriceIrr`); the rows above sum to exactly this. */
|
||||
totalIrr: string;
|
||||
/** The b10 three-amount split (gross = commission + payout, guaranteed server-side). */
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
}
|
||||
|
||||
/** `POST bookings/{bookingRequestId}/payments` input — the key travels as the `Idempotency-Key` header. */
|
||||
export interface InitiatePaymentInput {
|
||||
bookingRequestId: number;
|
||||
/** Stable per payment attempt (reused across retries of the same attempt); a new attempt gets a new one. */
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** `InitiatePaymentResult` (b10 swagger): where to send the customer, and the PSP reference. */
|
||||
export interface InitiatePaymentResult {
|
||||
transactionId: number;
|
||||
/** Absolute PSP URL on the real path; an app-relative gateway-harness path from the mock. */
|
||||
redirectUrl: string | null;
|
||||
gatewayReferenceCode: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the return-from-gateway surface reports: the harness outcome plus which transaction came back.
|
||||
* On the real path the PSP has already hit the webhook before redirecting — the impl just re-reads the
|
||||
* outcome; in the mock this IS the capture trigger (the client stand-in for the webhook confirm).
|
||||
*/
|
||||
export interface ConfirmGatewayReturnInput {
|
||||
bookingRequestId: number;
|
||||
transactionId: number | null;
|
||||
outcome: GatewayReturnOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending-callback poll target. No client transaction read exists in the b10 contract (REQ-017), so
|
||||
* the real path derives `transactionStatus` from the request status (`converted` → succeeded,
|
||||
* `payment_deadline_expired` → failed, else pending) and `bookingId` stays `null` until the backend
|
||||
* serves it; the mock knows both first-hand.
|
||||
*/
|
||||
export interface PaymentOutcomeDto {
|
||||
bookingRequestId: number;
|
||||
requestStatus: BookingRequestStatus;
|
||||
transactionStatus: PaymentTransactionStatus | null;
|
||||
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
|
||||
bookingId: number | null;
|
||||
}
|
||||
|
||||
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
|
||||
export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
|
||||
return (
|
||||
outcome.transactionStatus === 'succeeded' ||
|
||||
outcome.transactionStatus === 'failed' ||
|
||||
isTerminalBookingRequestStatus(outcome.requestStatus)
|
||||
);
|
||||
}
|
||||
|
||||
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
|
||||
export interface InvoiceDto {
|
||||
id: number;
|
||||
bookingId: number;
|
||||
/** Sequential, gap-free human-facing reference (e.g. `INV-0000000001`) — treat as opaque. */
|
||||
invoiceNumber: string;
|
||||
issuingEntityType: 'platform' | 'partner_center';
|
||||
grossIrr: string;
|
||||
/** Balinyaar's commission — the VAT-relevant line (`vatIrr = round(commission × vatRate)`). */
|
||||
platformCommissionIrr: string;
|
||||
bnplCommissionIrr: string | null;
|
||||
vatRate: number;
|
||||
vatIrr: string;
|
||||
moadianReferenceNumber: string | null;
|
||||
moadianStatus: MoadianStatus | null;
|
||||
pdfUrl: string | null;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_PAYMENT_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface PaymentApi {
|
||||
getCheckoutSummary(bookingRequestId: number): Promise<CheckoutSummaryDto>;
|
||||
initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
|
||||
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
|
||||
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
|
||||
getInvoice(bookingId: number): Promise<InvoiceDto>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,122 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Frontend ↔ backend gaps — REQ reconciliation</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
|
||||
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
|
||||
--warn:#a04b12; color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
|
||||
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
|
||||
--warn:#e09355;
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
|
||||
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
|
||||
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
|
||||
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
|
||||
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
|
||||
p{margin:.6rem 0;}
|
||||
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
|
||||
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
|
||||
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
|
||||
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
|
||||
padding: .9rem 1rem;overflow-x:auto;}
|
||||
pre code{background:none;padding:0;}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
|
||||
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
|
||||
table{border-collapse:collapse;width:100%;font-size:.92rem;}
|
||||
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
|
||||
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
|
||||
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
|
||||
tbody tr:last-child td{border-bottom:none;}
|
||||
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
|
||||
li{margin:.35rem 0;}
|
||||
li>code:first-child{font-weight:600;}
|
||||
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
|
||||
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
|
||||
nav.toc strong{display:block;margin-bottom:.4rem;}
|
||||
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
|
||||
nav.toc>ul{padding-inline-start:0;}
|
||||
nav.toc li{margin:.2rem 0;}
|
||||
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
|
||||
nav.toc .l3 a{color:var(--muted);}
|
||||
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
|
||||
.crumbs a{color:var(--muted);}
|
||||
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
|
||||
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
|
||||
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
|
||||
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
|
||||
details.src summary{cursor:pointer;}
|
||||
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
|
||||
svg .t2{font-size:11px;fill:var(--muted);}
|
||||
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
|
||||
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
|
||||
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
|
||||
svg .box.mock{stroke-dasharray:5 4;}
|
||||
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
|
||||
svg .edge{fill:none;stroke-width:1.7;}
|
||||
svg .edge.real{stroke:var(--accent);}
|
||||
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
|
||||
svg .lbl{font-size:10.5px;fill:var(--muted);}
|
||||
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<div class="crumbs"><a href="index.html">← Post-phase server audit</a></div>
|
||||
<h1 id="frontend-backend-gaps-req-reconciliation">Frontend ↔ backend gaps — REQ reconciliation</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p><nav class="toc"><strong>Contents</strong><ul><li class="l2"><a href="#verdict-summary">Verdict summary</a></li><li class="l2"><a href="#per-req-detail">Per-REQ detail</a></li><li class="l3"><a href="#req-001-envelope-casing-pagination-done-needs-a-written-confirmation-one-caveat">REQ-001 — envelope, casing, pagination — <strong>Done, needs a written confirmation + one caveat</strong></a></li><li class="l3"><a href="#req-002-otp-length-expiry-missing">REQ-002 — OTP length + expiry — <strong>Missing</strong></a></li><li class="l3"><a href="#req-003-machine-readable-verify-otp-errors-missing">REQ-003 — machine-readable verify_otp errors — <strong>Missing</strong></a></li><li class="l3"><a href="#req-004-activerole-confirmation-missing-recommend-answer-client-owns-it">REQ-004 — activeRole confirmation — <strong>Missing (recommend: answer "client owns it")</strong></a></li><li class="l3"><a href="#req-005-patient-relation-conditions-missing">REQ-005 — patient relation + conditions — <strong>Missing</strong></a></li><li class="l3"><a href="#req-006-avatar-upload-avatarurl-missing">REQ-006 — avatar upload + avatarUrl — <strong>Missing</strong></a></li><li class="l3"><a href="#req-007-customer-name-preferred-language-missing">REQ-007 — customer name + preferred language — <strong>Missing</strong></a></li><li class="l3"><a href="#req-008-accept-the-client-map-pin-missing">REQ-008 — accept the client map pin — <strong>Missing</strong></a></li><li class="l3"><a href="#req-009-provinceid-on-customeraddressdto-missing">REQ-009 — provinceId on CustomerAddressDto — <strong>Missing</strong></a></li><li class="l3"><a href="#req-010-pagesize-param-name-partial-server-verified-docs-stale">REQ-010 — pageSize param name — <strong>Partial (server verified; docs stale)</strong></a></li><li class="l3"><a href="#req-011-nurse-credential-details-isrequired-missing">REQ-011 — nurse credential_details + isRequired — <strong>Missing</strong></a></li><li class="l3"><a href="#req-012-search-enrichment-public-nurse-profile-missing-highest-leverage-gap">REQ-012 — search enrichment + public nurse profile — <strong>Missing (highest-leverage gap)</strong></a></li><li class="l3"><a href="#req-013-variantprice-on-bookingrequestdto-missing">REQ-013 — variantPrice on BookingRequestDto — <strong>Missing</strong></a></li><li class="l3"><a href="#req-014-variantlabel-patientage-on-the-inbox-row-missing">REQ-014 — variantLabel/patientAge on the inbox row — <strong>Missing</strong></a></li><li class="l3"><a href="#req-015-enum-codes-checkinaddressmatch-tri-state-done-verified-needs-a-written-confirmation">REQ-015 — enum codes + checkInAddressMatch tri-state — <strong>Done (verified), needs a written confirmation</strong></a></li><li class="l2"><a href="#beyond-the-filed-reqs-what-f9-f15-will-hit">Beyond the filed REQs — what f9–f15 will hit</a></li><li class="l2"><a href="#frontend-unblock-priority">Frontend-unblock priority</a></li></ul></nav>
|
||||
<p><strong>Audit date:</strong> 2026-07-10 · <strong>Sources:</strong> <code>dev/shared-working-context/frontend/requests/for-backend.md</code> (REQ-001…015), the frontend phase reports/STATUS (f0–f8), the client code's mock flags, the published contracts (<code>dev/contracts/domains/*.md</code> + <code>dev/contracts/openapi/swagger.v1.json</code>), and the server code. Every verdict was checked against <strong>both</strong> the contract surface and the actual DTO/handler/controller code.</p>
|
||||
<p><strong>Headline:</strong> the backend chain is complete (b0–b15), but of the 15 filed REQs only <strong>REQ-001</strong> and <strong>REQ-015</strong> are effectively satisfied and <strong>REQ-010</strong> is a documentation fix — the other <strong>12 are undelivered</strong>. Every REQ still reads <code>Status: open</code> in the tracker. As a direct consequence, <strong>11 of the client's 12 service domains still default to mock-primary</strong> (only auth is real-default, <code>client/src/services/auth/constants.ts:6</code>). Beyond the filed REQs, the unbuilt frontend phases 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).</p>
|
||||
<h2 id="verdict-summary">Verdict summary</h2>
|
||||
<div class="tblwrap"><table><thead><tr><th>REQ</th><th>Ask (short)</th><th>Verdict</th><th>One-line evidence</th></tr></thead><tbody><tr><td>REQ-001</td><td>Envelope / casing / pagination shape</td><td><strong>Done</strong> (confirm + caveat)</td><td><code>ApiResult</code> + <code>PagedResult</code> match the typed shape; <code>statusCode</code> is an <strong>integer</strong> enum</td></tr><tr><td>REQ-002</td><td><code>codeLength</code>/<code>expiresInSeconds</code> on RequestOtpResult</td><td><strong>Missing</strong></td><td><code>RequestOtpResult.cs:7</code> has only <code>OtpSent</code>, <code>ResendAvailableInSeconds</code></td></tr><tr><td>REQ-003</td><td>Machine error codes for verify_otp</td><td><strong>Missing</strong></td><td>envelope has no <code>code</code> slot; lockout differs only by message text</td></tr><tr><td>REQ-004</td><td><code>activeRole</code> on MeResult (confirmation)</td><td><strong>Missing</strong> (answer: client owns it)</td><td><code>MeResult.cs:9</code> — no ActiveRole anywhere in the contract</td></tr><tr><td>REQ-005</td><td>Patient <code>relation</code> + <code>conditions</code></td><td><strong>Missing</strong></td><td><code>PatientDto.cs:7</code>, create/update commands unchanged</td></tr><tr><td>REQ-006</td><td>Avatar upload route + <code>avatarUrl</code></td><td><strong>Missing</strong></td><td>zero <code>IFormFile</code>/avatar matches in <code>server/src</code></td></tr><tr><td>REQ-007</td><td>Customer name + preferred language</td><td><strong>Missing</strong></td><td>upsert body is emergency-contact only</td></tr><tr><td>REQ-008</td><td>Accept client map pin on address create/update</td><td><strong>Missing</strong></td><td>commands have no lat/lng; always geocodes</td></tr><tr><td>REQ-009</td><td><code>provinceId</code> on CustomerAddressDto</td><td><strong>Missing</strong></td><td>DTO ends at RecipientPhone</td></tr><tr><td>REQ-010</td><td>pageSize vs page_size</td><td><strong>Partial</strong></td><td>server binds <code>pageSize</code> (verified); contract docs still say <code>page_size</code></td></tr><tr><td>REQ-011</td><td>Nurse credential_details endpoint + <code>isRequired</code></td><td><strong>Missing</strong></td><td>no such route/command; step DTO lacks isRequired</td></tr><tr><td>REQ-012</td><td>Search row name/avatar/distance + <code>GET nurses/{id}/profile</code></td><td><strong>Missing</strong></td><td>DTO ids-only; no profile action on NursesController</td></tr><tr><td>REQ-013</td><td><code>variantPrice</code> on BookingRequestDto</td><td><strong>Missing</strong></td><td>DTO has unit without price</td></tr><tr><td>REQ-014</td><td><code>variantLabel</code>/<code>patientAge</code> on list item</td><td><strong>Missing</strong></td><td>list DTO omits both</td></tr><tr><td>REQ-015</td><td>Status enum codes + <code>checkInAddressMatch</code> tri-state (confirmation)</td><td><strong>Done</strong> (verified in code)</td><td>snake_case string constants on the wire; null-when-no-GPS confirmed</td></tr></tbody></table></div>
|
||||
<hr>
|
||||
<h2 id="per-req-detail">Per-REQ detail</h2>
|
||||
<h3 id="req-001-envelope-casing-pagination-done-needs-a-written-confirmation-one-caveat">REQ-001 — envelope, casing, pagination — <strong>Done, needs a written confirmation + one caveat</strong></h3>
|
||||
<ul><li><strong>Frontend expects:</strong> payload always under <code>data</code> in <code>{ isSuccess, statusCode, message, requestId, data }</code>; camelCase JSON; lists as <code>{ items, total, page, pageSize }</code>.</li><li><strong>Backend ships:</strong> exactly that. <code>ApiResult</code> at <code>server/src/Core/Baya.Application/Models/ApiResult/ApiResult.cs:8</code> (+ generic <code>Data</code> at <code>:28</code>); <code>PagedResult<T>(Items, Total, Page, PageSize)</code> at <code>server/src/Core/Baya.Application/Models/Common/PagedResult.cs:4</code>; camelCase is the System.Text.Json default (no naming-policy override exists in <code>server/src/API</code>); swagger confirms (<code>dev/contracts/openapi/swagger.v1.json:13448</code> envelope, <code>:14205</code> paged shape).</li><li><strong>Caveat to communicate:</strong> <code>statusCode</code> serializes as an <strong>integer</strong> (<code>ApiResultStatusCode</code> enum, swagger <code>:13468-13469</code>) — matches the client's <code>number</code> typing, but worth stating so nobody expects an HTTP-status string.</li><li><strong>Fix:</strong> zero code. Write the confirmation into the REQ and mark it delivered.</li></ul>
|
||||
<h3 id="req-002-otp-length-expiry-missing">REQ-002 — OTP length + expiry — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Expected:</strong> <code>RequestOtpResult { otpSent, resendAvailableInSeconds, codeLength, expiresInSeconds }</code>.</li><li><strong>Actual:</strong> <code>server/src/Core/Baya.Application/Models/Identity/RequestOtpResult.cs:7</code> — two fields only; handler returns them at <code>Features/Identity/Commands/RequestOtp/RequestOtpCommand.Handler.cs:67</code>; swagger agrees (<code>swagger.v1.json:16539</code>). The client hardcodes <code>OTP_CODE_LENGTH = 6</code> (<code>client/src/services/auth/constants.ts:19</code>).</li><li><strong>Fix:</strong> add the two ints (code length is a constant today; TTL from the OTP provider options). S effort.</li></ul>
|
||||
<h3 id="req-003-machine-readable-verify-otp-errors-missing">REQ-003 — machine-readable verify_otp errors — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Expected:</strong> stable <code>code</code> (<code>otp_invalid</code> | <code>otp_expired</code> | <code>otp_locked</code>) + <code>retryAfterSeconds</code> on lockout.</li><li><strong>Actual:</strong> the envelope has no <code>code</code> slot (<code>ApiResult.cs:8</code>; <code>OperationResult</code> carries only boolean flags — <code>Models/Common/OperationResult.cs:14-31</code>). Wrong/expired share one anti-enumeration message (<code>Features/Identity/Commands/VerifyOtp/VerifyOtpCommand.Handler.cs:24,30,34,50</code>); lockout is a different English string only (<code>:38</code>). The client keys off the mock-only <code>otp_locked</code> code (<code>client/src/services/auth/constants.ts:29</code>).</li><li><strong>Fix:</strong> add an optional <code>code</code> (+ optional <code>data</code>) to the failure envelope — a small <code>OperationResult</code>/<code>ApiResult</code> extension — and emit <code>otp_locked</code> + <code>retryAfterSeconds</code> from the lockout branch; keep wrong-vs-expired collapsed if enumeration-safety is preferred (state that in the REQ answer). S–M effort (the only REQ touching a cross-cutting type).</li></ul>
|
||||
<h3 id="req-004-activerole-confirmation-missing-recommend-answer-client-owns-it">REQ-004 — activeRole confirmation — <strong>Missing (recommend: answer "client owns it")</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> no <code>activeRole</code> on <code>MeResult</code> (<code>server/src/Core/Baya.Application/Models/Identity/MeResult.cs:9</code>) or anywhere in the contract (schema scan). No endpoint persists a current-role choice.</li><li><strong>Fix:</strong> zero code — write the decision (client-owned <code>intended_role</code> stands) into the REQ so the router behavior is contract-blessed. If the product later wants a persisted active role, it's a <code>me/select_role</code> extension.</li></ul>
|
||||
<h3 id="req-005-patient-relation-conditions-missing">REQ-005 — patient relation + conditions — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> <code>PatientDto</code> ends at <code>InitialMedicalNotes</code>/<code>IsActive</code> (<code>Models/Identity/PatientDto.cs:7,15-16</code>); create/update commands unchanged (<code>Features/Identity/Commands/CreatePatient/CreatePatientCommand.cs:12-19</code>, <code>UpdatePatient/UpdatePatientCommand.cs:8-16</code>); no <code>relation</code>/<code>conditions</code> in any schema.</li><li><strong>Fix:</strong> <code>relation</code> as a nullable code column; <code>conditions</code> as stable codes (JSON column or child table — child table if search/filtering is ever wanted). Gate: flips <code>USE_PATIENTS_MOCK</code> (<code>client/src/services/patients/constants.ts:8</code>). S–M effort.</li></ul>
|
||||
<h3 id="req-006-avatar-upload-avatarurl-missing">REQ-006 — avatar upload + avatarUrl — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> zero <code>IFormFile</code>/multipart/avatar usage in <code>server/src</code> (repo-wide grep); no <code>avatarUrl</code> on <code>NurseProfileDto</code> (swagger <code>:18945</code>) or <code>CustomerProfileDto</code> (<code>Models/Identity/CustomerProfileDto.cs:7</code>). The client's real path deliberately throws 501 (<code>client/src/services/profiles/apis/clientApi.ts:84</code>).</li><li><strong>Fix:</strong> <code>POST api/v1/{nurse|customer}_profiles/avatar</code> (multipart, size/type-validated) storing via <code>IObjectStorage</code> + <code>avatar_url</code> column on both profiles. Note it also feeds REQ-012 (search card avatar) and REQ-013 (nurse avatar on request detail) — deliver before or with those. M effort (first multipart endpoint; pairs with the object-storage swap, plan §5.5).</li></ul>
|
||||
<h3 id="req-007-customer-name-preferred-language-missing">REQ-007 — customer name + preferred language — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> upsert body is emergency-contact only (<code>Features/Identity/Commands/UpsertCustomerProfile/UpsertCustomerProfileCommand.cs:11</code>); <code>MeResult</code> exposes name read-only (<code>MeResult.cs:12</code>); no <code>preferredLanguage</code> anywhere (schema scan).</li><li><strong>Fix:</strong> decide the home (recommend: extend the upsert to write <code>Users.FirstName/LastName</code> + <code>preferred_language</code> on the customer profile) and answer the REQ. S effort.</li></ul>
|
||||
<h3 id="req-008-accept-the-client-map-pin-missing">REQ-008 — accept the client map pin — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> create/update commands have no coordinates; the server always geocodes (<code>Features/Addresses/Commands/CreateAddress/CreateAddressCommand.cs:9-12</code>, <code>UpdateAddress/UpdateAddressCommand.cs:8-9</code>; swagger <code>:18051</code>). The user's pin is silently discarded on the real path — exactly what the REQ warned. This also degrades <strong>EVV accuracy</strong> (b9 measures distance to the stored coordinate; a mock/geocoded centroid is ±5 km off — <code>CrossCutting/Seams/MockGeocoder.cs:52</code>).</li><li><strong>Fix:</strong> optional <code>latitude</code>/<code>longitude</code> on both bodies; when present store as source <code>user_pin</code>, else geocode as today. S effort; do before the real geocoder swap (plan §5.4).</li></ul>
|
||||
<h3 id="req-009-provinceid-on-customeraddressdto-missing">REQ-009 — provinceId on CustomerAddressDto — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> DTO fields run <code>Id..RecipientPhone</code> (<code>Models/Addresses/CustomerAddressDto.cs:9-24</code>); no <code>provinceId</code> (swagger <code>:17983</code> — the property exists only on <code>CityDto</code>).</li><li><strong>Fix:</strong> join <code>cities.province_id</code> into the address projections. S effort. Gate (with REQ-008): <code>USE_ADDRESSES_MOCK</code> (<code>client/src/services/addresses/constants.ts:10</code>).</li></ul>
|
||||
<h3 id="req-010-pagesize-param-name-partial-server-verified-docs-stale">REQ-010 — pageSize param name — <strong>Partial (server verified; docs stale)</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> every list binds a <code>PageSize</code> record property via <code>[FromQuery]</code> — so the working wire name is camelCase <code>pageSize</code> (case-insensitive), and <code>page_size</code> <strong>silently does not bind</strong>. Verified: <code>Features/ServiceAreas/Queries/ListMyServiceAreas/ListMyServiceAreasQuery.cs:8</code>, <code>Features/Variants/Queries/ListMyVariants/ListMyVariantsQuery.cs:8</code>, <code>Controllers/V1/NurseServiceAreasController.cs:35</code>, <code>AdminPayoutsController.cs:54</code>; swagger names the parameter <code>pageSize</code> (<code>swagger.v1.json:3104</code>). But the requested deliverable — fixing the docs — never happened: <code>dev/contracts/domains/catalog.md:41</code> and <code>config-reference.md:11</code> (and others, e.g. bookings-evv.md, verification.md) still write <code>page_size</code>.</li><li><strong>Fix:</strong> sweep the contract docs to <code>pageSize</code>, answer the REQ. Zero server code.</li></ul>
|
||||
<h3 id="req-011-nurse-credential-details-isrequired-missing">REQ-011 — nurse credential_details + isRequired — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> the nurse-facing controller exposes only submit/status/upload_url/documents/run (<code>Controllers/V1/NurseVerificationController.cs:26-57</code>); repo-wide grep for <code>credential_details|SubmitCredential</code> finds nothing. <code>VerificationStepDto</code> has no <code>IsRequired</code> (<code>Models/Verification/VerificationDtos.cs:17</code>); the flag exists only on the admin step-type catalog. Consequence on the real path: the INO number + specialties a nurse types are <strong>silently dropped</strong> (<code>verificationClientApi.submitCredentialDetails</code> no-ops — <code>dev/shared-working-context/reports/frontend-phase-5-report.md:101</code>).</li><li><strong>Fix:</strong> <code>POST api/v1/nurse_verification/credential_details</code> writing the structured <code>nurse_credentials</code> fields (the registry table already stores number/authority/expiry), + project <code>isRequired</code> onto the step DTO. M effort. Gate: <code>USE_VERIFICATION_MOCK</code> (<code>client/src/services/verification/constants.ts:9</code>).</li></ul>
|
||||
<h3 id="req-012-search-enrichment-public-nurse-profile-missing-highest-leverage-gap">REQ-012 — search enrichment + public nurse profile — <strong>Missing (highest-leverage gap)</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> <code>NurseSearchResultDto</code> carries ids + price/rating/gender/geo only (<code>Models/Search/NurseSearchResultDto.cs:8-19</code>); the public <code>NursesController</code> has trust_badge, reviews, review_tags — <strong>no <code>/profile</code></strong> (<code>Controllers/V1/NursesController.cs:24-36</code>); no <code>avatarUrl</code>/<code>distanceKm</code> anywhere in the contract (schema scan).</li><li><strong>Why it leads the priority list:</strong> C2/C3 are the trust funnel — the family picks a <em>named, faced, priced</em> nurse here; this single REQ keeps <code>services/search</code> mock-primary (<code>client/src/services/search/constants.ts:9</code>) and blocks the whole discovery→request→booking real-path chain (search feeds C4's nurse/variant ids).</li><li><strong>Fix:</strong> (a) denormalize <code>nurse_name</code>/<code>avatar_url</code> into <code>nurse_search_index</code> (the maintainer already re-derives rows from source — <code>Persistence/Services/Search/SearchIndexMaintainer.cs:25</code>; add columns + reindex-on-profile-change) or join at query time in <code>SqlNurseSearch</code>; <code>distanceKm</code> is optional — the district model makes it derived-if-cheap. (b) an aggregated <code>GET nurses/{id}/profile</code> composing existing reads (profile + variants + trust badge + latest published review). M effort; depends on REQ-006 for the avatar itself.</li></ul>
|
||||
<h3 id="req-013-variantprice-on-bookingrequestdto-missing">REQ-013 — variantPrice on BookingRequestDto — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> the DTO has <code>VariantLabel</code> + <code>VariantPriceUnit</code> but no price and no nurse avatar (<code>Models/Booking/BookingRequestDto.cs:20-21</code>, full list <code>:11-42</code>; swagger <code>:16674</code>).</li><li><strong>Fix:</strong> join the variant's <code>Price</code> (IRR digit-string, consistent with the money convention) into the projection. The money-free rule stays intact — this is the display <em>rate</em>, not an engagement total (the request row still stores no money). S effort.</li></ul>
|
||||
<h3 id="req-014-variantlabel-patientage-on-the-inbox-row-missing">REQ-014 — variantLabel/patientAge on the inbox row — <strong>Missing</strong></h3>
|
||||
<ul><li><strong>Actual:</strong> <code>BookingRequestListItemDto</code> has neither (<code>Models/Booking/BookingRequestListItemDto.cs:10-21</code>; swagger <code>:16903</code>) — the nurse inbox can't show <em>which service</em> was requested without opening the detail.</li><li><strong>Fix:</strong> add <code>variantLabel</code> (already on the detail DTO); <code>patientAge</code> as a coarse band if product wants it. S effort. Gate (with REQ-013): <code>USE_BOOKING_REQUESTS_MOCK</code> (<code>client/src/services/bookingRequests/constants.ts:14</code>) — though that flip also needs the upstream domains real (see below).</li></ul>
|
||||
<h3 id="req-015-enum-codes-checkinaddressmatch-tri-state-done-verified-needs-a-written-confirmation">REQ-015 — enum codes + checkInAddressMatch tri-state — <strong>Done (verified), needs a written confirmation</strong></h3>
|
||||
<ul><li><strong>Verified in code:</strong> statuses are stored/projected as snake_case <strong>string constants</strong> — exactly the client unions: <code>Domain/Entities/Booking/BookingStatus.cs:11-30</code>, <code>BookingSessionStatus.cs:10-19</code>, <code>VisitVerificationStatus.cs:11-17</code>; DTOs copy them verbatim (<code>Models/Booking/BookingDtos.cs:97</code>), so no PascalCase/int ever hits the wire. <code>checkInAddressMatch</code> is <code>bool?</code> (<code>BookingDtos.cs:104</code>) assigned only inside the lat/lng-present branch (<code>Features/Bookings/Commands/CheckInVisit/CheckInVisitCommand.Handler.cs:64-77</code>) → <strong>null when GPS is absent</strong>; a <code>false</code> is advisory only (support alert + notification, no block — <code>:90-109</code>).</li><li><strong>One nuance to include in the answer:</strong> <code>null</code> also occurs when GPS <em>was</em> sent but the frozen booking address has no resolvable coordinates — the client copy for «موقعیت ثبت نشد» should tolerate that.</li><li><strong>Fix:</strong> zero code; write the confirmation, mark delivered.</li></ul>
|
||||
<hr>
|
||||
<h2 id="beyond-the-filed-reqs-what-f9-f15-will-hit">Beyond the filed REQs — what f9–f15 will hit</h2>
|
||||
<p>Frontend phases f0–f8 are built (reports exist); <strong>f9–f15 are specs only</strong>. Reconciling their declared consumption against the shipped backend:</p>
|
||||
<div class="tblwrap"><table><thead><tr><th>Upcoming phase</th><th>Consumes</th><th>Backend reality</th><th>Verdict</th></tr></thead><tbody><tr><td>f9 checkout/card</td><td>b10 <code>payments.md</code> + b11 invoice</td><td>endpoints exist (initiate/webhook/<code>GET invoices/{booking_id}</code>), but <strong>no checkout-summary read with the VAT line</strong> — f8 already flagged <code>BookingDetailDto</code> has no tax field (<code>reports/frontend-phase-8-report.md:113</code>); f9's spec expects <code>vat_irr</code>/<code>vat_rate</code>/<code>redirect_url</code> shapes (<code>dev/phases/frontend/frontend-phase-9-b10.md:117,330</code>)</td><td><strong>Partial — pre-file the checkout-summary REQ now</strong></td></tr><tr><td>f10 refund status</td><td>b11 <code>refunds-invoices.md</code></td><td><code>GET refunds/{id}/status</code> + <code>GET invoices/{booking_id}</code> shipped (<code>Controllers/V1/RefundsController</code>, <code>InvoicesController</code>)</td><td>Done (verify shapes when f10 runs)</td></tr><tr><td>f11 BNPL</td><td>b12 <code>bnpl.md</code></td><td>full eligibility→initiate→status surface shipped (<code>CheckoutBnplController</code>)</td><td>Done (verify shapes)</td></tr><tr><td>f12 nurse earnings</td><td>b13 <code>payouts.md</code></td><td><code>nurse_payouts/history</code> + admin console shipped (<code>NursePayoutsController</code>, <code>AdminPayoutsController</code>)</td><td>Done (verify shapes)</td></tr><tr><td>f13 reviews/care records</td><td>b14 <code>reviews-records.md</code></td><td>submit/list/tags/moderation + care records shipped (5 controllers)</td><td>Done (verify shapes)</td></tr><tr><td>f14 tickets + notifications</td><td>b15 + <strong>b1 notifications</strong></td><td>tickets shipped; notifications <strong>verified present</strong>: <code>GET notifications/get_notifications</code>/<code>get_unread_count</code>, <code>POST mark_notification_read</code>/<code>mark_all_read</code> (<code>Controllers/V1/NotificationsController.cs:24-42</code>) — the f14 spec's worry about missing b1 endpoints is unfounded</td><td>Done</td></tr><tr><td>f15 admin/partner consoles</td><td>admin endpoints across b1/b6/b11/b13/b14/b15</td><td>all shipped per the chain (verification queue, refunds, payouts, moderation, config/holidays/audit/support-alerts, partner centers)</td><td>Done (expect shape-polish REQs when f15 runs)</td></tr></tbody></table></div>
|
||||
<p><strong>Data gap (not a contract gap):</strong> flipping <code>USE_CATALOG_MOCK</code> against a fresh backend yields categories with <strong>no option groups</strong> — only the 5 categories are seeded; groups/values are admin-authored and the admin catalog UI is f15 (<code>reports/frontend-phase-4-report.md:92</code>). Until f15 (or a seed migration), the variant builder's required-option step has nothing to render on the real path. Recommend: a small representative option-group seed, or prioritize the f15 catalog manager.</p>
|
||||
<p><strong>Tracker hygiene:</strong> all 15 REQs read <code>Status: open</code> (<code>for-backend.md:32…216</code>) and the mocks-registry's early block contradicts its own later rows (see plan §7.6). Whoever lands this batch should update both in the same change.</p>
|
||||
<hr>
|
||||
<h2 id="frontend-unblock-priority">Frontend-unblock priority</h2>
|
||||
<ol><li><strong>REQ-012</strong> (search row enrichment + public profile) — unlocks the discovery funnel; everything downstream needs C2/C3 real. Include the <code>nurse_search_index</code> columns + reindex.</li><li><strong>REQ-005, REQ-008, REQ-009</strong> — the booking-request <em>inputs</em> (patients, addresses) go real; REQ-008 also protects EVV accuracy.</li><li><strong>REQ-013, REQ-014</strong> — the request flow prices/labels itself; with (1)+(2) the whole search→request→accept chain can flip to real.</li><li><strong>REQ-006, REQ-007</strong> — profile/avatar polish; REQ-006 also feeds (1) and (3)'s avatar fields.</li><li><strong>REQ-011</strong> — verification detail capture (stops silent INO/specialty data loss).</li><li><strong>REQ-002, REQ-003</strong> — auth UX polish (real path already works without them).</li><li><strong>Zero-code batch: REQ-001, REQ-004, REQ-010, REQ-015</strong> — written confirmations + contract-doc <code>page_size</code> sweep + tracker statuses.</li><li><strong>Pre-file the f9 checkout-summary REQ</strong> (VAT line, redirect_url, idempotency header echo) so b-side work can precede the f9 build.</li></ol>
|
||||
</main></body></html>
|
||||
@@ -0,0 +1,250 @@
|
||||
# Frontend ↔ backend gaps — REQ reconciliation
|
||||
|
||||
**Audit date:** 2026-07-10 · **Sources:** `dev/shared-working-context/frontend/requests/for-backend.md`
|
||||
(REQ-001…015), the frontend phase reports/STATUS (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<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 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.
|
||||
@@ -0,0 +1,80 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Post-phase server audit — 2026-07-10</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
|
||||
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
|
||||
--warn:#a04b12; color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
|
||||
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
|
||||
--warn:#e09355;
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
|
||||
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
|
||||
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
|
||||
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
|
||||
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
|
||||
p{margin:.6rem 0;}
|
||||
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
|
||||
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
|
||||
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
|
||||
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
|
||||
padding: .9rem 1rem;overflow-x:auto;}
|
||||
pre code{background:none;padding:0;}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
|
||||
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
|
||||
table{border-collapse:collapse;width:100%;font-size:.92rem;}
|
||||
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
|
||||
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
|
||||
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
|
||||
tbody tr:last-child td{border-bottom:none;}
|
||||
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
|
||||
li{margin:.35rem 0;}
|
||||
li>code:first-child{font-weight:600;}
|
||||
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
|
||||
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
|
||||
nav.toc strong{display:block;margin-bottom:.4rem;}
|
||||
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
|
||||
nav.toc>ul{padding-inline-start:0;}
|
||||
nav.toc li{margin:.2rem 0;}
|
||||
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
|
||||
nav.toc .l3 a{color:var(--muted);}
|
||||
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
|
||||
.crumbs a{color:var(--muted);}
|
||||
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
|
||||
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
|
||||
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
|
||||
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
|
||||
details.src summary{cursor:pointer;}
|
||||
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
|
||||
svg .t2{font-size:11px;fill:var(--muted);}
|
||||
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
|
||||
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
|
||||
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
|
||||
svg .box.mock{stroke-dasharray:5 4;}
|
||||
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
|
||||
svg .edge{fill:none;stroke-width:1.7;}
|
||||
svg .edge.real{stroke:var(--accent);}
|
||||
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
|
||||
svg .lbl{font-size:10.5px;fill:var(--muted);}
|
||||
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
|
||||
<h1 id="post-phase-server-audit-2026-07-10">Post-phase server audit — 2026-07-10</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p>
|
||||
<p>A read-only audit of the completed backend chain (backend-phase-0 → 15), produced after the final backend phase shipped. Three deliverables, each in canonical Markdown with a matching self-contained HTML view (<code>index.html</code> is the browsable entry point):</p>
|
||||
<div class="tblwrap"><table><thead><tr><th>Deliverable</th><th>What it answers</th></tr></thead><tbody><tr><td><a href="post-phase-backend-plan.html">post-phase-backend-plan.md</a> · <a href="post-phase-backend-plan.html">html</a></td><td>What backend work remains — 8 prioritized, runnable "post-phases" (security hygiene → money-path fixes → contract batch → scheduler/Redis → trust rails → money rails → observability → later)</td></tr><tr><td><a href="frontend-backend-gaps.html">frontend-backend-gaps.md</a> · <a href="frontend-backend-gaps.html">html</a></td><td>REQ-001…015 reconciled against the shipped contract + code: 2 done, 1 doc-fix, 12 missing; plus what unbuilt f9–f15 will hit, and the unblock priority</td></tr><tr><td><a href="runtime-services.html">runtime-services.md</a> · <a href="runtime-services.html">html</a></td><td>The deployment topology: 17 services/rails derived from the seams + config, a dependency graph, per-service defaults/config keys/health notes</td></tr></tbody></table></div>
|
||||
<p><strong>Executive summary.</strong> The chain is genuinely complete against its own specs — 358 green tests, and the load-bearing invariants (balanced ledger groups, four money DB CHECKs, webhook idempotency, tenancy 404s, forward-only status machines) all verifiably exist in code. The API's only real external dependency today is SQL Server; all 18 vendor/infra seams are deterministic in-process mocks, which is the designed MVP posture. What the audit surfaced beyond that design: <strong>(1)</strong> committed live credentials — a real <code>sa</code> connection string in <code>appsettings*.json</code>, placeholder JWE/encryption keys, and a seeded <code>admin</code>/<code>qw123321</code> user — that block any deployment; <strong>(2)</strong> one genuine money-correctness hole — the BNPL/manual refund settlement path is unreachable (<code>Refund.MarkSucceededAsync</code> has zero callers), so those refunds strand <code>refund_payable</code>/<code>escrow_held</code> forever; <strong>(3)</strong> the frontend is still 11/12 domains mock-primary because 12 of its 15 filed REQs were never delivered and none were answered; <strong>(4)</strong> unattended operation doesn't exist yet — payout batches, credential-expiry scans, no-show sweeps, and Moadian reconciliation are admin-click-only while their cadence config keys sit unread; and <strong>(5)</strong> the promised forward-dep FKs (refunds→tickets, clawbacks→payouts, invoices→partner_centers) were never added after their target tables shipped. Full evidence and the fix-by-fix plan are in the three documents.</p>
|
||||
</main></body></html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,601 @@
|
||||
# Post-development backend plan — fixes & improvements
|
||||
|
||||
**Audit date:** 2026-07-10 · **Scope:** the completed backend chain (backend-phase-0 → 15) under `server/`
|
||||
· **Method:** read-only audit of code + `dev/` docs; every finding cites the file/line it was verified at.
|
||||
Companion documents: [frontend-backend-gaps.md](frontend-backend-gaps.md) (the REQ-by-REQ contract
|
||||
reconciliation) and [runtime-services.md](runtime-services.md) (the deployment topology).
|
||||
|
||||
The chain is genuinely complete against its own specs: all 16 backend phases shipped, 358 tests are green,
|
||||
and the load-bearing invariants (balanced ledger, DB CHECKs, idempotency uniques, tenancy 404s, forward-only
|
||||
status machines) verifiably exist in code. What remains falls into eight coherent "post-phases", ordered so
|
||||
that each is a runnable unit of work: deployment blockers first, then a code-level money-correctness fix,
|
||||
then the contract batch that unblocks the frontend lane, then the infrastructure and vendor swaps the seam
|
||||
architecture was built for.
|
||||
|
||||
| Bucket | Theme | Blocking what? |
|
||||
| --- | --- | --- |
|
||||
| post-phase-1 | Security & config hygiene | Any non-local deployment |
|
||||
| post-phase-2 | Money-path correctness completion | Ledger ⇄ bank reconciliation |
|
||||
| post-phase-3 | Frontend-unblock contract batch | 11 of 12 client domains are still mock-primary |
|
||||
| post-phase-4 | Scheduling, locking & multi-instance readiness | Unattended operation; >1 API instance |
|
||||
| post-phase-5 | Identity & trust rails go real | Real nurses onboarding (OTP, KYC, docs) |
|
||||
| post-phase-6 | Money rails go real | Real payments, payouts, tax |
|
||||
| post-phase-7 | Observability, audit & ops hardening | Production diagnosability |
|
||||
| post-phase-8 | Scale & later | Search scale, analytics, doc debt |
|
||||
|
||||
Status legend used below — **Current state** always cites what the code does *today*.
|
||||
|
||||
---
|
||||
|
||||
## post-phase-1 — Security & config hygiene (do before anything is deployed)
|
||||
|
||||
Everything in this bucket is small (S) and none of it changes behavior — but each item is a deployment
|
||||
blocker, and two of them are live credential leaks sitting in git today.
|
||||
|
||||
### 1.1 Rotate and remove the committed SQL Server `sa` connection string
|
||||
|
||||
- **Why:** `appsettings.json` and `appsettings.Development.json` both commit a real connection string —
|
||||
public IP `87.107.152.16`, login `sa`, plaintext password — for the app DB *and* the log DB. Anyone with
|
||||
repo access owns the database (all PII ciphertext + the encryption keys sit in the same repo, see 1.2).
|
||||
This directly violates root `CLAUDE.md` working agreement #6 ("Never commit secrets").
|
||||
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:3-4` and
|
||||
`appsettings.Development.json:3-4` (byte-identical files); consumed at
|
||||
`server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41`
|
||||
and by the Serilog sink at
|
||||
`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:44`.
|
||||
- **Change:** rotate the `sa` password on that server (assume compromised); create a least-privilege app
|
||||
login; move both connection strings to user-secrets (dev) / environment variables (deploy); commit only a
|
||||
placeholder. Consider `git filter-repo` history scrubbing, and add a secret-scanning pre-commit hook.
|
||||
- **Files/layers:** `appsettings*.json` only (config).
|
||||
- **Effort:** S · **Risk:** low (config move) · **Deps:** none. **Do this first.**
|
||||
|
||||
### 1.2 Replace placeholder JWE signing/encryption keys and field-encryption keys
|
||||
|
||||
- **Why:** the JWT/JWE `SecretKey`/`Encryptkey` are starter-template placeholders and the PII
|
||||
field-encryption keys are committed `local-dev-…-change-me` strings. If these defaults reach any shared
|
||||
environment, every token is forgeable and every encrypted PII column is decryptable. The access-token
|
||||
lifetime is also ~7 days (`ExpirationMinutes: 10000`) and `RequireHttpsMetadata = false`.
|
||||
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:7-8` (keys), `:12` (expiry);
|
||||
`Seams:FieldEncryption` at `appsettings.json:15-18`; the JWE decryption key is wired at
|
||||
`server/src/Infrastructure/Baya.Infrastructure.Identity/ServiceConfiguration/ServiceCollectionExtension.cs:135`
|
||||
and `RequireHttpsMetadata = false` at `:139`. `SymmetricFieldEncryptor` derives a real AES-256-CBC key
|
||||
from whatever string is configured
|
||||
(`server/src/Infrastructure/Baya.Infrastructure.CrossCutting/Seams/SymmetricFieldEncryptor.cs:25`) — the
|
||||
crypto is fine; the key *management* is dev-grade (mocks-registry row `IFieldEncryptor` 🟡 agrees).
|
||||
- **Change:** per-environment secrets (env vars / Key Vault / KMS per the registry's "make it real"), a
|
||||
sane access-token lifetime (≤ 60 min; refresh flow already exists), `RequireHttpsMetadata = true` outside
|
||||
Development, real `Issuer`/`Audience` values (currently `"MyWebsite"`). Note: rotating the field key
|
||||
requires a re-encryption migration for existing rows — do it before real PII exists.
|
||||
- **Effort:** S (config) + M if key-rotation tooling is wanted · **Risk:** medium (existing dev-DB
|
||||
ciphertext becomes unreadable — acceptable pre-launch) · **Deps:** 1.1.
|
||||
|
||||
### 1.3 Remove or environment-gate the seeded `admin` / `qw123321` user
|
||||
|
||||
- **Why:** every non-Testing boot creates a well-known admin account with a hardcoded weak password and the
|
||||
full admin role — in production too.
|
||||
- **Current state:** `server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs:48`
|
||||
(`CreateAsync(user, "qw123321")`), invoked from `server/src/API/Baya.Web.Api/Program.cs:102`.
|
||||
- **Change:** read the bootstrap admin credentials from configuration and only seed when explicitly
|
||||
configured (or Development-only); force a password change on first login.
|
||||
- **Effort:** S · **Risk:** low · **Deps:** none.
|
||||
|
||||
### 1.4 Environment-gate the auto-seeded sandbox ZarinPal payment gateway
|
||||
|
||||
- **Why:** `SeedPaymentGatewaysAsync` idempotently inserts an **active** sandbox ZarinPal gateway row
|
||||
(all-zeros merchant id) on every boot — a production DB would silently contain an active sandbox money
|
||||
gateway.
|
||||
- **Current state:** `server/src/API/Baya.Web.Api/Program.cs:103` →
|
||||
`server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:106-117`
|
||||
(sandbox row, `config_json` encrypted via the DbContext converter).
|
||||
- **Change:** seed only in Development/Testing, or seed `is_active = false` and require an admin to
|
||||
activate a real gateway (`payment_gateways` is already admin data).
|
||||
- **Effort:** S · **Risk:** low · **Deps:** none; pairs with 6.1.
|
||||
|
||||
### 1.5 Fix the Kestrel HTTP/2-only default
|
||||
|
||||
- **Why:** `Kestrel:EndpointDefaults:Protocols = "Http2"` (set for the gRPC plugin) makes every endpoint
|
||||
HTTP/2-only. Browsers can't speak h2c, so any non-TLS hop — container health probes, an HTTP/1.1 reverse
|
||||
proxy leg, plain-HTTP Swagger — breaks. Locally it only works because `https://localhost:5002` negotiates
|
||||
via ALPN.
|
||||
- **Current state:** `server/src/API/Baya.Web.Api/appsettings.json:29-33` (both files). The gRPC plugin that
|
||||
motivated it serves a single duplicate OTP/token service
|
||||
(`server/src/API/Plugins/Baya.Web.Plugins.Grpc/Services/UserGrpcServices.cs:18`).
|
||||
- **Change:** default `Http1AndHttp2`; give gRPC its own `Http2` endpoint if kept (see 7.5 for the
|
||||
keep-or-remove decision).
|
||||
- **Effort:** S · **Risk:** low · **Deps:** none.
|
||||
|
||||
### 1.6 Make rate limiting proxy-aware and align the two payment webhooks
|
||||
|
||||
- **Why:** all rate-limit partitions key on `RemoteIpAddress` and no `ForwardedHeaders` middleware is
|
||||
registered — behind any reverse proxy every client shares one 100 req/min bucket (self-DoS). Separately,
|
||||
the two webhook siblings disagree: the BNPL webhook runs the 20/min `sensitive` policy while the card
|
||||
webhook has only the global 100/min fallback — one of them is wrong on purpose or both by accident.
|
||||
- **Current state:** partition key at
|
||||
`server/src/API/Baya.WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69`; global limiter
|
||||
`:35`; `Program.cs` has no `UseForwardedHeaders` (checked `server/src/API/Baya.Web.Api/Program.cs`).
|
||||
Webhooks: `Controllers/V1/WebhooksBnplController.cs:28` (`sensitive`) vs `Controllers/V1/WebhooksController.cs:25`
|
||||
(`[AllowAnonymous]`, global only).
|
||||
- **Change:** add `ForwardedHeaders` middleware (trusting only the known proxy), partition on the resolved
|
||||
client IP, and pick one deliberate webhook policy (PSP callbacks are bursty — a dedicated `webhook`
|
||||
policy keyed per-provider is safer than `sensitive`).
|
||||
- **Effort:** S · **Risk:** 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)**.
|
||||
@@ -0,0 +1,223 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Runtime services — deployment topology</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#faf9f6; --fg:#26221c; --muted:#6d675e; --panel:#ffffff; --border:#ddd7cc;
|
||||
--accent:#0e7a63; --accent-soft:#e4f2ee; --code-bg:#f1ede5; --th-bg:#efeadf;
|
||||
--warn:#a04b12; color-scheme: light dark;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root{
|
||||
--bg:#191714; --fg:#e8e3da; --muted:#a29a8d; --panel:#211e1a; --border:#3a352d;
|
||||
--accent:#4fc3a8; --accent-soft:#1e3630; --code-bg:#2a261f; --th-bg:#2d2921;
|
||||
--warn:#e09355;
|
||||
}
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--fg);
|
||||
font:16px/1.62 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Vazirmatn",sans-serif;}
|
||||
main{max-width:72rem;margin:0 auto;padding:2.5rem 1.5rem 5rem;}
|
||||
h1{font-size:1.75rem;line-height:1.25;margin:.2rem 0 1rem;}
|
||||
h2{font-size:1.35rem;margin:2.4rem 0 .7rem;padding-top:1rem;border-top:1px solid var(--border);}
|
||||
h3{font-size:1.08rem;margin:1.8rem 0 .5rem;color:var(--accent);}
|
||||
p{margin:.6rem 0;}
|
||||
a{color:var(--accent);text-decoration:none;} a:hover{text-decoration:underline;}
|
||||
code{background:var(--code-bg);border-radius:4px;padding:.1em .35em;
|
||||
font:.86em ui-monospace,"Cascadia Code",Consolas,monospace;overflow-wrap:anywhere;}
|
||||
pre{background:var(--code-bg);border:1px solid var(--border);border-radius:8px;
|
||||
padding: .9rem 1rem;overflow-x:auto;}
|
||||
pre code{background:none;padding:0;}
|
||||
hr{border:none;border-top:1px solid var(--border);margin:2rem 0;}
|
||||
.tblwrap{overflow-x:auto;margin:1rem 0;border:1px solid var(--border);border-radius:8px;}
|
||||
table{border-collapse:collapse;width:100%;font-size:.92rem;}
|
||||
th{background:var(--th-bg);text-align:start;position:sticky;top:0;}
|
||||
th,td{border-bottom:1px solid var(--border);padding:.5rem .7rem;vertical-align:top;}
|
||||
td:not(:last-child),th:not(:last-child){border-inline-end:1px solid var(--border);}
|
||||
tbody tr:last-child td{border-bottom:none;}
|
||||
ul,ol{margin:.6rem 0;padding-inline-start:1.5rem;}
|
||||
li{margin:.35rem 0;}
|
||||
li>code:first-child{font-weight:600;}
|
||||
nav.toc{background:var(--panel);border:1px solid var(--border);border-radius:10px;
|
||||
padding:1rem 1.3rem;margin:1.4rem 0 2rem;font-size:.92rem;}
|
||||
nav.toc strong{display:block;margin-bottom:.4rem;}
|
||||
nav.toc ul{margin:.2rem 0;padding-inline-start:1.1rem;list-style:none;}
|
||||
nav.toc>ul{padding-inline-start:0;}
|
||||
nav.toc li{margin:.2rem 0;}
|
||||
nav.toc .l3{padding-inline-start:1.1rem;font-size:.88em;color:var(--muted);}
|
||||
nav.toc .l3 a{color:var(--muted);}
|
||||
.crumbs{font-size:.85rem;color:var(--muted);margin-bottom:.3rem;}
|
||||
.crumbs a{color:var(--muted);}
|
||||
.stamp{font-size:.85rem;color:var(--muted);margin:-.4rem 0 1rem;}
|
||||
figure.diagram{margin:1.5rem 0;padding:1rem;background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:10px;overflow-x:auto;}
|
||||
figure.diagram svg{display:block;min-width:900px;width:100%;height:auto;}
|
||||
details.src{margin:.6rem 0 1.6rem;font-size:.85rem;color:var(--muted);}
|
||||
details.src summary{cursor:pointer;}
|
||||
svg text{fill:var(--fg);font:13px ui-sans-serif,system-ui,"Segoe UI",sans-serif;}
|
||||
svg .t2{font-size:11px;fill:var(--muted);}
|
||||
svg .grp-title{font-size:12px;font-weight:600;fill:var(--muted);letter-spacing:.04em;}
|
||||
svg .box{fill:var(--panel);stroke:var(--fg);stroke-opacity:.55;rx:8;}
|
||||
svg .box.live{stroke:var(--accent);stroke-opacity:1;stroke-width:1.6;}
|
||||
svg .box.mock{stroke-dasharray:5 4;}
|
||||
svg .grp{fill:none;stroke:var(--border);stroke-width:1.2;rx:12;}
|
||||
svg .edge{fill:none;stroke-width:1.7;}
|
||||
svg .edge.real{stroke:var(--accent);}
|
||||
svg .edge.mock{stroke:var(--muted);stroke-dasharray:6 4;}
|
||||
svg .lbl{font-size:10.5px;fill:var(--muted);}
|
||||
svg .arr-real{fill:var(--accent);} svg .arr-mock{fill:var(--muted);}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<div class="crumbs"><a href="index.html">← Post-phase server audit</a></div>
|
||||
<h1 id="runtime-services-deployment-topology">Runtime services — deployment topology</h1><p class="stamp">Generated from the canonical Markdown — do not hand-edit. Audit date 2026-07-10.</p><nav class="toc"><strong>Contents</strong><ul><li class="l2"><a href="#service-inventory">Service inventory</a></li><li class="l2"><a href="#dependency-graph">Dependency graph</a></li><li class="l2"><a href="#per-service-notes">Per-service notes</a></li><li class="l3"><a href="#1-2-sql-server-baya-baya-logs">1–2 · SQL Server (<code>Baya</code> + <code>Baya_Logs</code>)</a></li><li class="l3"><a href="#3-reverse-proxy-tls">3 · Reverse proxy / TLS</a></li><li class="l3"><a href="#4-prometheus-grafana">4 · Prometheus (+ Grafana)</a></li><li class="l3"><a href="#5-redis">5 · Redis</a></li><li class="l3"><a href="#6-minio-s3-arvancloud">6 · MinIO / S3 / ArvanCloud</a></li><li class="l3"><a href="#7-job-scheduler">7 · Job scheduler</a></li><li class="l3"><a href="#8-sms-gateway">8 · SMS gateway</a></li><li class="l3"><a href="#9-psp-ipg-shaparak-تسهیم">9 · PSP / IPG + Shaparak (تسهیم)</a></li><li class="l3"><a href="#10-bnpl-providers">10 · BNPL providers</a></li><li class="l3"><a href="#11-bank-transfer-rail-paya-satna">11 · Bank-transfer rail (PAYA/SATNA)</a></li><li class="l3"><a href="#12-سامانه-مودیان">12 · سامانه مودیان</a></li><li class="l3"><a href="#13-kyc-bridge-shahkar-e-kyc-استعلام-شبا">13 · KYC bridge (Shahkar / e-KYC / استعلام شبا)</a></li><li class="l3"><a href="#14-geocoding-neshan">14 · Geocoding (Neshan)</a></li><li class="l3"><a href="#15-review-moderation">15 · Review moderation</a></li><li class="l3"><a href="#16-moh-ino-enamad-a-process-not-a-service">16 · MoH / INO / eNamad — a process, not a service</a></li><li class="l3"><a href="#17-elasticsearch-deliberately-later">17 · Elasticsearch — deliberately later</a></li><li class="l2"><a href="#deployment-notes-from-the-code-not-aspiration">Deployment notes (from the code, not aspiration)</a></li></ul></nav>
|
||||
<p><strong>Audit date:</strong> 2026-07-10 · <strong>Derivation:</strong> every entry below is justified from the code — the DI seam that depends on it, the config key that names it, or the package/startup wiring that talks to it. Nothing is invented; "not needed" claims are backed by the absence of the package/code. Make-it-real steps live in <code>dev/shared-working-context/reports/mocks-registry.md</code> (row references below).</p>
|
||||
<p><strong>The shape in one sentence:</strong> today the API binary talks to exactly <strong>one external system — SQL Server</strong> (app DB + log DB); everything else (18 seams) is an in-process mock, so "deployment" today is one container + one database — and the table below is the roadmap of what must exist as each seam goes real.</p>
|
||||
<h2 id="service-inventory">Service inventory</h2>
|
||||
<div class="tblwrap"><table><thead><tr><th>#</th><th>Service</th><th>Purpose</th><th>Depends via (seam / config)</th><th>MVP?</th><th>Registry row</th></tr></thead><tbody><tr><td>1</td><td><strong>SQL Server</strong> (app DB <code>Baya</code>)</td><td>System of record — 12 schemas (<code>usr ops geo catalog verif search booking payments payouts reviews messaging partner</code>)</td><td>EF Core; <code>ConnectionStrings:SqlServer</code></td><td><strong>Required now</strong></td><td>—</td></tr><tr><td>2</td><td><strong>SQL Server</strong> (log DB <code>Baya_Logs</code>)</td><td>Serilog sink in deployed envs (Warning+, auto-created <code>log.LogEvents</code>)</td><td><code>ConnectionStrings:logDb</code></td><td><strong>Required now</strong> (deployed)</td><td>—</td></tr><tr><td>3</td><td><strong>Reverse proxy / TLS</strong> (nginx·caddy·traefik)</td><td>TLS termination, HTTP/1.1+2, forwarded headers</td><td>Kestrel config; JWE bearer</td><td><strong>Required now</strong></td><td>—</td></tr><tr><td>4</td><td><strong>Prometheus</strong> (+ Grafana)</td><td>Scrapes <code>/metrics</code>; health forwarded to gauges</td><td><code>UseMetricServer</code> + OTel exporter</td><td><strong>Recommended now</strong></td><td>—</td></tr><tr><td>5</td><td><strong>Redis</strong></td><td><code>ICacheService</code> + <code>IDistributedLock</code> (money-path mutex)</td><td><code>Seams:*</code> (keys TBD; none today)</td><td>Before >1 API instance</td><td>rows 14, 42</td></tr><tr><td>6</td><td><strong>MinIO / S3 / ArvanCloud</strong></td><td><code>IObjectStorage</code> — verification docs, avatars (REQ-006), invoice PDFs</td><td><code>Seams:ObjectStorage:*</code></td><td>Before real verification</td><td>row 13</td></tr><tr><td>7</td><td><strong>Job scheduler</strong> (Hangfire/Quartz, in-app on SQL)</td><td>The deferred crons: payout batch, expiry scan, no-show, Moadian poll</td><td>hosted services (no interface exists)</td><td>Before unattended ops</td><td>row 26</td></tr><tr><td>8</td><td><strong>SMS gateway</strong> (Kavenegar·Ghasedak·SMS.ir)</td><td><code>ISmsSender</code> — OTP delivery (login is impossible without it)</td><td><code>Seams:Sms:*</code> (to be added)</td><td><strong>Launch-critical</strong></td><td>row 12</td></tr><tr><td>9</td><td><strong>PSP / IPG + Shaparak</strong> (ZarinPal·Sadad·Vandar·Jibit)</td><td><code>IPaymentProvider</code> + <code>IWebhookVerifier</code> + <code>ISettlementSplitProvider</code> (تسهیم)</td><td>encrypted <code>payment_gateways.config_json</code></td><td>Real payments</td><td>rows 39–41</td></tr><tr><td>10</td><td><strong>BNPL providers</strong> (SnappPay·Digipay)</td><td><code>IBnplProvider</code> / <code>IBnplProviderResolver</code> / <code>ICurrencyNormalizer</code></td><td><code>Seams:Bnpl:*</code>, <code>Seams:Currency:*</code>, gateway config</td><td>Optional at launch</td><td>rows 46–47</td></tr><tr><td>11</td><td><strong>Bank-transfer rail</strong> (Jibit·Vandar·Sadad payout API → PAYA/SATNA)</td><td><code>IBankTransferProvider</code> — weekly nurse payouts</td><td><code>Seams:BankTransfer:*</code></td><td>Real payouts</td><td>row 23</td></tr><tr><td>12</td><td><strong>سامانه مودیان</strong> (tax e-invoicing)</td><td><code>IMoadianClient</code> — legal invoice registration</td><td><code>Seams:Moadian:*</code> + signing cert</td><td>Legal — soon after launch</td><td>row 45</td></tr><tr><td>13</td><td><strong>KYC bridge vendor</strong> (Finnotech-class)</td><td><code>IShahkarVerifier</code> + <code>IIdentityKycProvider</code> + <code>IBankAccountOwnershipVerifier</code></td><td><code>Seams:Shahkar:*</code>, <code>Seams:IdentityKyc:*</code>, <code>Seams:BankOwnership:*</code></td><td>Real verification + payout gate</td><td>rows 27, 28, 30</td></tr><tr><td>14</td><td><strong>Geocoding</strong> (Neshan)</td><td><code>IGeocoder</code> — address → coordinates (EVV distance)</td><td><code>Seams:Geocoding:*</code></td><td>With real EVV</td><td>row 31</td></tr><tr><td>15</td><td><strong>Review-moderation classifier</strong> (LLM/API)</td><td><code>IReviewModerationService</code> — AI pre-screen</td><td><code>Seams:ReviewModeration:*</code></td><td>Optional (human queue is default)</td><td>row 33</td></tr><tr><td>16</td><td><strong>MoH / INO / eNamad</strong></td><td><code>ICredentialVerifier</code> + <code>ILicenseVerificationService</code> — <strong>manual admin process; no public B2B API exists</strong></td><td><code>Seams:LicenseVerification:*</code></td><td>Manual = the MVP design</td><td>rows 29, 50</td></tr><tr><td>17</td><td><strong>Elasticsearch</strong></td><td><code>INurseSearch</code> alt backend + outbox feeder</td><td><code>Search:Backend</code> (non-<code>sql</code> throws today)</td><td><strong>Not MVP</strong> — SQL search is real</td><td>rows 38, 43</td></tr></tbody></table></div>
|
||||
<p><strong>Explicitly not needed</strong> (verified absent from <code>server/Directory.Packages.props</code> and code): message broker (no RabbitMQ/Kafka), Redis today (no <code>StackExchange.Redis</code>), Hangfire/Quartz today, Elasticsearch client (<code>Elastic.Clients.*</code>), any cloud SDK, any payment/SMS vendor SDK. The only externally-pointing package beyond SQL Server is <code>Serilog.Sinks.Elasticsearch</code> — its wiring is commented out (<code>Baya.Infrastructure.CrossCutting/Logging/LoggingConfiguration.cs:58-70</code>).</p>
|
||||
<hr>
|
||||
<h2 id="dependency-graph">Dependency graph</h2>
|
||||
<p>Solid edges are live today; dashed edges are behind a mocked seam (the arrow exists in code, the wire does not). One line per node below the graph.</p>
|
||||
<figure class="diagram"><svg viewBox="0 0 1160 830" role="img" aria-label="Balinyaar runtime dependency graph">
|
||||
<defs>
|
||||
<marker id="arr-real" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="arr-real" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
<marker id="arr-mock" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="arr-mock" d="M0,0 L10,5 L0,10 z"/></marker>
|
||||
</defs>
|
||||
|
||||
<rect class="box live" x="20" y="150" width="210" height="62" rx="8"/><text x="125" y="178" text-anchor="middle" font-weight="600">Next.js client</text><text class="t2" x="125" y="195" text-anchor="middle">HTTPS · JSON (camelCase)</text>
|
||||
<rect class="box live" x="20" y="430" width="210" height="62" rx="8"/><text x="125" y="458" text-anchor="middle" font-weight="600">Prometheus (+ Grafana)</text><text class="t2" x="125" y="475" text-anchor="middle">scrapes /metrics + health</text>
|
||||
|
||||
<rect class="grp" x="290" y="90" width="270" height="330" rx="12"/>
|
||||
<text class="grp-title" x="425" y="114" text-anchor="middle">API HOST · Baya.Web.Api (:5002)</text>
|
||||
<rect class="box live" x="310" y="132" width="230" height="78" rx="8"/><text x="425" y="150" text-anchor="middle" font-weight="600">ASP.NET Core API</text><text class="t2" x="425" y="166" text-anchor="middle">REST /api/v1 · Swagger</text><text class="t2" x="425" y="180" text-anchor="middle">/metrics · /HealthCheck · gRPC</text>
|
||||
<rect class="box live" x="310" y="250" width="230" height="62" rx="8"/><text x="425" y="278" text-anchor="middle" font-weight="600">In-proc interval jobs</text><text class="t2" x="425" y="295" text-anchor="middle">expiry 1min · retention 24h</text>
|
||||
<path class="edge real" d="M 425 250 L 425 210"/>
|
||||
<text class="t2" x="425" y="345" text-anchor="middle">18 vendor seams resolve</text>
|
||||
<text class="t2" x="425" y="361" text-anchor="middle">in-process (mocks) today —</text>
|
||||
<text class="t2" x="425" y="377" text-anchor="middle">dashed edges = the wire to build</text>
|
||||
|
||||
<rect class="grp" x="640" y="20" width="500" height="216" rx="12"/>
|
||||
<text class="grp-title" x="660" y="44">DATA & PLATFORM INFRA</text>
|
||||
<rect class="box live" x="660" y="58" width="225" height="62" rx="8"/><text x="772.5" y="86" text-anchor="middle" font-weight="600">SQL Server «Baya»</text><text class="t2" x="772.5" y="103" text-anchor="middle">12 schemas · system of record</text>
|
||||
<rect class="box live" x="900" y="58" width="220" height="62" rx="8"/><text x="1010" y="86" text-anchor="middle" font-weight="600">SQL Server «Baya_Logs»</text><text class="t2" x="1010" y="103" text-anchor="middle">Serilog sink (deployed envs)</text>
|
||||
<rect class="box mock" x="660" y="146" width="225" height="62" rx="8"/><text x="772.5" y="174" text-anchor="middle" font-weight="600">Redis</text><text class="t2" x="772.5" y="191" text-anchor="middle">cache + dist. lock — in-proc now</text>
|
||||
<rect class="box mock" x="900" y="146" width="220" height="62" rx="8"/><text x="1010" y="174" text-anchor="middle" font-weight="600">MinIO / S3</text><text class="t2" x="1010" y="191" text-anchor="middle">object storage — local disk now</text>
|
||||
|
||||
<rect class="grp" x="640" y="260" width="500" height="216" rx="12"/>
|
||||
<text class="grp-title" x="660" y="284">MONEY RAILS (ALL MOCKED TODAY)</text>
|
||||
<rect class="box mock" x="660" y="298" width="225" height="62" rx="8"/><text x="772.5" y="326" text-anchor="middle" font-weight="600">PSP / IPG + Shaparak</text><text class="t2" x="772.5" y="343" text-anchor="middle">capture · webhook · تسهیم</text>
|
||||
<rect class="box mock" x="900" y="298" width="220" height="62" rx="8"/><text x="1010" y="326" text-anchor="middle" font-weight="600">BNPL</text><text class="t2" x="1010" y="343" text-anchor="middle">SnappPay / Digipay</text>
|
||||
<rect class="box mock" x="660" y="386" width="225" height="62" rx="8"/><text x="772.5" y="414" text-anchor="middle" font-weight="600">PAYA / SATNA rail</text><text class="t2" x="772.5" y="431" text-anchor="middle">weekly nurse payouts</text>
|
||||
<rect class="box mock" x="900" y="386" width="220" height="62" rx="8"/><text x="1010" y="414" text-anchor="middle" font-weight="600">سامانه مودیان</text><text class="t2" x="1010" y="431" text-anchor="middle">tax e-invoicing</text>
|
||||
|
||||
<rect class="grp" x="640" y="500" width="500" height="300" rx="12"/>
|
||||
<text class="grp-title" x="660" y="524">TRUST & IDENTITY RAILS (ALL MOCKED TODAY)</text>
|
||||
<rect class="box mock" x="660" y="538" width="225" height="62" rx="8"/><text x="772.5" y="566" text-anchor="middle" font-weight="600">SMS gateway (OTP)</text><text class="t2" x="772.5" y="583" text-anchor="middle">launch-critical — logs only now</text>
|
||||
<rect class="box mock" x="900" y="538" width="220" height="62" rx="8"/><text x="1010" y="566" text-anchor="middle" font-weight="600">KYC bridge</text><text class="t2" x="1010" y="583" text-anchor="middle">Shahkar · e-KYC · استعلام شبا</text>
|
||||
<rect class="box mock" x="660" y="626" width="225" height="62" rx="8"/><text x="772.5" y="654" text-anchor="middle" font-weight="600">Neshan geocoding</text><text class="t2" x="772.5" y="671" text-anchor="middle">EVV distance accuracy</text>
|
||||
<rect class="box mock" x="900" y="626" width="220" height="62" rx="8"/><text x="1010" y="654" text-anchor="middle" font-weight="600">Review moderation</text><text class="t2" x="1010" y="671" text-anchor="middle">optional — human queue default</text>
|
||||
<rect class="box mock" x="660" y="714" width="460" height="62" rx="8"/><text x="890" y="742" text-anchor="middle" font-weight="600">MoH / INO / eNamad</text><text class="t2" x="890" y="759" text-anchor="middle">manual admin review — no public API exists</text>
|
||||
|
||||
<path class="edge real" d="M 230 181 C 270 181, 270 171, 310 171" marker-end="url(#arr-real)"/><text class="lbl" x="270" y="170" text-anchor="middle">HTTPS/JSON</text>
|
||||
<path class="edge real" d="M 230 455 C 260 455, 260 400, 290 400" marker-end="url(#arr-real)"/><text class="lbl" x="260" y="419.5" text-anchor="middle">scrape</text>
|
||||
<path class="edge real" d="M 560 130 C 600 130, 600 100, 640 100" marker-end="url(#arr-real)"/><text class="lbl" x="600" y="99" text-anchor="middle">EF Core · Serilog</text>
|
||||
<path class="edge mock" d="M 560 170 C 600 170, 600 185, 640 185" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="193.5" text-anchor="middle">cache · lock · files</text>
|
||||
<path class="edge mock" d="M 560 245 C 600 245, 600 320, 640 320" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="268.5" text-anchor="middle">capture · payouts · invoices</text>
|
||||
<path class="edge mock" d="M 640 395 C 600 395, 600 290, 560 290" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="356.5" text-anchor="middle">webhooks / callbacks</text>
|
||||
<path class="edge mock" d="M 560 335 C 600 335, 600 610, 640 610" marker-end="url(#arr-mock)"/><text class="lbl" x="600" y="466.5" text-anchor="middle">OTP · استعلام · geocode · moderation</text>
|
||||
|
||||
<rect class="grp" x="20" y="600" width="210" height="130" rx="12"/>
|
||||
<text class="grp-title" x="125" y="624" text-anchor="middle">LEGEND</text>
|
||||
<path class="edge real" d="M 40 646 L 90 646"/><text class="t2" x="100" y="650">live today</text>
|
||||
<path class="edge mock" d="M 40 674 L 90 674"/><text class="t2" x="100" y="678">mocked seam — to build</text>
|
||||
<rect class="box mock" x="40" y="694" width="50" height="20" rx="6"/><text class="t2" x="100" y="708">service to provision</text>
|
||||
</svg></figure>
|
||||
<details class="src"><summary>Mermaid source (canonical, in the .md)</summary><pre><code>flowchart LR
|
||||
subgraph fe[Frontend]
|
||||
WEB["Next.js client"]
|
||||
end
|
||||
subgraph host["API host — Baya.Web.Api (:5002)"]
|
||||
API["ASP.NET Core API\nREST /api/v1 · /metrics · /HealthCheck"]
|
||||
JOBS["In-proc interval jobs\n(→ Hangfire/Quartz later)"]
|
||||
end
|
||||
subgraph data[Data & 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</code></pre></details>
|
||||
<ul><li><strong>Next.js client</strong> — the only API consumer; reads <code>NEXT_PUBLIC_API_URL</code> (root <code>CLAUDE.md</code>).</li><li><strong>API</strong> — single ASP.NET Core host; all seams resolve in-process today.</li><li><strong>In-proc jobs</strong> — the two <code>BackgroundService</code> sweeps (booking-request expiry 1 min, notification retention 24 h); the scheduler upgrade re-homes them (plan §4.1).</li><li><strong>SQL Server <code>Baya</code></strong> — system of record; migrations + seeding run on every non-Testing boot.</li><li><strong>SQL Server <code>Baya_Logs</code></strong> — deployed-env Serilog sink (auto-creates DB/table).</li><li><strong>Redis</strong> — target for <code>ICacheService</code>/<code>IDistributedLock</code>; nothing speaks Redis yet.</li><li><strong>MinIO/S3</strong> — target for <code>IObjectStorage</code>; local disk + <code>file://</code> URLs today.</li><li><strong>PSP/IPG</strong> — card capture, callback signatures, تسهیم settlement split.</li><li><strong>BNPL</strong> — provider-financed installments; settle/revert callbacks.</li><li><strong>PAYA/SATNA</strong> — weekly nurse payout batches + async reconciliation.</li><li><strong>مودیان</strong> — legal e-invoice registration (pending→registered poll).</li><li><strong>KYC vendor</strong> — Shahkar phone↔NID, identity+liveness, Sheba ownership (payout gate).</li><li><strong>Neshan</strong> — geocoding for address coordinates / EVV distance.</li><li><strong>Moderation classifier</strong> — optional AI pre-screen; human moderation is the default gate.</li><li><strong>MoH/INO/eNamad</strong> — human verification workflows, by design (no API exists).</li><li><strong>Prometheus</strong> — scrapes <code>/metrics</code>; health check results forwarded as gauges.</li></ul>
|
||||
<hr>
|
||||
<h2 id="per-service-notes">Per-service notes</h2>
|
||||
<h3 id="1-2-sql-server-baya-baya-logs">1–2 · SQL Server (<code>Baya</code> + <code>Baya_Logs</code>)</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>UseSqlServer</code> at <code>server/src/Infrastructure/Baya.Infrastructure.Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:41</code>; sink at <code>…CrossCutting/Logging/LoggingConfiguration.cs:44-45</code> (schema <code>log</code>, auto-create); the 12 schemas via per-entity <code>ToTable(name, schema)</code> (e.g. <code>PaymentsConfig/LedgerEntryConfig.cs:18</code>, <code>PayoutsConfig/NursePayoutConfig.cs:19</code>).</li><li><strong>Default:</strong> <code>mcr.microsoft.com/mssql/server:2022-latest</code> (Developer for dev; licensed edition in prod). Both DBs fit one instance; <code>Baya_Logs</code> can move later.</li><li><strong>Config:</strong> <code>ConnectionStrings:SqlServer</code>, <code>ConnectionStrings:logDb</code> — <strong>rotate + externalize first</strong> (plan §1.1; live <code>sa</code> credentials are committed today).</li><li><strong>Health/readiness:</strong> the app's only health check (<code>/HealthCheck</code>, <code>Monitoring/Configurations/HealthCheckConfigurations.cs:17</code>); <code>logDb</code> has none (plan §7.2). Boot runs <code>MigrateAsync</code> + 3 seeders (<code>Program.cs:99-104</code>) → the login needs DDL rights and concurrent multi-node boot races (plan §4.3).</li></ul>
|
||||
<h3 id="3-reverse-proxy-tls">3 · Reverse proxy / TLS</h3>
|
||||
<ul><li><strong>Evidence of need:</strong> JWE bearer auth (<code>RequireHttpsMetadata</code> must be true in prod — <code>Identity/ServiceConfiguration/ServiceCollectionExtension.cs:139</code>); Kestrel <code>EndpointDefaults=Http2</code> breaks non-TLS HTTP/1.1 (<code>appsettings.json:29-33</code>, plan §1.5); the rate limiter partitions on <code>RemoteIpAddress</code> with <strong>no ForwardedHeaders middleware</strong> (<code>WebFramework/ServiceConfiguration/RateLimitingServiceExtension.cs:69</code>, plan §1.6) — the proxy must pass <code>X-Forwarded-For</code> <em>and</em> the app must be taught to honor it.</li><li><strong>Default:</strong> caddy 2 / nginx 1.27; terminate TLS, h2 to clients, HTTP/1.1 (or h2c) upstream once §1.5 lands.</li></ul>
|
||||
<h3 id="4-prometheus-grafana">4 · Prometheus (+ Grafana)</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>/metrics</code> via prometheus-net <code>UseMetricServer</code> + OTel <code>AddPrometheusExporter</code> (two stacks — consolidate, plan §7.1) at <code>Monitoring/Configurations/PrometheusMetricsConfigurations.cs:11</code> and <code>OpenTelemetryConfigurations.cs:21</code>; health forwarded (<code>HealthCheckConfigurations.cs:18</code>).</li><li><strong>Default:</strong> <code>prom/prometheus:v2.53</code> + <code>grafana/grafana:11</code>. No tracing backend exists yet (metrics-only); an OTLP collector becomes relevant with plan §7.1.</li></ul>
|
||||
<h3 id="5-redis">5 · Redis</h3>
|
||||
<ul><li><strong>Evidence of the gap:</strong> <code>MemoryCacheService</code> and <code>InProcessDistributedLock</code> (<code>CrossCutting/Seams/MemoryCacheService.cs:11</code>, <code>InProcessDistributedLock.cs:14</code>) — single-process only; the money-path lock convention <code>booking:{id}:payment|refund</code> is already in the handlers.</li><li><strong>Default:</strong> <code>redis:7-alpine</code> (AOF on). <strong>Required the moment a second API instance runs</strong> (shared cache invalidation generation-tokens + cross-instance money mutex). Config keys to be introduced with the swap (plan §4.2); none exist today.</li></ul>
|
||||
<h3 id="6-minio-s3-arvancloud">6 · MinIO / S3 / ArvanCloud</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>LocalDiskObjectStorage</code> writes under a temp root and returns <code>file://</code> URLs (<code>CrossCutting/Seams/LocalDiskObjectStorage.cs:20,56</code>); consumers: b6 verification documents (signed-URL upload flow), future avatars (REQ-006), invoice <code>PdfStorageKey</code> (<code>InvoicesConfig/InvoiceConfig.cs</code> — <code>pdf_storage_key</code> column).</li><li><strong>Default:</strong> <code>minio/minio:latest</code> (S3-compatible; ArvanCloud object storage is the Iran-hosted option).</li><li><strong>Config:</strong> <code>Seams:ObjectStorage:RootPath</code> today → bucket/endpoint/keys with the swap. Presigned PUT/GET with expiry is the contract the frontend already codes against.</li></ul>
|
||||
<h3 id="7-job-scheduler">7 · Job scheduler</h3>
|
||||
<ul><li><strong>Evidence:</strong> two <code>PeriodicTimer</code> hosted services only (<code>Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:63,67</code>); the payout/expiry/no-show/ Moadian crons are admin-manual with seeded-but-unread cadence keys (plan §4.1). <strong>No <code>IJobScheduler</code> interface exists</strong> — the registry name is aspirational.</li><li><strong>Default:</strong> Hangfire on the existing SQL Server (no new container) — dashboard behind admin auth; or Quartz with SQL persistence. Not a separate service to "spin up", but it changes the SQL footprint (schema) and ops (dashboard, retries).</li></ul>
|
||||
<h3 id="8-sms-gateway">8 · SMS gateway</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>LoggingSmsSender</code> logs OTPs instead of sending (<code>CrossCutting/Seams/LoggingSmsSender.cs:16</code>) — <strong>no real user can log in</strong>; per-phone resend window + <code>otp</code> rate policy already enforced upstream.</li><li><strong>Default:</strong> Kavenegar / Ghasedak / SMS.ir (SaaS — API key, no container). Keys to add: <code>Seams:Sms:{ApiKey,SenderLine,BaseUrl}</code> (registry row 12). Template/pattern OTP send for deliverability.</li></ul>
|
||||
<h3 id="9-psp-ipg-shaparak-تسهیم">9 · PSP / IPG + Shaparak (تسهیم)</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>MockPaymentProvider</code> (instant success, <code>VerifyAsync</code> echoes the expected amount — <code>CrossCutting/Seams/MockPaymentProvider.cs:24</code>), <code>MockWebhookVerifier</code> (marker-based "signature" — <code>MockWebhookVerifier.cs:21</code>), <code>MockSettlementSplitProvider</code> (<code>MockSettlementSplitProvider.cs:13</code>). Real merchant credentials belong in the <strong>encrypted</strong> <code>payment_gateways.config_json</code> (seeded sandbox row: <code>Persistence/ServiceCollectionExtensions.cs:109-117</code> — environment-gate it, plan §1.4).</li><li><strong>Default:</strong> SaaS (ZarinPal / Sadad / Vandar / Jibit) — needs merchant + terminal registration and تسهیم (settlement-split) setup to registered IBANs; webhook endpoint is already public (<code>POST webhooks/payments/{provider}</code>) with upsert-first idempotency in place.</li></ul>
|
||||
<h3 id="10-bnpl-providers">10 · BNPL providers</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>MockBnplProvider</code> drives the full verb set + state machine (<code>CrossCutting/Seams/MockBnplProvider.cs:17-53</code>); callback endpoint <code>WebhooksBnplController</code> (signed, rate-limited). <code>Seams:Bnpl:*</code> + <code>Seams:Currency:TomanToIrrMultiplier</code> (<code>SeamOptions.cs:98-120,79</code>).</li><li><strong>Default:</strong> SnappPay and/or Digipay (SaaS, OAuth). <strong>Do plan §2.1 first</strong> — the revert-clearing path is currently unreachable, so real BNPL refunds would strand ledger state.</li></ul>
|
||||
<h3 id="11-bank-transfer-rail-paya-satna">11 · Bank-transfer rail (PAYA/SATNA)</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>MockBankTransferProvider</code> settles every instruction instantly (<code>CrossCutting/Seams/MockBankTransferProvider.cs:18-31</code>); the handler already chooses PAYA vs SATNA by <code>payout_satna_threshold_irr</code> (<code>platform_configs</code> seed row 22) and the irreversibility backstops (unconditional <code>UNIQUE(booking_id)</code> link, forward-only payout machine) are in place.</li><li><strong>Default:</strong> Jibit / Vandar / Sadad payout API (SaaS). Needs the source settlement account + the async <code>submitted → paid/failed</code> reconciliation callback the mock collapses (registry row 23, steps 3–4).</li></ul>
|
||||
<h3 id="12-سامانه-مودیان">12 · سامانه مودیان</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>MockMoadianClient</code> leaves invoices <code>pending</code> forever (<code>CrossCutting/Seams/MockMoadianClient.cs:21</code>); no reconciliation job exists (plan §6.5). VAT-on-commission + sequential invoice numbers are already correct server-side.</li><li><strong>Default:</strong> government SaaS — enrollment (memory/economic code) + a signing certificate; the certificate is a deploy-time secret.</li></ul>
|
||||
<h3 id="13-kyc-bridge-shahkar-e-kyc-استعلام-شبا">13 · KYC bridge (Shahkar / e-KYC / استعلام شبا)</h3>
|
||||
<ul><li><strong>Evidence:</strong> three deterministic mocks with magic-value failure cases (<code>MockShahkarVerifier.cs:26-37</code>, <code>MockIdentityKycProvider.cs:25</code>, <code>MockBankAccountOwnershipVerifier.cs:26</code>); handlers already persist <code>external_response_json</code> and treat shared-SIM as a handled state. The Sheba-ownership result gates first payouts (<code>matched_national_id</code>).</li><li><strong>Default:</strong> one Finnotech-class bridge covers all three استعلامها (SaaS; API keys under <code>Seams:{Shahkar,IdentityKyc,BankOwnership}:*</code>).</li></ul>
|
||||
<h3 id="14-geocoding-neshan">14 · Geocoding (Neshan)</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>MockGeocoder</code> jitters ±5 km around 8 hardcoded centroids (<code>MockGeocoder.cs:52</code>) — EVV distance checks are noise until this (and/or REQ-008's user pin) is real. <code>Seams:Geocoding:*</code> is one of only three seam sections present in <code>appsettings.json</code> (<code>:22-26</code>).</li><li><strong>Default:</strong> Neshan (Iran coverage; SaaS API key), rate-limit/retry per registry row 31.</li></ul>
|
||||
<h3 id="15-review-moderation">15 · Review moderation</h3>
|
||||
<ul><li><strong>Evidence:</strong> keyword-list mock; clean text stays in the human queue by default (<code>MockReviewModerationService.cs:23</code>; <code>Seams:ReviewModeration:*</code>). Human <code>ModerateReviewCommand</code> retains decision authority — so this can stay mocked indefinitely at low volume.</li><li><strong>Default:</strong> any text-moderation API / LLM endpoint when review volume outgrows the human queue.</li></ul>
|
||||
<h3 id="16-moh-ino-enamad-a-process-not-a-service">16 · MoH / INO / eNamad — a process, not a service</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>MockCredentialVerifier</code> always returns <code>RequiresManualReview</code> (<code>MockCredentialVerifier.cs:18</code>); <code>MockLicenseVerificationService</code> likewise (<code>MockLicenseVerificationService.cs:26</code>). The registry itself records <strong>no public B2B API exists</strong> — the admin review queue <em>is</em> the real implementation. Provision: admin staffing + the f15 console.</li></ul>
|
||||
<h3 id="17-elasticsearch-deliberately-later">17 · Elasticsearch — deliberately later</h3>
|
||||
<ul><li><strong>Evidence:</strong> <code>SqlNurseSearch</code> is the real MVP backend; <code>Search:Backend</code> ≠ <code>sql</code> throws at startup (<code>Persistence/ServiceConfiguration/ServiceCollectionExtensions.cs:74-78</code>); no ES client package.</li><li><strong>Default when needed:</strong> <code>elasticsearch:8.x</code> + the outbox feeder (registry rows 38/43; plan §8.1).</li></ul>
|
||||
<hr>
|
||||
<h2 id="deployment-notes-from-the-code-not-aspiration">Deployment notes (from the code, not aspiration)</h2>
|
||||
<ol><li><strong>Boot = migrate + seed.</strong> Every non-Testing start applies EF migrations and seeds roles, the <code>admin</code>/<code>qw123321</code> user, and an <strong>active sandbox ZarinPal gateway</strong> (<code>Program.cs:99-104</code>). Until plan §1.3/§1.4/§4.3 land: single-instance start-up, DDL-privileged login, and clean up the seeded credentials per environment.</li><li><strong>Environment files:</strong> <code>appsettings.json</code> ≡ <code>appsettings.Development.json</code> (byte-identical); <strong>no Production/Staging file exists.</strong> All non-secret env differences ride on ~14 <code>Seams:*</code> groups whose defaults live in code (<code>SeamOptions.cs</code>), not in config files.</li><li><strong>HTTP posture:</strong> HTTP/2-only Kestrel default (plan §1.5), gRPC plugin + reflection always on (plan §7.5), TLS required for JWE sanity.</li><li><strong>Single-instance constraints today:</strong> in-memory cache, in-proc money lock, in-proc sweeps, per-instance rate-limit buckets. Scaling past one instance requires plan §4.2 (Redis) + §4.3 (migrations) first — the DB uniques keep money <em>correct</em> either way, but locks/cache/limits silently degrade.</li><li><strong>Logs:</strong> deployed envs write Warning+ to <code>Baya_Logs</code> only (Information dropped — plan §7.3); dev writes console + <code>logs/log.json</code>.</li><li><strong>Client:</strong> the Next.js app needs <code>NEXT_PUBLIC_API_URL</code> pointing at the proxy; wire casing camelCase; snake_case routes.</li></ol>
|
||||
</main></body></html>
|
||||
@@ -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.
|
||||
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# Frontend phase 9 report — Checkout, card payment & invoice (consumes b10 + the invoice slice of b11)
|
||||
|
||||
**Status:** complete · gate green (`npm run check`, `npm run test:ci` — 204 tests, production build) · 2026-07-10
|
||||
**Scope shipped:** the C6 خلاصه و پرداخت checkout, the card-payment state machine (initiate → redirect →
|
||||
pending-callback → succeeded→confirmed / failed→retry), the confirmation screen, the invoice view, the
|
||||
`services/payment` domain, and three shared money composites.
|
||||
|
||||
---
|
||||
|
||||
## 1. What was built
|
||||
|
||||
### `services/payment` (new domain — mirrors the `auth`/`bookings` shape)
|
||||
|
||||
| File | What it is |
|
||||
| --- | --- |
|
||||
| `types.ts` | Contract-derived DTOs + the `PaymentApi` seam. `PaymentTransactionStatus = pending\|succeeded\|failed` (the b10 enum — the phase file's illustrative `initiated/cancelled` states do **not** exist on the wire and were not used). `CheckoutSummaryDto` (REQ-016 shape), `InitiatePaymentResult` (b10 swagger), `PaymentOutcomeDto` (poll target), `InvoiceDto` (b11 swagger, flat totals — **no line-items array exists in the contract**). |
|
||||
| `keys.ts` | `paymentKeys.summary(requestId)` / `.outcome(requestId)` / `.invoice(bookingId)`. |
|
||||
| `constants.ts` | `USE_PAYMENT_MOCK=true` (why documented in-file), `BNPL_ENABLED=false` (the f11 seam gate), poll backoff tuning, checkout query-param names, mock money rates. |
|
||||
| `apis/clientApi.ts` | Real impl: **initiate** = `POST api/v1/bookings/{id}/payments` + **`Idempotency-Key` header** (no body — contract-exact); **invoice** = `GET api/v1/invoices/{bookingId}`; **summary** targets the REQ-016 proposed slug; **outcome** maps `GET booking_requests/get/{id}` (`converted`→succeeded, `payment_deadline_expired`→failed, else pending — REQ-017). |
|
||||
| `apis/mockApi.ts` | Mock-primary state machine — see §3. |
|
||||
| `invalidations.ts` | `invalidateAfterPaymentSuccess` — the one post-capture cache transition (request detail/lists + bookings lists/detail + this request's summary/outcome). Exact keys, never a blanket refetch. |
|
||||
| `hooks/` | `useCheckoutSummary` (short stale), `useInitiatePayment` (caller owns the per-attempt key), `useConfirmGatewayReturn` (primes the outcome key; invalidates on immediate success), `usePaymentOutcome` (**geometric-backoff poll**: 2s → ×1.5 → cap 15s, stops on terminal outcome or 40 attempts; exports `isTerminalPaymentOutcome`), `useInvoice` (immutable → long stale; **404 = "not issued", not retried**). |
|
||||
|
||||
### Screens (customer shell)
|
||||
|
||||
- **C6** `/bookings/checkout?request_id=` — «✓ پرستار تایید کرد» badge (reuses `booking.accepted_badge`),
|
||||
nurse/service/schedule mini-summary, the payment-window `CountdownTimer`, the **served reconciling
|
||||
breakdown** (هزینه خدمت / کارمزد بالینیار / مالیات بر ارزش افزوده / **مبلغ کل**) via `PriceBreakdown`,
|
||||
the verbatim `EscrowNotice`, «ادامه پرداخت ←» (terracotta `secondary`), and the **BNPL seam** — a
|
||||
disabled outlined «یا پرداخت اقساطی» + "coming soon" caption gated by `BNPL_ENABLED` for f11 to wire to D1.
|
||||
Non-payable statuses render convergence cards (already-paid → outcome; window-expired / other terminal → back to C5).
|
||||
- **Gateway harness** `/bookings/checkout/gateway` — **test-only** fake PSP (labelled آزمایشی): success +
|
||||
failure buttons so both return branches are drivable without a gateway. The phase file suggested
|
||||
auto-success; buttons were chosen instead so §7 step 5 (failed attempt → new idempotency key) is testable by a human.
|
||||
- **Return** `/bookings/checkout/return` — fires `useConfirmGatewayReturn` once per mount (ref-guarded; a
|
||||
refresh replays it and converges idempotently), then the pending-callback poll. Succeeded → invalidate +
|
||||
`router.replace` to confirmation (once, ref-guarded, no double invalidation between mutation and poll
|
||||
paths); failed → retry (back to a fresh C6 mount = **new attempt, new key**); window lapsed → back to C5.
|
||||
A manual «بررسی دوباره» covers the bounded poll giving up.
|
||||
- **Confirmation** `/bookings/checkout/confirmation` — success state, amount-paid card, «مشاهده رزرو» →
|
||||
`/bookings/{bookingId}` (falls back to the list without a bookingId — REQ-017), «دانلود فاکتور» →
|
||||
`/bookings/{bookingId}/invoice` (hidden without a bookingId).
|
||||
- **Invoice** `/bookings/[id]/invoice` — `invoiceNumber` + Shamsi issue date, `PriceBreakdown` rows where
|
||||
the **service line is the exact integer remainder** (gross − commission − VAT, via `parseIrr` BigInt) so
|
||||
the lines reconcile by construction, the VAT line labelled **«مالیات بر ارزش افزوده (بر کارمزد
|
||||
بالینیار)»** (product rule: the nurse is never implied to be taxed), read-only **مودیان** state chip
|
||||
(`moadian_*`), and `pdfUrl` download **or** a print receipt (`window.print()` + a print-scoped
|
||||
visibility rule isolating the invoice card). 404 renders «فاکتور هنوز صادر نشده است» (REQ-018).
|
||||
|
||||
### Shared composites (each with a co-located test)
|
||||
|
||||
- **`PriceBreakdown`** — typed rows + total, all IRR digit-strings through `formatIrrToToman`; dev-guard
|
||||
`console.error`s if rows ≠ total (test proves rows render, total = Σ rows, and the guard fires on mismatch).
|
||||
- **`EscrowNotice`** — wraps `AppAlert` (info severity, `--bal-primary` text on `--bal-primary-soft`, lock icon). Its test mocks next-intl to
|
||||
read **the real `fa.json`**, pinning the mandated copy verbatim: a rewording fails the suite.
|
||||
- **`PaymentStatusBadge`** — full `PaymentTransactionStatus` → `StatusChip` kind map (`Record` typed, so an
|
||||
enum change breaks the build); labels from `payment.pstatus_*`.
|
||||
|
||||
### Extensions to prior phases (in place, per operating rules)
|
||||
|
||||
- `services/bookingRequests`: client-augmented **`bookingId: number | null`** on `BookingRequestDto`
|
||||
(REQ-017 twin of REQ-013's `variantPrice`; real client maps it to `null`), and the mock-only
|
||||
`mockMarkBookingRequestConverted(id, bookingId)` capture bridge.
|
||||
- `services/bookings/apis/mockApi.ts`: mock-only `mockInsertConvertedBooking(seed)` — inserts a confirmed
|
||||
single-session booking (ids 6001+/80001+, distinct from the 5001/5002 seeds).
|
||||
- C5 (`bookings/request/[id]`): the `converted` terminal card now deep-links the booking when
|
||||
`bookingId` is present (list fallback otherwise).
|
||||
- `constants/routes.ts`: `CHECKOUT_GATEWAY/RETURN/CONFIRMATION` + `bookingInvoicePath()`.
|
||||
- i18n: new **`payment`** namespace — 53 keys, both locales, inserted textually (no reformat of existing lines).
|
||||
|
||||
## 2. Contract deltas the implementation honors (vs the phase file's illustrative design)
|
||||
|
||||
The phase file sketched `getCheckoutSummary`/`verifyPayment`/`getTransaction` endpoints and an
|
||||
`initiated…cancelled` status enum. The **published contract wins**:
|
||||
|
||||
1. **Status enum is `pending|succeeded|failed`** (b10 `payment_transactions.status`). Client unions match.
|
||||
2. **There is no client verify/transaction endpoint.** The server re-verifies inside the webhook handler;
|
||||
"verify on return" is therefore modelled as `confirmGatewayReturn` (real impl = an outcome *read* — the
|
||||
PSP already hit the webhook before redirecting) + the outcome poll. Filed as REQ-017.
|
||||
3. **There is no checkout-summary endpoint** and the b8 request DTO is money-free — the C6 breakdown
|
||||
cannot be served today. Filed as REQ-016; mocked behind the seam; the real client targets the proposed slug.
|
||||
4. **The invoice is flat totals** (`grossIrr`/`platformCommissionIrr`/`vatRate`/`vatIrr`…, no line-items
|
||||
array) and only exists after the **admin-only** issue action. Filed as REQ-018; the UI has a not-issued state.
|
||||
5. **Idempotency is a header** (`Idempotency-Key`), not a body field — the contract's exact casing.
|
||||
|
||||
## 3. Mocks in this phase (recorded in mocks-registry.md)
|
||||
|
||||
- **`paymentMockApi` (mock-primary)** — the missing **conversion trigger between the f7 and f8 mock
|
||||
stores** (their `converted`/seeded-booking states were previously unconnected): capture flips the f7
|
||||
request to `converted` + stamps `bookingId`, inserts a **confirmed** f8 booking, and auto-issues the
|
||||
b11-shaped invoice. Money split uses integer parts-per-10000 BigInt math (12% fee, 10% VAT,
|
||||
`vat = commission_net × rate` per b11) so `service + commission + vat = total` **exactly** and
|
||||
`gross = balinyaarCommission + payout` holds. Idempotency mirrors b10: same-key retry reuses the
|
||||
attempt, post-capture initiate → `409`, replayed returns converge.
|
||||
- **The mock-gateway page** — test harness only (see registry row for the deletion story).
|
||||
- **Why mock-primary:** upstream ids are mock-primary (f7), REQ-016/017 are unserved, and nothing fires
|
||||
the PSP webhook in dev (b10's "webhook simulator" is a manual server-side POST — after a real initiate,
|
||||
nothing would ever confirm). Swap = deliver REQ-016/017/018 + real upstreams, then `USE_PAYMENT_MOCK=false`.
|
||||
|
||||
## 4. What is now testable, exactly (mock path — `npm run dev`)
|
||||
|
||||
Prereq: an `accepted_awaiting_payment` request — either seed-driven (open `/fa/nurse/requests`, accept a
|
||||
seeded pending request) or full-flow (C4 create → nurse accept). Same browser tab throughout (module-singleton mocks).
|
||||
|
||||
1. **C6:** from C5's «ادامه پرداخت» (or `/fa/bookings/checkout?request_id={id}`) — badge, mini-summary,
|
||||
30-min countdown, breakdown that sums to the rial (2,800,000×1 IRR gross → service 2,436,000 +
|
||||
commission 336,000 − VAT split), escrow notice in info tone. `/en` flips `dir`, translates, still Toman.
|
||||
2. **Pay:** «ادامه پرداخت ←» → spinner → the آزمایشی gateway → «پرداخت موفق» → return surface briefly shows
|
||||
«در حال تایید پرداخت…» → confirmation screen.
|
||||
3. **Booking flips:** «مشاهده رزرو» lands on `/bookings/{id}` showing **confirmed** (React Query Devtools:
|
||||
only request-detail/lists, bookings lists/detail, and this request's payment keys invalidated). C5 now
|
||||
shows the converted card deep-linking the same booking; the bookings list has the new row.
|
||||
4. **Invoice:** «دانلود فاکتور» → number `INV-…`, Shamsi date, service/commission/VAT-on-commission/total
|
||||
reconciling to C6, مودیان «در انتظار ثبت», print button (mock serves no `pdfUrl` so the print path runs).
|
||||
5. **Idempotency/retry:** double-tap pay (one attempt, same key — mock returns the same transaction);
|
||||
re-initiate after success → `409` → converges to confirmation, no error toast. Gateway «شبیهسازی
|
||||
پرداخت ناموفق» → failed card → «تلاش دوباره» → fresh C6 mount issues a **new** key (observable in the
|
||||
mock's `gatewayReferenceCode` suffix).
|
||||
6. **Window expiry:** wait out the 30-min window (or re-enter later) → C6/return show «مهلت پرداخت به
|
||||
پایان رسید» and C5 shows its terminal card; initiate after expiry → `409` handled as state, not error.
|
||||
7. **Invoice not-issued state:** `/fa/bookings/5001/invoice` (a seeded booking that never went through
|
||||
checkout) → «فاکتور هنوز صادر نشده است».
|
||||
|
||||
## 5. Follow-ups for the next phases
|
||||
|
||||
- **f10 (refunds/cancellation):** reuse `PriceBreakdown` (fee disclosure), `EscrowNotice` (identical trust
|
||||
copy), `PaymentStatusBadge`; `RefundStatusDto`/`refunds/{id}/status` is live in b11 and unconsumed;
|
||||
`refundableAmountIrr`/`cancellationRefundPercentage` already ride on `BookingDetailDto`.
|
||||
- **f11 (BNPL):** flip `BNPL_ENABLED` and wire the C6 secondary to D1. The b12 contract
|
||||
(`checkout_bnpl/eligibility|initiate|{id}` + `Idempotency-Key` header) parallels this domain's shapes;
|
||||
`InvoiceDto.bnplCommissionIrr` is already typed. The gateway-harness pattern extends to the BNPL redirect.
|
||||
- **Backend:** REQ-016 (checkout summary), REQ-017 (outcome/bookingId — until then the real poll can't
|
||||
distinguish *declined* from *slow*, and the confirmation can't deep-link), REQ-018 (invoice reachable
|
||||
post-capture). Also note: the PSP's return-URL config must deep-link `/{locale}/bookings/checkout/return`.
|
||||
|
||||
## 6. Post-review hardening (multi-agent adversarial review before close)
|
||||
|
||||
A 26-agent review/verify pass over the diff confirmed and fixed, pre-merge:
|
||||
- **Stale-outcome guard (major):** the return surface now trusts the outcome cache only after *this*
|
||||
mount's return report settles — a previous attempt's cached `failed` outcome can no longer flash a
|
||||
false «پرداخت ناموفق» (with a live retry) while the current attempt's capture is in flight.
|
||||
- **No dead-end retries:** malformed `request_id`/booking-id links render a navigation card instead of a
|
||||
`refetch()` that bypasses `enabled` and would request `checkout_summary/undefined`.
|
||||
- **Unpriced request fails loudly:** the mock throws `409 unpriced_request` instead of silently serving a
|
||||
reconciling 0-rial checkout when `variantPrice` is null (REQ-013 edge).
|
||||
- **i18n/UX:** inline initiate errors always use the localized copy (never raw `ApiError.message`); en
|
||||
`cta_pay` arrow points → (fa keeps ←); fa `error_body` matches the app's «بارگذاری … ممکن نشد» pattern;
|
||||
the C6 service-cost row carries the quantity (`row_service_cost_with_count`); the invoice issuer line
|
||||
uses the product spelling «بالینیار» (note: fa `common.brand` reads «بلینیار» — a pre-existing
|
||||
wordmark/product-spelling divergence worth a product decision).
|
||||
- **Dark scheme:** EscrowNotice text/border use `--bal-primary` (the info token is an alert *background*
|
||||
and is illegible as dark-mode text); the print button temporarily flips `data-mui-color-scheme` to
|
||||
light around `window.print()` (restored on `afterprint`) so a dark-mode user prints paper colors.
|
||||
- **Contract hygiene:** the domain barrel is hooks-only again (`isTerminalPaymentOutcome` moved to
|
||||
`types.ts`, mirroring `isTerminalBookingRequestStatus`); the gateway harness scopes `dir="ltr"` to the
|
||||
reference code, not the Persian label; the invoice VAT percent formats fractional rates
|
||||
(`maximumFractionDigits: 2`).
|
||||
|
||||
## 7. Gate
|
||||
|
||||
`npm run check` green · `npm run test:ci` green (46 suites, 204 tests — +9: PriceBreakdown 4, EscrowNotice 2,
|
||||
PaymentStatusBadge 3) · `npm run build` green. `en.json`/`fa.json` in sync (53-key `payment` namespace).
|
||||
`client/CLAUDE.md` Project Structure updated (checkout subtree, invoice route, `services/payment`, three
|
||||
components, `payment` namespace entry). Gotcha for future phases: **BigInt literals (`0n`) don't compile**
|
||||
(tsconfig target ES2017) — use the `BigInt(...)` constructor like `utils/money.ts`.
|
||||
@@ -71,3 +71,5 @@ the frontend can build before the backend phase merges, and swap to the real HTT
|
||||
| `VerificationApi` | `client/src/services/verification/apis/mockApi.ts` | The whole nurse trust journey (b6). Seeds the six required steps on `start` (idempotent); `runIdentityKyc` passes any well-formed 10-digit id **except** `0000000000` (→ `failed`/`kyc_no_match`, matches backend `MockIdentityKycProvider`); `runShahkarMatch` requires identity passed, fails **shared-SIM** when the bound national id is `1111111111` (→ `failed`/`shared_sim`); `runBankVerification` passes (assumes a primary bank account); `uploadStepDocument` simulates signed-URL PUT progress then moves the step to `in_review` (metadata only); `submitCredentialDetails` validates the INO number. Re-aggregates like the server (`approved` only when every step passes). **Dev-only** `__mockApproveAll()`/`__mockRejectStep(code,reason)` stand in for the deferred (f15) admin review queue so a human can watch `is_verified`/the trust badge/the publish gate flip — reachable from B3/B6 only while the flag is true | `USE_VERIFICATION_MOCK` (`services/verification/constants.ts`, default `true`) | b6 `nurse_verification/*` + `nurses/{id}/trust_badge` are live; set flag `false` — `verificationClientApi` is wired (action-style routes, camelCase, XHR signed-URL PUT for upload progress + SHA-256 integrity hash). **Caveat:** the real `submitCredentialDetails` no-ops pending REQ-011 (no nurse-facing endpoint for the structured INO/specialties fields yet) — the document uploads it accompanies are contract-backed. No hook/component change | 🟡 |
|
||||
| `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 | 🟡 |
|
||||
|
||||
Reference in New Issue
Block a user