frontend phase 9

This commit is contained in:
hamid
2026-07-10 11:49:55 +03:30
parent cd6c2591a6
commit 40cc1d163b
49 changed files with 4130 additions and 20 deletions
+12 -2
View File
@@ -129,8 +129,13 @@ client/
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── [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
+55
View File
@@ -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",
+55
View File
@@ -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>
);
}
@@ -0,0 +1,98 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { bookingInvoicePath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCheckoutSummary } from '@/services/payment';
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
/**
* Post-payment confirmation the booking is now **confirmed** (flipped by cache invalidation on the
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
* the invoice («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
* to the bookings list and the invoice link is hidden the invoice route needs the booking id.
*/
export default function CheckoutConfirmationPage() {
return (
<Suspense fallback={<AppLoading />}>
<ConfirmationScreen />
</Suspense>
);
}
function ConfirmationScreen() {
const t = useTranslations('payment');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
const { data: summary } = useCheckoutSummary(
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
);
return (
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
<Stack sx={{ gap: 0.5 }}>
<Typography variant="h6" component="h1">
{t('confirm_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('confirm_subtitle')}
</Typography>
</Stack>
{summary ? (
<Paper
elevation={0}
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
>
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('total_paid_label')}
</Typography>
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{summary.variantLabel} · {summary.nurseName}
</Typography>
</Stack>
</Paper>
) : null}
<Stack sx={{ gap: 1, width: '100%' }}>
<AppButton
color="primary"
variant="contained"
size="large"
onClick={() =>
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
{bookingId != null ? (
<AppButton
variant="outlined"
color="primary"
startIcon="document"
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('download_invoice')}
</AppButton>
) : null}
</Stack>
</Stack>
);
}
@@ -0,0 +1,76 @@
'use client';
import { Suspense } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter, useSearchParams } from 'next/navigation';
import { Box, Paper, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, AppLoading } from '@/components';
import { ROUTES } from '@/constants';
import {
CHECKOUT_QUERY_OUTCOME,
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
} from '@/services/payment/constants';
import type { GatewayReturnOutcome } from '@/services/payment/types';
/**
* Dev mock-gateway page a **test harness, not a product feature**. It stands in for the PSP so the
* initiate redirect return round-trip is exercisable without a real gateway: the payment mock's
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
*/
export default function MockGatewayPage() {
return (
<Suspense fallback={<AppLoading />}>
<MockGatewayScreen />
</Suspense>
);
}
function MockGatewayScreen() {
const t = useTranslations('payment');
const locale = useLocale();
const router = useRouter();
const params = useSearchParams();
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
const returnWith = (outcome: GatewayReturnOutcome) => {
const query = new URLSearchParams({
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
[CHECKOUT_QUERY_OUTCOME]: outcome,
});
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
};
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('gateway_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('gateway_hint')}
</Typography>
{transactionId ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
{t('gateway_reference_label')}:{' '}
<Box component="span" dir="ltr">
#{transactionId}
</Box>
</Typography>
) : null}
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
{t('gateway_pay_success')}
</AppButton>
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
{t('gateway_pay_fail')}
</AppButton>
</Stack>
</Paper>
);
}
@@ -1,25 +1,299 @@
'use client';
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>
);
}
@@ -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';
+8
View File
@@ -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';
+11 -1
View File
@@ -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}`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_PAYMENT_MOCK } from '../constants';
import type { PaymentApi } from '../types';
import { paymentClientApi } from './clientApi';
import { paymentMockApi } from './mockApi';
/**
* The selected `PaymentApi` implementation the single seam the hooks import. Selection is by config
* (`USE_PAYMENT_MOCK`), never by scattered `if (mock)` checks.
*/
export const paymentApi: PaymentApi = USE_PAYMENT_MOCK ? paymentMockApi : paymentClientApi;
+267
View File
@@ -0,0 +1,267 @@
import { multiplyIrr, parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { ROUTES } from '@/constants';
import {
bookingRequestsMockApi,
mockMarkBookingRequestConverted,
} from '@/services/bookingRequests/apis/mockApi';
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
import type { BookingRequestDto } from '@/services/bookingRequests/types';
import {
CHECKOUT_QUERY_REQUEST_ID,
CHECKOUT_QUERY_TRANSACTION_ID,
MOCK_PLATFORM_FEE_RATE,
MOCK_VAT_RATE,
} from '../constants';
import type {
CheckoutSummaryDto,
ConfirmGatewayReturnInput,
InitiatePaymentInput,
InitiatePaymentResult,
InvoiceDto,
PaymentApi,
PaymentOutcomeDto,
PaymentTransactionStatus,
} from '../types';
const MOCK_LATENCY_MS = 350;
/** A request is a single visit until multi-session requests exist (b8 carries one date/time window). */
const SESSION_COUNT = 1;
// Integer-only rate math: rates as parts-per-10000 so the money path never touches a float.
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
const RATE_SCALE = BigInt(10_000);
const FEE_RATE_PPM = BigInt(Math.round(MOCK_PLATFORM_FEE_RATE * Number(RATE_SCALE)));
const VAT_RATE_PPM = BigInt(Math.round(MOCK_VAT_RATE * Number(RATE_SCALE)));
/**
* The C6 money decomposition, all BigInt (mirrors what REQ-016 asks the server to serve):
* commission (net) = gross × feeRate; VAT = commission × vatRate (b11 rule: VAT is computed ON the
* commission, the platform's taxable supply); service cost = the remainder (= the nurse payout leg).
* Rows reconcile by construction: service + commission + vat = gross, and the b10 three-amount
* invariant holds with balinyaarCommission = commission + vat.
*/
function splitAmounts(grossIrr: string) {
const gross = parseIrr(grossIrr);
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
const serviceCost = gross - commissionNet - vat;
return {
grossPriceIrr: gross.toString(),
commissionIrr: commissionNet.toString(),
vatIrr: vat.toString(),
serviceCostIrr: serviceCost.toString(),
balinyaarCommissionIrr: (commissionNet + vat).toString(),
nursePayoutAmount: serviceCost.toString(),
};
}
interface MockTransaction {
transactionId: number;
bookingRequestId: number;
idempotencyKey: string;
status: PaymentTransactionStatus;
gatewayReferenceCode: string;
grossPriceIrr: string;
bookingId: number | null;
}
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
// checkout, the C5 poll, and the bookings list all observe the same capture.
let transactions: MockTransaction[] = [];
const invoices: Record<number, InvoiceDto> = {};
let nextTransactionId = 42_001;
let nextInvoiceId = 101;
function latestTransactionFor(bookingRequestId: number): MockTransaction | undefined {
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
}
function requestGross(request: BookingRequestDto): string {
// The frozen gross the server would charge: variant price × session count, never client-supplied.
// A request without a price (REQ-013 unmet) must fail loudly — never a reconciling 0-rial checkout.
if (request.variantPrice == null) {
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
}
return multiplyIrr(request.variantPrice, SESSION_COUNT);
}
function toOutcome(request: BookingRequestDto, transaction: MockTransaction | undefined): PaymentOutcomeDto {
return {
bookingRequestId: request.id,
requestStatus: request.status,
transactionStatus: transaction?.status ?? null,
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
};
}
/** The webhook-confirm stand-in: flip the transaction, convert the request, insert the booking, invoice it. */
function capture(request: BookingRequestDto, transaction: MockTransaction): PaymentOutcomeDto {
const amounts = splitAmounts(transaction.grossPriceIrr);
const booking = mockInsertConvertedBooking({
bookingRequestId: request.id,
nurseId: request.nurseId,
nurseName: request.nurseName,
patientId: request.patientId,
patientName: request.patientName,
variantId: request.variantId,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
customerAddressId: request.customerAddressId,
addressSnapshotJson: JSON.stringify({
title: request.addressTitle,
city: request.cityNameFa,
district: request.districtNameFa,
line: request.addressLine,
postalCode: request.postalCode,
}),
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
// The PSP's cut is a platform expense outside the three-amount split; 2% matches the f8 seeds.
pspFeeAmount: ((parseIrr(amounts.grossPriceIrr) * BigInt(200)) / RATE_SCALE).toString(),
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
scheduledDate: request.requestedDate,
scheduledTimeStart: request.requestedTimeStart,
scheduledTimeEnd: request.requestedTimeEnd,
});
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
transaction.status = 'succeeded';
transaction.bookingId = booking.id;
invoices[booking.id] = {
id: nextInvoiceId,
bookingId: booking.id,
invoiceNumber: `INV-${String(nextInvoiceId).padStart(10, '0')}`,
issuingEntityType: 'platform',
grossIrr: amounts.grossPriceIrr,
platformCommissionIrr: amounts.commissionIrr,
bnplCommissionIrr: null,
vatRate: MOCK_VAT_RATE,
vatIrr: amounts.vatIrr,
moadianReferenceNumber: null,
moadianStatus: 'pending',
pdfUrl: null,
issuedAt: new Date().toISOString(),
};
nextInvoiceId += 1;
return toOutcome(converted, transaction);
}
/**
* In-memory mock behind the `PaymentApi` seam the missing conversion trigger between the f7 and f8
* mock stores. It serves the unserved C6 summary (REQ-016), plays the PSP + webhook roles (initiate
* app-relative gateway-harness redirect capture on the success return), enforces b10's idempotency
* semantics (same-key retry reuses the attempt; a repeat initiate after capture is a `409`; a replayed
* return is a no-op), and issues the b11-shaped invoice at capture. Swap to the real `clientApi` once
* the upstream domains are real and REQ-016/017 land (`USE_PAYMENT_MOCK = false`).
*/
export const paymentMockApi: PaymentApi = {
getCheckoutSummary: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const amounts = splitAmounts(requestGross(request));
const summary: CheckoutSummaryDto = {
bookingRequestId: request.id,
requestStatus: request.status,
nurseName: request.nurseName,
patientName: request.patientName,
variantLabel: request.variantLabel,
variantPriceUnit: request.variantPriceUnit,
sessionCount: SESSION_COUNT,
requestedDate: request.requestedDate,
requestedTimeStart: request.requestedTimeStart,
requestedTimeEnd: request.requestedTimeEnd,
paymentDeadlineAt: request.paymentDeadlineAt,
serviceCostIrr: amounts.serviceCostIrr,
commissionIrr: amounts.commissionIrr,
vatIrr: amounts.vatIrr,
vatRate: MOCK_VAT_RATE,
totalIrr: amounts.grossPriceIrr,
grossPriceIrr: amounts.grossPriceIrr,
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
nursePayoutAmount: amounts.nursePayoutAmount,
};
return summary;
},
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const existing = latestTransactionFor(bookingRequestId);
if (request.status === 'converted' || existing?.status === 'succeeded') {
throw new ApiError(409, 'Request already paid', 'already_paid');
}
if (request.status !== 'accepted_awaiting_payment') {
// Covers the lapsed 30-minute window (`payment_deadline_expired`) and every other non-payable state.
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
}
// b10 idempotency: a retried start with the same `Idempotency-Key` reuses the attempt/reference.
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'pending') {
return toInitiateResult(existing);
}
const transaction: MockTransaction = {
transactionId: nextTransactionId++,
bookingRequestId,
idempotencyKey,
status: 'pending',
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
grossPriceIrr: requestGross(request),
bookingId: null,
};
transactions = [transaction, ...transactions];
return toInitiateResult(transaction);
},
confirmGatewayReturn: async ({ bookingRequestId, transactionId, outcome }: ConfirmGatewayReturnInput) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
const transaction =
(transactionId != null ? transactions.find((t) => t.transactionId === transactionId) : undefined) ??
latestTransactionFor(bookingRequestId);
// Replays / double-taps converge (the webhook dedup analogue): if capture already happened, report it.
if (request.status === 'converted' || transaction?.status === 'succeeded') {
return toOutcome(request, transaction);
}
if (!transaction) throw new ApiError(404, 'Payment transaction not found', 'not_found');
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
// A declined gateway, or the payment window lapsed while the customer was at the gateway.
transaction.status = 'failed';
return toOutcome(request, transaction);
}
return capture(request, transaction);
},
getPaymentOutcome: async (bookingRequestId) => {
await sleep(MOCK_LATENCY_MS);
const request = await bookingRequestsMockApi.get(bookingRequestId);
return toOutcome(request, latestTransactionFor(bookingRequestId));
},
getInvoice: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
const invoice = invoices[bookingId];
// b11: the invoice exists only once issued — a clean 404 until then (the UI shows "not issued yet").
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
return { ...invoice };
},
};
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
const query = new URLSearchParams();
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
return {
transactionId: transaction.transactionId,
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
gatewayReferenceCode: transaction.gatewayReferenceCode,
};
}
+54
View File
@@ -0,0 +1,54 @@
/**
* When true, the payment domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PaymentApi` seam.
*
* **Mock is primary this phase.** The b10 `initiate` endpoint and the b11 `invoices/{bookingId}` read are
* live server-side, but the checkout cannot run real end-to-end from the client yet:
* - the accepted request being paid comes from the **mock-primary** `bookingRequests` store (f7), so a
* real initiate would reference an id that exists only in memory;
* - the contract serves **no checkout summary** (gross/commission/VAT for C6 REQ-016) and **no
* client-readable transaction status** for the return poll (REQ-017);
* - nothing fires the PSP webhook in dev (b10's mock provider returns a fake redirect URL and the
* "webhook simulator" is a manual server-side POST), so a real payment would never confirm.
* The mock closes the loop: capture converts the f7 request, inserts a **confirmed** booking into the f8
* store, and issues the invoice so C5 C6 gateway confirmation booking detail demos end-to-end.
* Flip to `false` once the upstream domains are real and REQ-016/017 land no hook/component change.
*/
export const USE_PAYMENT_MOCK = true;
/**
* f11 wires the BNPL method screens (D1D5); until then C6 renders the «یا پرداخت اقساطی» seam as a
* clearly-deferred secondary (disabled + "coming soon"), never a dead button.
*/
export const BNPL_ENABLED = false;
/** Prices/deadlines can move between visits — keep the checkout summary short-lived. */
export const CHECKOUT_SUMMARY_STALE_TIME = 10 * 1000;
/** An issued invoice is immutable — cache it for the session. */
export const INVOICE_STALE_TIME = 60 * 60 * 1000;
/**
* The pending-callback poll ("PSP received ≠ cash in bank" a pending state is normal, reflect it
* calmly). Starts at ~2s, grows geometrically, caps, and gives up after a bounded number of attempts
* never a tight loop. Terminal statuses stop the poll regardless (see `usePaymentOutcome`).
*/
export const PAYMENT_OUTCOME_POLL_BASE_MS = 2 * 1000;
export const PAYMENT_OUTCOME_POLL_GROWTH = 1.5;
export const PAYMENT_OUTCOME_POLL_MAX_MS = 15 * 1000;
export const PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS = 40;
/** Checkout deep-link query params (C5 hands off `request_id`; the gateway round-trip carries the rest). */
export const CHECKOUT_QUERY_REQUEST_ID = 'request_id';
export const CHECKOUT_QUERY_TRANSACTION_ID = 'transaction_id';
export const CHECKOUT_QUERY_BOOKING_ID = 'booking_id';
export const CHECKOUT_QUERY_OUTCOME = 'outcome';
/**
* Mock-only money rates. `MOCK_PLATFORM_FEE_RATE` matches the f8 seeds' snapshotted `platformFeeRate`
* (0.12); `MOCK_VAT_RATE` is the current 10% VAT (product: VAT applies to **Balinyaar's commission
* only** and the rate is config-driven server-side b11 serves `vatRate`/`vatIrr`; the client never
* derives tax on the real path).
*/
export const MOCK_PLATFORM_FEE_RATE = 0.12;
export const MOCK_VAT_RATE = 0.1;
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
/**
* The C6 payload badge status, nurse/service mini-summary, and the served, reconciling money rows
* (service + commission + VAT = total). Short `staleTime`: the price/deadline can change between visits,
* and after a capture the summary's `requestStatus` must flip promptly (it is also invalidated by
* `invalidateAfterPaymentSuccess`). Enabled only when an id is present.
*/
export function useCheckoutSummary(bookingRequestId: number | undefined) {
return useQuery({
queryKey: paymentKeys.summary(bookingRequestId ?? -1),
queryFn: () => paymentApi.getCheckoutSummary(bookingRequestId as number),
enabled: bookingRequestId != null && bookingRequestId > 0,
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
});
}
@@ -0,0 +1,25 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { invalidateAfterPaymentSuccess } from '../invalidations';
import type { ConfirmGatewayReturnInput } from '../types';
/**
* Report the return from the gateway and read the outcome. On the real path the PSP already confirmed
* via the webhook, so this is a read; in the mock it is the capture trigger. When the outcome is already
* `succeeded`, the affected booking/request caches are invalidated here (the booking flips to confirmed
* by cache invalidation, never a blanket refetch); a still-`pending` outcome is primed into the outcome
* key so the pending-callback poll (`usePaymentOutcome`) starts from fresh data.
*/
export function useConfirmGatewayReturn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ConfirmGatewayReturnInput) => paymentApi.confirmGatewayReturn(input),
onSuccess: (outcome) => {
queryClient.setQueryData(paymentKeys.outcome(outcome.bookingRequestId), outcome);
if (outcome.transactionStatus === 'succeeded') {
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
}
},
});
}
@@ -0,0 +1,17 @@
import { useMutation } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import type { InitiatePaymentInput } from '../types';
/**
* Start a card payment for an accepted request. The caller owns the **per-attempt idempotency key**
* (generated once per attempt and reused across retries of that attempt see the C6 page), because only
* the UI knows when a *new* attempt starts (after a failed/cancelled outcome). On success the caller
* navigates to `redirectUrl`. A `409` here means "already paid / already in progress / window lapsed"
* the page converges (routes to the return surface to read the outcome) instead of toasting; other
* domain 4xx render inline via `mutation.error` (the fetch layer owns 401/403/5xx toasts).
*/
export function useInitiatePayment() {
return useMutation({
mutationFn: (input: InitiatePaymentInput) => paymentApi.initiatePayment(input),
});
}
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { ApiError } from '@/lib/api/errors';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import { INVOICE_STALE_TIME } from '../constants';
/**
* The booking's commission invoice (b11). An issued invoice is immutable long `staleTime`, no polling.
* A 404 is a domain state, not a transient failure ("not issued yet" b11 issues via an admin action;
* auto-issue on capture is filed as REQ-018), so it renders as an empty state and is **not retried**.
*/
export function useInvoice(bookingId: number | undefined) {
return useQuery({
queryKey: paymentKeys.invoice(bookingId ?? -1),
queryFn: () => paymentApi.getInvoice(bookingId as number),
enabled: bookingId != null && bookingId > 0,
staleTime: INVOICE_STALE_TIME,
retry: (failureCount, error) => {
if (error instanceof ApiError && error.status === 404) return false;
return failureCount < 3;
},
});
}
@@ -0,0 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { paymentApi } from '../apis';
import { paymentKeys } from '../keys';
import {
PAYMENT_OUTCOME_POLL_BASE_MS,
PAYMENT_OUTCOME_POLL_GROWTH,
PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS,
PAYMENT_OUTCOME_POLL_MAX_MS,
} from '../constants';
import { isTerminalPaymentOutcome } from '../types';
/**
* The pending-callback poll. "PSP received ≠ cash in bank" a pending state after the gateway return is
* normal, so this polls **with geometric backoff** (base ×growth cap) and **stops** on any terminal
* outcome or after a bounded number of attempts never a tight loop, never hammering the endpoint. The
* return surface offers a manual re-check once the budget is spent. `dataUpdateCount` is the attempt
* counter (it increments per successful fetch, which is exactly the cadence backoff should follow).
*/
export function usePaymentOutcome(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: paymentKeys.outcome(bookingRequestId ?? -1),
queryFn: () => paymentApi.getPaymentOutcome(bookingRequestId as number),
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
refetchInterval: (query) => {
const outcome = query.state.data;
if (outcome && isTerminalPaymentOutcome(outcome)) return false;
const attempt = query.state.dataUpdateCount;
if (attempt >= PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS) return false;
return Math.min(
PAYMENT_OUTCOME_POLL_BASE_MS * PAYMENT_OUTCOME_POLL_GROWTH ** attempt,
PAYMENT_OUTCOME_POLL_MAX_MS,
);
},
});
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Payment domain barrel re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCheckoutSummary } from './hooks/useCheckoutSummary';
export { useInitiatePayment } from './hooks/useInitiatePayment';
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
export { useInvoice } from './hooks/useInvoice';
@@ -0,0 +1,27 @@
import type { QueryClient } from '@tanstack/react-query';
import { bookingKeys } from '@/services/bookings/keys';
import { bookingRequestKeys } from '@/services/bookingRequests/keys';
import { paymentKeys } from './keys';
/**
* The one cache transition a successful capture causes: the request flipped `converted` and a confirmed
* booking now exists. Invalidate exactly the affected keys the request detail + inboxes, the bookings
* lists (a new row), the specific booking detail when known, and this request's checkout summary/outcome
* never a blanket refetch (phase rule: the booking flips to confirmed *by cache invalidation*, no
* refetch storm). Shared by `useConfirmGatewayReturn` (immediate success) and the pending-callback poll
* (late success).
*/
export function invalidateAfterPaymentSuccess(
queryClient: QueryClient,
bookingRequestId: number,
bookingId: number | null,
): void {
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
if (bookingId != null) {
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
}
queryClient.invalidateQueries({ queryKey: paymentKeys.summary(bookingRequestId) });
queryClient.invalidateQueries({ queryKey: paymentKeys.outcome(bookingRequestId) });
}
+14
View File
@@ -0,0 +1,14 @@
/**
* React Query key factory for the payment domain (hierarchical, per the `services/{domain}` pattern).
* Summary and outcome are keyed by the booking-request id (payment runs against the accepted request);
* the invoice is keyed by the booking id it belongs to.
*/
export const paymentKeys = {
all: ['payment'] as const,
summaries: () => [...paymentKeys.all, 'summary'] as const,
summary: (bookingRequestId: number) => [...paymentKeys.summaries(), bookingRequestId] as const,
outcomes: () => [...paymentKeys.all, 'outcome'] as const,
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
invoices: () => [...paymentKeys.all, 'invoice'] as const,
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
};
+150
View File
@@ -0,0 +1,150 @@
import type { PriceUnit } from '@/services/catalog/types';
import { isTerminalBookingRequestStatus, type BookingRequestStatus } from '@/services/bookingRequests/types';
/**
* Payment domain the checkout/card-capture layer (b10) plus the customer invoice read (b11). Shapes
* mirror the published contracts (`dev/contracts/domains/payments.md`, `refunds-invoices.md`; camelCase
* wire, `clientFetch` unwraps the `ApiEnvelope<T>`).
*
* Load-bearing semantics (contracts + phase §5):
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
* `@/utils` BigInt helpers, display as Toman via `formatIrrToToman`. No float math on the money path.
* - **Payment is initiated against the accepted REQUEST, not a booking** the `bookings` row is created
* & confirmed on capture (the PSP webhook), never on initiate.
* - **Idempotency is per attempt.** One stable `Idempotency-Key` (header) per payment attempt, reused
* across retries of that attempt; a NEW attempt gets a new key. A `409` on initiate means "already in
* progress / already captured" a benign convergence, never an error toast.
* - **There is no client verify endpoint.** The server re-verifies inside the webhook handler; the client
* learns the outcome by polling (`getPaymentOutcome`). On the real path that poll maps the request
* status (`converted` succeeded); a first-class transaction-status read is filed as REQ-017.
* - **VAT is on Balinyaar's commission only** (the platform's taxable supply) never the nurse payout.
*/
/** `payment_transactions.status` (b10 contract enum): no other states cross the wire. */
export type PaymentTransactionStatus = 'pending' | 'succeeded' | 'failed';
/** `invoices.moadian_status` (b11) — the e-invoicing registration state, surfaced read-only. */
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
/** What the dev gateway round-trip reports back (mock harness); the real PSP signals via the webhook. */
export type GatewayReturnOutcome = 'success' | 'failure';
/**
* The C6 payload. **Not yet served by the backend** (REQ-016): the b8 `BookingRequestDto` is money-free
* and no checkout-summary endpoint exists, so the mock builds this and the real `clientApi` targets the
* proposed route until it lands. Display rows are **served, never client-derived** in particular
* `vatIrr` (the client must not derive tax with a float rate) and reconcile by construction:
* `serviceCostIrr + commissionIrr + vatIrr = totalIrr`, with `totalIrr = grossPriceIrr` and the b10
* three-amount invariant `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
*/
export interface CheckoutSummaryDto {
bookingRequestId: number;
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
requestStatus: BookingRequestStatus;
nurseName: string;
patientName: string;
variantLabel: string;
variantPriceUnit: PriceUnit;
sessionCount: number;
/** ISO date `YYYY-MM-DD`. */
requestedDate: string;
/** `HH:mm:ss`. */
requestedTimeStart: string;
requestedTimeEnd: string;
/** Server-frozen UTC end of the 30-minute payment window. */
paymentDeadlineAt: string | null;
/** Display row: the nursing service itself (= the nurse payout leg). IRR digit-string. */
serviceCostIrr: string;
/** Display row: Balinyaar's commission **net of VAT**. IRR digit-string. */
commissionIrr: string;
/** Display row: VAT on the commission (the served figure — never derived client-side). */
vatIrr: string;
/** Snapshotted VAT rate (decimal, e.g. 0.1) — informational; the rial figure is `vatIrr`. */
vatRate: number;
/** Display total (= `grossPriceIrr`); the rows above sum to exactly this. */
totalIrr: string;
/** The b10 three-amount split (gross = commission + payout, guaranteed server-side). */
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
}
/** `POST bookings/{bookingRequestId}/payments` input — the key travels as the `Idempotency-Key` header. */
export interface InitiatePaymentInput {
bookingRequestId: number;
/** Stable per payment attempt (reused across retries of the same attempt); a new attempt gets a new one. */
idempotencyKey: string;
}
/** `InitiatePaymentResult` (b10 swagger): where to send the customer, and the PSP reference. */
export interface InitiatePaymentResult {
transactionId: number;
/** Absolute PSP URL on the real path; an app-relative gateway-harness path from the mock. */
redirectUrl: string | null;
gatewayReferenceCode: string | null;
}
/**
* What the return-from-gateway surface reports: the harness outcome plus which transaction came back.
* On the real path the PSP has already hit the webhook before redirecting the impl just re-reads the
* outcome; in the mock this IS the capture trigger (the client stand-in for the webhook confirm).
*/
export interface ConfirmGatewayReturnInput {
bookingRequestId: number;
transactionId: number | null;
outcome: GatewayReturnOutcome;
}
/**
* The pending-callback poll target. No client transaction read exists in the b10 contract (REQ-017), so
* the real path derives `transactionStatus` from the request status (`converted` succeeded,
* `payment_deadline_expired` failed, else pending) and `bookingId` stays `null` until the backend
* serves it; the mock knows both first-hand.
*/
export interface PaymentOutcomeDto {
bookingRequestId: number;
requestStatus: BookingRequestStatus;
transactionStatus: PaymentTransactionStatus | null;
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
bookingId: number | null;
}
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
return (
outcome.transactionStatus === 'succeeded' ||
outcome.transactionStatus === 'failed' ||
isTerminalBookingRequestStatus(outcome.requestStatus)
);
}
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
export interface InvoiceDto {
id: number;
bookingId: number;
/** Sequential, gap-free human-facing reference (e.g. `INV-0000000001`) — treat as opaque. */
invoiceNumber: string;
issuingEntityType: 'platform' | 'partner_center';
grossIrr: string;
/** Balinyaar's commission — the VAT-relevant line (`vatIrr = round(commission × vatRate)`). */
platformCommissionIrr: string;
bnplCommissionIrr: string | null;
vatRate: number;
vatIrr: string;
moadianReferenceNumber: string | null;
moadianStatus: MoadianStatus | null;
pdfUrl: string | null;
issuedAt: string;
}
/**
* The payment API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_PAYMENT_MOCK`), never scattered `if (mock)` checks.
*/
export interface PaymentApi {
getCheckoutSummary(bookingRequestId: number): Promise<CheckoutSummaryDto>;
initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
getInvoice(bookingId: number): Promise<InvoiceDto>;
}