frontend phase 9
This commit is contained in:
+12
-2
@@ -129,8 +129,13 @@ client/
|
||||
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
|
||||
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer")
|
||||
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
|
||||
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards
|
||||
│ │ │ │ └── checkout/page.tsx # /bookings/checkout — pay & confirm handoff target (DEFERRED→f9 stub; C5 accept CTA lands here with request_id)
|
||||
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017)
|
||||
│ │ │ │ ├── [id]/invoice/page.tsx # /bookings/[id]/invoice — f9 commission invoice (b11): number + Shamsi date, reconciling lines with the VAT-on-commission line, read-only مودیان state; pdfUrl download or window.print receipt
|
||||
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=)
|
||||
│ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + disabled BNPL seam (f11)
|
||||
│ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches)
|
||||
│ │ │ │ ├── return/page.tsx # return-from-gateway — confirm return → pending-callback poll (backoff, stops on terminal) → succeeded (invalidate + hand off) / failed retry / window-expired
|
||||
│ │ │ │ └── confirmation/page.tsx # payment success — «مشاهده رزرو» (booking detail) + «دانلود فاکتور» (invoice)
|
||||
│ │ │ ├── patients/page.tsx # /patients — E1 list/CRUD (add/edit dialog reusing PatientForm, soft-archive)
|
||||
│ │ │ ├── addresses/page.tsx # /addresses — F3 address book (cascading region dropdowns + map-pin picker, set-primary)
|
||||
│ │ │ ├── wallet/page.tsx # /wallet
|
||||
@@ -180,6 +185,9 @@ client/
|
||||
│ ├── ServicePriceRow/ # f6 C3 service line: localised name + PriceDisplay (money util + i18n unit label); reused by the booking summary later (tested)
|
||||
│ ├── CountdownTimer/ # f7 pure presentational countdown to a server-frozen UTC deadline; owns its own 1s tick (only it re-renders), stops + shows elapsed text at zero, locale digits LTR (tested)
|
||||
│ ├── BookingRequestSummaryCard/ # f7 engagement summary (nurse+rating, patient, priced service, address, Shamsi time) — shared by C5 + nurse detail + later f8 booking detail (tested)
|
||||
│ ├── PriceBreakdown/ # f9 reconciling money breakdown (rows + bold total, all IRR digit-strings via the money util; dev-guard console.errors when rows ≠ total) — C6 + invoice now, f10/f11 refund/BNPL later (tested)
|
||||
│ ├── EscrowNotice/ # f9 product-mandated escrow trust callout (verbatim fa copy, --bal-info tone, lock icon) — C6 now, f10/f11 reuse the identical message (tested)
|
||||
│ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
|
||||
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
|
||||
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
|
||||
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
|
||||
@@ -234,6 +242,7 @@ client/
|
||||
│ ├── verification/ # F5 nurse trust flow (b6). ONE cached status() query drives B3+B6; every mutation invalidates it. useVerificationStatus/useStartVerification/useSubmitIdentity/useRunBankVerification/useUploadVerificationDocument/useSubmitCredentials/useNurseTrustBadge; seam+mock(primary)+client; validation.ts (national-ID checksum); types export ownBadgeState/publicBadgeState/isApproved
|
||||
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
|
||||
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
|
||||
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast
|
||||
│ └── {domain}/
|
||||
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
|
||||
│ ├── keys.ts # React Query key factory (hierarchical)
|
||||
@@ -329,6 +338,7 @@ async function MyServerComponent() {
|
||||
- `'search'` — the f6 discovery flow (C1/C2/C3): filter section labels, the same-gender facet + hint, sort/count (ICU plural), all four result states + "relax filters" suggestions, card labels (rating/distance/from-price), profile badges (تاییدشده/نظام پرستاری)/attribute chips/specialty codes/services/latest review, and the "درخواست رزرو" CTA
|
||||
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
|
||||
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
|
||||
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالینیار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
|
||||
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
|
||||
|
||||
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
|
||||
|
||||
@@ -558,6 +558,61 @@
|
||||
"list_error": "Couldn't load your bookings.",
|
||||
"list_total": "Total"
|
||||
},
|
||||
"payment": {
|
||||
"title_checkout": "Confirm & pay",
|
||||
"row_service_cost": "Service cost",
|
||||
"row_service_cost_with_count": "Service cost ({count, plural, one {# visit} other {# visits}})",
|
||||
"row_commission": "Balinyaar fee",
|
||||
"row_vat": "VAT",
|
||||
"row_total": "Total",
|
||||
"escrow_notice": "The amount is held in escrow with Balinyaar and released after the visit ends",
|
||||
"cta_pay": "Continue to payment →",
|
||||
"bnpl_option": "Or pay in installments",
|
||||
"state_initiating": "Starting payment…",
|
||||
"state_redirecting": "Redirecting to the payment gateway…",
|
||||
"state_pending_title": "Confirming your payment…",
|
||||
"state_pending_hint": "The gateway is confirming your payment; this usually takes a few moments.",
|
||||
"check_again": "Check again",
|
||||
"state_failed_title": "Payment failed",
|
||||
"state_failed_hint": "Nothing was charged. You can try again.",
|
||||
"retry_payment": "Try again",
|
||||
"back_to_request": "Back to the request",
|
||||
"already_paid_title": "This request is already paid",
|
||||
"already_paid_body": "Your booking has been confirmed.",
|
||||
"window_expired_title": "The payment window has closed",
|
||||
"window_expired_body": "To continue, submit a new request.",
|
||||
"not_payable_title": "This request can no longer be paid",
|
||||
"initiate_failed": "The payment could not be started.",
|
||||
"confirm_title": "Payment successful",
|
||||
"confirm_subtitle": "Your booking is confirmed and the nurse has been notified.",
|
||||
"view_booking": "View booking",
|
||||
"download_invoice": "Download invoice",
|
||||
"total_paid_label": "Amount paid",
|
||||
"invoice_title": "Invoice",
|
||||
"invoice_number_label": "Invoice number",
|
||||
"invoice_issued_at": "Issued on",
|
||||
"invoice_vat_on_commission": "VAT (on the Balinyaar fee)",
|
||||
"invoice_not_issued_title": "The invoice has not been issued yet",
|
||||
"invoice_not_issued_body": "The invoice for this booking will appear here once issued.",
|
||||
"print_invoice": "Print invoice",
|
||||
"moadian_label": "Moadian (e-invoicing) status",
|
||||
"moadian_pending": "Awaiting registration",
|
||||
"moadian_submitted": "Submitted",
|
||||
"moadian_registered": "Registered",
|
||||
"moadian_failed": "Failed",
|
||||
"pstatus_pending": "Awaiting confirmation",
|
||||
"pstatus_succeeded": "Paid",
|
||||
"pstatus_failed": "Failed",
|
||||
"gateway_title": "Test payment gateway",
|
||||
"gateway_hint": "This page stands in for the real gateway in development.",
|
||||
"gateway_reference_label": "Reference",
|
||||
"gateway_pay_success": "Pay successfully",
|
||||
"gateway_pay_fail": "Simulate a failed payment",
|
||||
"error_title": "Something went wrong",
|
||||
"error_body": "We couldn't load the payment summary.",
|
||||
"invalid_link_body": "This payment link is not valid.",
|
||||
"issuer_platform": "Balinyaar"
|
||||
},
|
||||
"auth": {
|
||||
"customer_title": "Sign in to Balinyaar",
|
||||
"customer_subtitle": "Sign in with your mobile number",
|
||||
|
||||
@@ -558,6 +558,61 @@
|
||||
"list_error": "بارگذاری رزروها ممکن نشد.",
|
||||
"list_total": "مبلغ کل"
|
||||
},
|
||||
"payment": {
|
||||
"title_checkout": "تایید و پرداخت",
|
||||
"row_service_cost": "هزینه خدمت",
|
||||
"row_service_cost_with_count": "هزینه خدمت ({count, plural, one {# ویزیت} other {# ویزیت}})",
|
||||
"row_commission": "کارمزد بالینیار",
|
||||
"row_vat": "مالیات بر ارزش افزوده",
|
||||
"row_total": "مبلغ کل",
|
||||
"escrow_notice": "مبلغ بهصورت امانی نزد بالینیار میماند و پس از پایان ویزیت آزاد میشود",
|
||||
"cta_pay": "ادامه پرداخت ←",
|
||||
"bnpl_option": "یا پرداخت اقساطی",
|
||||
"state_initiating": "در حال آغاز پرداخت…",
|
||||
"state_redirecting": "در حال انتقال به درگاه پرداخت…",
|
||||
"state_pending_title": "در حال تایید پرداخت…",
|
||||
"state_pending_hint": "پرداخت شما نزد درگاه در حال تایید است؛ این مرحله معمولاً چند لحظه طول میکشد.",
|
||||
"check_again": "بررسی دوباره",
|
||||
"state_failed_title": "پرداخت ناموفق بود",
|
||||
"state_failed_hint": "مبلغی از حساب شما کسر نشده است. میتوانید دوباره تلاش کنید.",
|
||||
"retry_payment": "تلاش دوباره",
|
||||
"back_to_request": "بازگشت به درخواست",
|
||||
"already_paid_title": "این درخواست قبلاً پرداخت شده است",
|
||||
"already_paid_body": "رزرو شما نهایی شده است.",
|
||||
"window_expired_title": "مهلت پرداخت به پایان رسید",
|
||||
"window_expired_body": "برای ادامه، درخواست تازهای ثبت کنید.",
|
||||
"not_payable_title": "این درخواست دیگر قابل پرداخت نیست",
|
||||
"initiate_failed": "شروع پرداخت ممکن نشد.",
|
||||
"confirm_title": "پرداخت با موفقیت انجام شد",
|
||||
"confirm_subtitle": "رزرو شما نهایی شد و پرستار در جریان قرار گرفت.",
|
||||
"view_booking": "مشاهده رزرو",
|
||||
"download_invoice": "دانلود فاکتور",
|
||||
"total_paid_label": "مبلغ پرداختشده",
|
||||
"invoice_title": "فاکتور",
|
||||
"invoice_number_label": "شماره فاکتور",
|
||||
"invoice_issued_at": "تاریخ صدور",
|
||||
"invoice_vat_on_commission": "مالیات بر ارزش افزوده (بر کارمزد بالینیار)",
|
||||
"invoice_not_issued_title": "فاکتور هنوز صادر نشده است",
|
||||
"invoice_not_issued_body": "فاکتور این رزرو پس از صدور در همینجا در دسترس خواهد بود.",
|
||||
"print_invoice": "چاپ فاکتور",
|
||||
"moadian_label": "وضعیت سامانه مودیان",
|
||||
"moadian_pending": "در انتظار ثبت",
|
||||
"moadian_submitted": "ارسالشده",
|
||||
"moadian_registered": "ثبتشده",
|
||||
"moadian_failed": "ناموفق",
|
||||
"pstatus_pending": "در انتظار تایید",
|
||||
"pstatus_succeeded": "موفق",
|
||||
"pstatus_failed": "ناموفق",
|
||||
"gateway_title": "درگاه پرداخت آزمایشی",
|
||||
"gateway_hint": "این صفحه در محیط توسعه جایگزین درگاه واقعی است.",
|
||||
"gateway_reference_label": "کد پیگیری",
|
||||
"gateway_pay_success": "پرداخت موفق",
|
||||
"gateway_pay_fail": "شبیهسازی پرداخت ناموفق",
|
||||
"error_title": "مشکلی پیش آمد",
|
||||
"error_body": "بارگذاری خلاصه پرداخت ممکن نشد.",
|
||||
"invalid_link_body": "پیوند پرداخت معتبر نیست.",
|
||||
"issuer_platform": "بالینیار"
|
||||
},
|
||||
"auth": {
|
||||
"customer_title": "ورود به بلینیار",
|
||||
"customer_subtitle": "با شماره موبایل خود وارد شوید",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Divider, GlobalStyles, Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, PriceBreakdown, StatusChip, type StatusKind } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate, parseIrr } from '@/utils';
|
||||
import { useInvoice } from '@/services/payment';
|
||||
import type { MoadianStatus } from '@/services/payment/types';
|
||||
|
||||
/** The printable region — everything else is hidden by the print rules below. */
|
||||
const PRINT_AREA_CLASS = 'invoice-print-area';
|
||||
|
||||
// مودیان registration is a backend concern — surfaced strictly read-only (contract exposes the state).
|
||||
const MOADIAN_KIND: Record<MoadianStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
submitted: 'info',
|
||||
registered: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
/**
|
||||
* The booking's commission invoice (b11 `GET invoices/{bookingId}`): header (`invoiceNumber`, Shamsi
|
||||
* issue date), the reconciling lines with the **VAT-on-commission** line explicitly labelled (product
|
||||
* rule: VAT is on Balinyaar's commission — the taxable supply — never the nurse's earnings), and the
|
||||
* مودیان state read-only. Downloads the served `pdfUrl` when present; otherwise prints a clean receipt
|
||||
* (`window.print()` + a print-scoped visibility rule). Every figure via the money util — no float math;
|
||||
* the service line is the exact integer remainder of served amounts (gross − commission − VAT).
|
||||
*/
|
||||
export default function BookingInvoicePage() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ id: string }>();
|
||||
const bookingId = Number(params.id);
|
||||
const validId = Number.isInteger(bookingId) && bookingId > 0;
|
||||
|
||||
const { data: invoice, isLoading, error, refetch } = useInvoice(validId ? bookingId : undefined);
|
||||
|
||||
// A malformed id can never load — navigation, not a retry (a manual refetch() bypasses `enabled`).
|
||||
if (!validId) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="error" size={44} color="var(--bal-error)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('invalid_link_body')}
|
||||
</Typography>
|
||||
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Skeleton variant="text" width="40%" height={36} />
|
||||
<Skeleton variant="rounded" height={120} />
|
||||
<Skeleton variant="rounded" height={200} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
const notIssued = error instanceof ApiError && error.status === 404;
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={notIssued ? 'document' : 'error'} size={44} color={notIssued ? 'var(--bal-warning)' : 'var(--bal-error)'} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{notIssued ? t('invoice_not_issued_title') : t('error_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{notIssued ? t('invoice_not_issued_body') : t('error_body')}
|
||||
</Typography>
|
||||
{notIssued ? (
|
||||
<AppButton variant="contained" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton variant="contained" onClick={() => refetch()} sx={{ m: 0 }}>
|
||||
{tc('retry')}
|
||||
</AppButton>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// The receipt must print on paper colors: the tokens are attribute-driven, so a dark-scheme user
|
||||
// would otherwise print cream-on-dark. Flip to the light tokens for the print dialog and restore
|
||||
// after it closes (afterprint) — no hard-coded colors, the same token system does the work.
|
||||
const handlePrint = () => {
|
||||
const root = document.documentElement;
|
||||
const previous = root.getAttribute('data-mui-color-scheme');
|
||||
if (previous === 'dark') {
|
||||
const restore = () => {
|
||||
root.setAttribute('data-mui-color-scheme', previous);
|
||||
window.removeEventListener('afterprint', restore);
|
||||
};
|
||||
window.addEventListener('afterprint', restore);
|
||||
root.setAttribute('data-mui-color-scheme', 'light');
|
||||
}
|
||||
window.print();
|
||||
};
|
||||
|
||||
// Display rows from served amounts only, integer-safe: the service line is the exact remainder, so
|
||||
// service + commission + VAT reconciles to the gross total by construction.
|
||||
const serviceIrr = (
|
||||
parseIrr(invoice.grossIrr) - parseIrr(invoice.platformCommissionIrr) - parseIrr(invoice.vatIrr)
|
||||
).toString();
|
||||
// maximumFractionDigits: the default (0) would silently round a fractional served rate (e.g. 9.5%).
|
||||
const vatPercent = new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
style: 'percent',
|
||||
maximumFractionDigits: 2,
|
||||
}).format(invoice.vatRate);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<GlobalStyles
|
||||
styles={{
|
||||
'@media print': {
|
||||
'body *': { visibility: 'hidden' },
|
||||
[`.${PRINT_AREA_CLASS}, .${PRINT_AREA_CLASS} *`]: { visibility: 'visible' },
|
||||
[`.${PRINT_AREA_CLASS}`]: { position: 'absolute', top: 0, insetInlineStart: 0, width: '100%' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
className={PRINT_AREA_CLASS}
|
||||
sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
|
||||
>
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('invoice_title')}
|
||||
</Typography>
|
||||
{/* The issuer line uses the product spelling «بالینیار» — fa `common.brand` currently reads
|
||||
«بلینیار» (the auth wordmark); a fiscal document must match the product/docs spelling. */}
|
||||
<Typography variant="subtitle2" sx={{ color: 'var(--bal-primary)', fontWeight: 700 }}>
|
||||
{t('issuer_platform')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Divider />
|
||||
|
||||
<Stack sx={{ gap: 0.75 }}>
|
||||
<MetaRow label={t('invoice_number_label')} value={invoice.invoiceNumber} ltr />
|
||||
<MetaRow label={t('invoice_issued_at')} value={formatShamsiDate(invoice.issuedAt, locale)} />
|
||||
</Stack>
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{ key: 'service_cost', label: t('row_service_cost'), amountIrr: serviceIrr },
|
||||
{ key: 'commission', label: t('row_commission'), amountIrr: invoice.platformCommissionIrr },
|
||||
{
|
||||
key: 'vat',
|
||||
label: `${t('invoice_vat_on_commission')} (${vatPercent})`,
|
||||
amountIrr: invoice.vatIrr,
|
||||
},
|
||||
]}
|
||||
totalLabel={t('row_total')}
|
||||
totalAmountIrr={invoice.grossIrr}
|
||||
/>
|
||||
|
||||
{invoice.moadianStatus ? (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('moadian_label')}
|
||||
</Typography>
|
||||
<StatusChip status={MOADIAN_KIND[invoice.moadianStatus]} label={t(`moadian_${invoice.moadianStatus}`)} />
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{invoice.pdfUrl ? (
|
||||
<AppButton color="primary" variant="contained" startIcon="document" href={invoice.pdfUrl} openInNewTab sx={{ m: 0 }}>
|
||||
{t('download_invoice')}
|
||||
</AppButton>
|
||||
) : (
|
||||
<AppButton color="primary" variant="contained" startIcon="document" onClick={handlePrint} sx={{ m: 0 }}>
|
||||
{t('print_invoice')}
|
||||
</AppButton>
|
||||
)}
|
||||
<AppButton variant="text" onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({ label, value, ltr }: { label: string; value: string; ltr?: boolean }) {
|
||||
return (
|
||||
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }} dir={ltr ? 'ltr' : undefined}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { bookingInvoicePath, ROUTES } from '@/constants';
|
||||
import { formatIrrToToman } from '@/utils';
|
||||
import { useCheckoutSummary } from '@/services/payment';
|
||||
import { CHECKOUT_QUERY_BOOKING_ID, CHECKOUT_QUERY_REQUEST_ID } from '@/services/payment/constants';
|
||||
|
||||
/**
|
||||
* Post-payment confirmation — the booking is now **confirmed** (flipped by cache invalidation on the
|
||||
* return surface, never a blanket refetch). Links back to the f8 booking detail («مشاهده رزرو») and to
|
||||
* the invoice («دانلود فاکتور»). Without a `booking_id` (REQ-017 unmet on the real path) both fall back
|
||||
* to the bookings list and the invoice link is hidden — the invoice route needs the booking id.
|
||||
*/
|
||||
export default function CheckoutConfirmationPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<ConfirmationScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmationScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const bookingIdParam = params.get(CHECKOUT_QUERY_BOOKING_ID);
|
||||
const bookingId = bookingIdParam ? Number(bookingIdParam) : null;
|
||||
|
||||
const { data: summary } = useCheckoutSummary(
|
||||
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3, alignItems: 'center', textAlign: 'center' }}>
|
||||
<AppIcon icon="verified" size={64} color="var(--bal-success)" />
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('confirm_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('confirm_subtitle')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{summary ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider', width: '100%' }}
|
||||
>
|
||||
<Stack sx={{ gap: 0.5, alignItems: 'center' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('total_paid_label')}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(summary.totalIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.variantLabel} · {summary.nurseName}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1, width: '100%' }}>
|
||||
<AppButton
|
||||
color="primary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
router.push(`/${locale}${bookingId != null ? `${ROUTES.BOOKINGS}/${bookingId}` : ROUTES.BOOKINGS}`)
|
||||
}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
{bookingId != null ? (
|
||||
<AppButton
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon="document"
|
||||
onClick={() => router.push(`/${locale}${bookingInvoicePath(bookingId)}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('download_invoice')}
|
||||
</AppButton>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Box, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
CHECKOUT_QUERY_OUTCOME,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/payment/constants';
|
||||
import type { GatewayReturnOutcome } from '@/services/payment/types';
|
||||
|
||||
/**
|
||||
* Dev mock-gateway page — a **test harness, not a product feature**. It stands in for the PSP so the
|
||||
* initiate → redirect → return round-trip is exercisable without a real gateway: the payment mock's
|
||||
* `redirectUrl` points here, and the success/failure buttons drive both outcome branches of the return
|
||||
* surface (a real PSP redirects back after the cardholder pays or cancels). On the real path the
|
||||
* `redirectUrl` is the PSP's absolute URL and this page is never reached.
|
||||
*/
|
||||
export default function MockGatewayPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<MockGatewayScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function MockGatewayScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
|
||||
const requestId = params.get(CHECKOUT_QUERY_REQUEST_ID) ?? '';
|
||||
const transactionId = params.get(CHECKOUT_QUERY_TRANSACTION_ID) ?? '';
|
||||
|
||||
const returnWith = (outcome: GatewayReturnOutcome) => {
|
||||
const query = new URLSearchParams({
|
||||
[CHECKOUT_QUERY_REQUEST_ID]: requestId,
|
||||
[CHECKOUT_QUERY_TRANSACTION_ID]: transactionId,
|
||||
[CHECKOUT_QUERY_OUTCOME]: outcome,
|
||||
});
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon="payment" size={44} color="var(--bal-secondary)" />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{t('gateway_title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{t('gateway_hint')}
|
||||
</Typography>
|
||||
{transactionId ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
{/* dir scoped to the code only — the label is Persian and must keep the RTL base direction. */}
|
||||
{t('gateway_reference_label')}:{' '}
|
||||
<Box component="span" dir="ltr">
|
||||
#{transactionId}
|
||||
</Box>
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton color="secondary" variant="contained" size="large" onClick={() => returnWith('success')} sx={{ m: 0 }}>
|
||||
{t('gateway_pay_success')}
|
||||
</AppButton>
|
||||
<AppButton variant="text" color="error" onClick={() => returnWith('failure')} sx={{ m: 0 }}>
|
||||
{t('gateway_pay_fail')}
|
||||
</AppButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,299 @@
|
||||
'use client';
|
||||
import { Suspense } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppLoading, PlaceholderScreen } from '@/components';
|
||||
import { Suspense, useRef } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Paper, Skeleton, Stack, Typography } from '@mui/material';
|
||||
import {
|
||||
AppButton,
|
||||
AppIcon,
|
||||
AppLoading,
|
||||
CountdownTimer,
|
||||
EscrowNotice,
|
||||
PriceBreakdown,
|
||||
StatusChip,
|
||||
} from '@/components';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { formatShamsiDate } from '@/utils';
|
||||
import { useCheckoutSummary, useInitiatePayment } from '@/services/payment';
|
||||
import {
|
||||
BNPL_ENABLED,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/payment/constants';
|
||||
import type { CheckoutSummaryDto } from '@/services/payment/types';
|
||||
|
||||
/**
|
||||
* Checkout (pay & confirm) — **DEFERRED → frontend-phase-9-b10**. C5's "ادامه پرداخت" hands off here with
|
||||
* the accepted `request_id`; f9 builds the C6 summary + escrow notice + card/BNPL. This placeholder
|
||||
* confirms the hand-off arrived so the CTA doesn't dead-end. `useSearchParams` needs a Suspense boundary.
|
||||
* C6 — خلاصه و پرداخت (summary & pay). The acceptance badge, the served & reconciling
|
||||
* service-cost / commission / VAT / total breakdown, the load-bearing escrow trust notice, and the
|
||||
* «ادامه پرداخت ←» CTA that initiates the card payment and follows the gateway redirect. Reached from
|
||||
* C5's accept CTA with `?request_id=`. `useSearchParams` needs a Suspense boundary.
|
||||
*/
|
||||
export default function CheckoutPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<CheckoutDeferred />
|
||||
<CheckoutScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutDeferred() {
|
||||
const t = useTranslations('booking');
|
||||
function CheckoutScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const tb = useTranslations('booking');
|
||||
const tc = useTranslations('common');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const requestId = params.get('request_id') ?? '—';
|
||||
return <PlaceholderScreen icon="payment" title={t('step_payment')} description={`#${requestId}`} />;
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const validId = Number.isInteger(requestId) && requestId > 0;
|
||||
const { data: summary, isLoading, isError, refetch } = useCheckoutSummary(validId ? requestId : undefined);
|
||||
const initiate = useInitiatePayment();
|
||||
|
||||
// One idempotency key per payment ATTEMPT: created lazily on the first tap and reused across retries
|
||||
// of the same attempt (double-tap, transient network error). A new attempt — after a failed outcome the
|
||||
// user comes back to a fresh mount of this screen — gets a new key.
|
||||
const attemptKeyRef = useRef<string | null>(null);
|
||||
|
||||
// A malformed/missing request_id can never load — navigation, not a retry that would fire
|
||||
// `checkout_summary/undefined` (a manual refetch() bypasses the query's `enabled` gate).
|
||||
if (!validId) {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="error"
|
||||
tone="var(--bal-error)"
|
||||
title={t('error_title')}
|
||||
body={t('invalid_link_body')}
|
||||
ctaLabel={tb('bd_my_bookings')}
|
||||
onCta={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="error"
|
||||
tone="var(--bal-error)"
|
||||
title={t('error_title')}
|
||||
body={t('error_body')}
|
||||
ctaLabel={tc('retry')}
|
||||
onCta={() => refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isLoading || !summary) return <CheckoutSkeleton />;
|
||||
|
||||
const returnUrl = (extra?: Record<string, string>) => {
|
||||
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(requestId), ...extra });
|
||||
return `/${locale}${ROUTES.CHECKOUT_RETURN}?${query.toString()}`;
|
||||
};
|
||||
|
||||
// Anything other than "awaiting payment" cannot show a pay CTA — converge or explain instead.
|
||||
if (summary.requestStatus === 'converted') {
|
||||
return (
|
||||
<MessageCard
|
||||
icon="verified"
|
||||
tone="var(--bal-success)"
|
||||
title={t('already_paid_title')}
|
||||
body={t('already_paid_body')}
|
||||
ctaLabel={tb('converted_cta')}
|
||||
onCta={() => router.replace(returnUrl())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (summary.requestStatus !== 'accepted_awaiting_payment') {
|
||||
const expired = summary.requestStatus === 'payment_deadline_expired';
|
||||
return (
|
||||
<MessageCard
|
||||
icon="pending"
|
||||
tone="var(--bal-warning)"
|
||||
title={expired ? t('window_expired_title') : t('not_payable_title')}
|
||||
body={expired ? t('window_expired_body') : undefined}
|
||||
ctaLabel={t('back_to_request')}
|
||||
onCta={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handlePay = () => {
|
||||
attemptKeyRef.current ??= crypto.randomUUID();
|
||||
initiate.mutate(
|
||||
{ bookingRequestId: requestId, idempotencyKey: attemptKeyRef.current },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (!result.redirectUrl) {
|
||||
// No gateway hop to make — read the outcome directly.
|
||||
router.push(returnUrl({ [CHECKOUT_QUERY_TRANSACTION_ID]: String(result.transactionId) }));
|
||||
return;
|
||||
}
|
||||
if (/^https?:\/\//i.test(result.redirectUrl)) {
|
||||
// The real PSP page — a full navigation, outside the app router.
|
||||
window.location.assign(result.redirectUrl);
|
||||
return;
|
||||
}
|
||||
router.push(`/${locale}${result.redirectUrl}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
// "Already paid / already in progress / window lapsed" — benign convergence, never a toast:
|
||||
// the return surface reads the actual outcome and routes accordingly.
|
||||
router.replace(returnUrl());
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const busy = initiate.isPending || initiate.isSuccess;
|
||||
// ApiError.message is a raw server/network string, never localized — always show the i18n copy.
|
||||
const inlineError =
|
||||
initiate.error && !(initiate.error instanceof ApiError && initiate.error.status === 409)
|
||||
? t('initiate_failed')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center', textAlign: 'center' }}>
|
||||
<StatusChip status="verified" label={tb('accepted_badge')} />
|
||||
<Typography variant="h6" component="h1">
|
||||
{t('title_checkout')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<EngagementSummary summary={summary} locale={locale} />
|
||||
|
||||
{summary.paymentDeadlineAt ? (
|
||||
<Paper elevation={0} sx={{ p: 2, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<CountdownTimer
|
||||
deadlineIso={summary.paymentDeadlineAt}
|
||||
label={tb('payment_countdown_label')}
|
||||
elapsedText={tb('payment_elapsed')}
|
||||
urgent
|
||||
onElapsed={() => refetch()}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<PriceBreakdown
|
||||
rows={[
|
||||
{
|
||||
key: 'service_cost',
|
||||
// Quantity context per the wireframe («هزینه خدمت (۸ ساعت)») — the visit count is the only
|
||||
// quantity that always matches the charged gross (variant price × session count).
|
||||
label: t('row_service_cost_with_count', { count: summary.sessionCount }),
|
||||
amountIrr: summary.serviceCostIrr,
|
||||
},
|
||||
{ key: 'commission', label: t('row_commission'), amountIrr: summary.commissionIrr },
|
||||
{ key: 'vat', label: t('row_vat'), amountIrr: summary.vatIrr },
|
||||
]}
|
||||
totalLabel={t('row_total')}
|
||||
totalAmountIrr={summary.totalIrr}
|
||||
/>
|
||||
|
||||
<EscrowNotice />
|
||||
|
||||
{inlineError ? (
|
||||
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
|
||||
{inlineError}
|
||||
</AppAlert>
|
||||
) : null}
|
||||
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={busy}
|
||||
onClick={handlePay}
|
||||
sx={{ m: 0, py: 1.25 }}
|
||||
>
|
||||
{initiate.isPending ? t('state_initiating') : initiate.isSuccess ? t('state_redirecting') : t('cta_pay')}
|
||||
</AppButton>
|
||||
{/* The f11 BNPL seam (D1): a clearly-deferred secondary, gated off until the BNPL phase wires it. */}
|
||||
<AppButton variant="outlined" color="primary" disabled={!BNPL_ENABLED} sx={{ m: 0 }}>
|
||||
{t('bnpl_option')}
|
||||
</AppButton>
|
||||
{!BNPL_ENABLED ? (
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center' }}>
|
||||
{tc('coming_soon')}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Nurse/service/schedule mini-summary — page-only composition (C6 needs no address or price-per-unit). */
|
||||
function EngagementSummary({ summary, locale }: { summary: CheckoutSummaryDto; locale: string }) {
|
||||
const start = new Date(`${summary.requestedDate}T${summary.requestedTimeStart}`);
|
||||
const end = new Date(`${summary.requestedDate}T${summary.requestedTimeEnd}`);
|
||||
const timeFmt = new Intl.DateTimeFormat(locale === 'fa' ? 'fa-IR' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, borderRadius: 2, border: '1px solid', borderColor: 'divider' }}>
|
||||
<Stack sx={{ gap: 0.5 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||
{summary.variantLabel}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{summary.nurseName} · {summary.patientName}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{formatShamsiDate(start, locale)} · {timeFmt.format(start)} – {timeFmt.format(end)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
ctaLabel,
|
||||
onCta,
|
||||
}: {
|
||||
icon: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
ctaLabel: string;
|
||||
onCta: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1, mb: body ? 0.5 : 2 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 2 }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
<AppButton variant="contained" color="primary" onClick={onCta} sx={{ m: 0 }}>
|
||||
{ctaLabel}
|
||||
</AppButton>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckoutSkeleton() {
|
||||
return (
|
||||
<Stack sx={{ gap: 3 }}>
|
||||
<Stack sx={{ gap: 1, alignItems: 'center' }}>
|
||||
<Skeleton variant="rounded" width={140} height={24} />
|
||||
<Skeleton variant="text" width="50%" height={32} />
|
||||
</Stack>
|
||||
<Skeleton variant="rounded" height={96} />
|
||||
<Skeleton variant="rounded" height={64} />
|
||||
<Skeleton variant="rounded" height={160} />
|
||||
<Skeleton variant="rounded" height={56} />
|
||||
<Skeleton variant="rounded" height={48} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
import { Suspense, useEffect, useRef, type ReactNode } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { CircularProgress, Paper, Stack, Typography } from '@mui/material';
|
||||
import { AppButton, AppIcon, AppLoading, PaymentStatusBadge } from '@/components';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useConfirmGatewayReturn, usePaymentOutcome } from '@/services/payment';
|
||||
import { invalidateAfterPaymentSuccess } from '@/services/payment/invalidations';
|
||||
import {
|
||||
CHECKOUT_QUERY_BOOKING_ID,
|
||||
CHECKOUT_QUERY_OUTCOME,
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
} from '@/services/payment/constants';
|
||||
|
||||
/**
|
||||
* Return-from-gateway surface — drives the tail of the checkout state machine: report the return
|
||||
* (`useConfirmGatewayReturn`; the capture trigger in the mock, an outcome read on the real path), then a
|
||||
* **pending-callback** state backed by the backoff poll ("PSP received ≠ cash in bank" — pending is
|
||||
* normal, reflected calmly) until a terminal outcome: succeeded → invalidate the booking/request caches
|
||||
* and hand off to the confirmation screen; failed → a retry affordance (a fresh C6 mount = a new attempt
|
||||
* with a NEW idempotency key); window lapsed → back to the request's terminal card.
|
||||
*/
|
||||
export default function CheckoutReturnPage() {
|
||||
return (
|
||||
<Suspense fallback={<AppLoading />}>
|
||||
<ReturnScreen />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ReturnScreen() {
|
||||
const t = useTranslations('payment');
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const requestId = Number(params.get(CHECKOUT_QUERY_REQUEST_ID));
|
||||
const validId = Number.isInteger(requestId) && requestId > 0;
|
||||
const transactionIdParam = params.get(CHECKOUT_QUERY_TRANSACTION_ID);
|
||||
const transactionId = transactionIdParam ? Number(transactionIdParam) : null;
|
||||
const gatewayOutcome = params.get(CHECKOUT_QUERY_OUTCOME) === 'failure' ? ('failure' as const) : ('success' as const);
|
||||
|
||||
const confirm = useConfirmGatewayReturn();
|
||||
const { mutate: confirmMutate } = confirm;
|
||||
|
||||
// Fire the return report exactly once per mount — a browser refresh on this page replays it, which the
|
||||
// backend/mock treat as a duplicate (idempotent convergence, per the webhook-dedup rule).
|
||||
const confirmFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (confirmFiredRef.current || !validId) return;
|
||||
confirmFiredRef.current = true;
|
||||
confirmMutate({ bookingRequestId: requestId, transactionId, outcome: gatewayOutcome });
|
||||
}, [confirmMutate, validId, requestId, transactionId, gatewayOutcome]);
|
||||
|
||||
// The poll takes over once the return report settles (its result is primed into the outcome key).
|
||||
const confirmSettled = confirm.isSuccess || confirm.isError;
|
||||
const outcomeQuery = usePaymentOutcome(validId ? requestId : undefined, {
|
||||
enabled: confirmSettled,
|
||||
});
|
||||
// Trust the outcome cache only after THIS mount's report settled — a previous attempt's failed outcome
|
||||
// survives in the cache and would otherwise flash a false "payment failed" (with a live retry CTA)
|
||||
// while the current attempt's capture is still in flight.
|
||||
const outcome = confirmSettled ? outcomeQuery.data : undefined;
|
||||
|
||||
const succeeded = outcome?.transactionStatus === 'succeeded' || outcome?.requestStatus === 'converted';
|
||||
const windowExpired = outcome?.requestStatus === 'payment_deadline_expired';
|
||||
const failed = !succeeded && !windowExpired && outcome?.transactionStatus === 'failed';
|
||||
|
||||
// Hand off to the confirmation exactly once. The confirm mutation already invalidated on an immediate
|
||||
// success; a success that arrived later through the poll invalidates here instead (never twice).
|
||||
const navigatedRef = useRef(false);
|
||||
const confirmSawSuccess = confirm.data?.transactionStatus === 'succeeded';
|
||||
useEffect(() => {
|
||||
if (!succeeded || navigatedRef.current || !outcome) return;
|
||||
navigatedRef.current = true;
|
||||
if (!confirmSawSuccess) {
|
||||
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
|
||||
}
|
||||
const query = new URLSearchParams({ [CHECKOUT_QUERY_REQUEST_ID]: String(outcome.bookingRequestId) });
|
||||
if (outcome.bookingId != null) query.set(CHECKOUT_QUERY_BOOKING_ID, String(outcome.bookingId));
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT_CONFIRMATION}?${query.toString()}`);
|
||||
}, [succeeded, outcome, confirmSawSuccess, queryClient, router, locale]);
|
||||
|
||||
if (!validId) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('error_title')}>
|
||||
<AppButton variant="contained" onClick={() => router.replace(`/${locale}${ROUTES.BOOKINGS}`)} sx={{ m: 0 }}>
|
||||
{t('view_booking')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (windowExpired) {
|
||||
return (
|
||||
<StateCard icon="pending" tone="var(--bal-warning)" title={t('window_expired_title')} body={t('window_expired_body')}>
|
||||
<AppButton
|
||||
variant="contained"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<StateCard icon="error" tone="var(--bal-error)" title={t('state_failed_title')} body={t('state_failed_hint')}>
|
||||
<PaymentStatusBadge status="failed" />
|
||||
<AppButton
|
||||
color="secondary"
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() =>
|
||||
router.replace(`/${locale}${ROUTES.CHECKOUT}?${CHECKOUT_QUERY_REQUEST_ID}=${requestId}`)
|
||||
}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('retry_payment')}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="text"
|
||||
onClick={() => router.replace(`/${locale}${ROUTES.BOOKING_REQUEST_STATUS}/${requestId}`)}
|
||||
sx={{ m: 0 }}
|
||||
>
|
||||
{t('back_to_request')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Pending-callback (and the brief succeeded → confirmation hand-off): a calm waiting state.
|
||||
return (
|
||||
<StateCard icon="payment" tone="var(--bal-secondary)" title={t('state_pending_title')} body={t('state_pending_hint')}>
|
||||
<CircularProgress color="primary" size="2.5rem" />
|
||||
<PaymentStatusBadge status="pending" />
|
||||
{/* Manual re-check — covers the bounded poll giving up on a very slow callback. */}
|
||||
<AppButton variant="text" disabled={outcomeQuery.isFetching} onClick={() => outcomeQuery.refetch()} sx={{ m: 0 }}>
|
||||
{t('check_again')}
|
||||
</AppButton>
|
||||
</StateCard>
|
||||
);
|
||||
}
|
||||
|
||||
function StateCard({
|
||||
icon,
|
||||
tone,
|
||||
title,
|
||||
body,
|
||||
children,
|
||||
}: {
|
||||
icon: string;
|
||||
tone: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
|
||||
<AppIcon icon={icon} size={44} color={tone} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{body ? (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{body}
|
||||
</Typography>
|
||||
) : null}
|
||||
{children}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -120,7 +120,12 @@ export default function BookingRequestStatusPage() {
|
||||
tone="var(--bal-success)"
|
||||
title={t('converted_title')}
|
||||
ctaLabel={t('converted_cta')}
|
||||
onCta={() => router.push(`/${locale}${ROUTES.BOOKINGS}`)}
|
||||
// Deep-link the booking when the id is known (client-augmented, REQ-017); list fallback otherwise.
|
||||
onCta={() =>
|
||||
router.push(
|
||||
`/${locale}${request.bookingId != null ? `${ROUTES.BOOKINGS}/${request.bookingId}` : ROUTES.BOOKINGS}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import faMessages from '../../../messages/fa.json';
|
||||
|
||||
// next-intl mocked to read the REAL fa message file, so the test pins the product-mandated escrow copy
|
||||
// as it actually ships — a rewording in fa.json (or a broken key) fails here as a conscious change.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: (namespace: string) => (key: string) => {
|
||||
const messages = jest.requireActual('../../../messages/fa.json') as Record<string, Record<string, string>>;
|
||||
return messages[namespace][key];
|
||||
},
|
||||
}));
|
||||
|
||||
import EscrowNotice from './EscrowNotice';
|
||||
|
||||
function renderNotice() {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<EscrowNotice />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('<EscrowNotice/> component', () => {
|
||||
it('renders the mandated escrow copy verbatim from fa.json', () => {
|
||||
renderNotice();
|
||||
expect(
|
||||
screen.getByText('مبلغ بهصورت امانی نزد بالینیار میماند و پس از پایان ویزیت آزاد میشود'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(faMessages.payment.escrow_notice)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an info (trust) surface, never an error tone', () => {
|
||||
renderNotice();
|
||||
const alert = screen.getByTestId('escrow-notice');
|
||||
expect(alert.className).toContain('MuiAlert-colorInfo');
|
||||
expect(alert.className).not.toContain('MuiAlert-colorError');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import AppAlert from '@/components/common/AppAlert';
|
||||
import AppIcon from '@/components/common/AppIcon';
|
||||
|
||||
/**
|
||||
* The escrow trust callout — product-mandated copy («مبلغ بهصورت امانی نزد بالینیار میماند و پس از
|
||||
* پایان ویزیت آزاد میشود»), rendered **verbatim in fa** as an info/trust surface (teal `--bal-info`
|
||||
* tokens, a lock — never an error tone). It is *why* the family pays on-platform, so f10/f11 must reuse
|
||||
* this exact component rather than re-writing the message. Self-translating (the copy is fixed), no props.
|
||||
* @component EscrowNotice
|
||||
*/
|
||||
const EscrowNotice: FunctionComponent = () => {
|
||||
const t = useTranslations('payment');
|
||||
return (
|
||||
<AppAlert
|
||||
severity="info"
|
||||
variant="outlined"
|
||||
icon={<AppIcon icon="lock" size={20} color="var(--bal-primary)" />}
|
||||
data-testid="escrow-notice"
|
||||
sx={{
|
||||
marginY: 0,
|
||||
// --bal-primary, not --bal-info: the info token is an alert *background* color and is too dark
|
||||
// to read as text on the dark scheme; primary is the same deep teal in light and lifts in dark.
|
||||
borderColor: 'var(--bal-primary)',
|
||||
color: 'var(--bal-primary)',
|
||||
backgroundColor: 'var(--bal-primary-soft)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{t('escrow_notice')}
|
||||
</AppAlert>
|
||||
);
|
||||
};
|
||||
|
||||
export default EscrowNotice;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './EscrowNotice';
|
||||
@@ -0,0 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
// next-intl mocked to echo keys — each status must resolve its own pstatus_* label key.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
|
||||
function renderBadge(status: PaymentTransactionStatus) {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<PaymentStatusBadge status={status} />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
// Every b10 wire status → its label key + the semantic chip kind it must map to.
|
||||
const CASES: Array<{ status: PaymentTransactionStatus; kind: string }> = [
|
||||
{ status: 'pending', kind: 'pending' },
|
||||
{ status: 'succeeded', kind: 'verified' },
|
||||
{ status: 'failed', kind: 'rejected' },
|
||||
];
|
||||
|
||||
describe('<PaymentStatusBadge/> component', () => {
|
||||
it.each(CASES)('renders the $status status with its label key and chip kind', ({ status, kind }) => {
|
||||
const { container } = renderBadge(status);
|
||||
expect(screen.getByText(`pstatus_${status}`)).toBeInTheDocument();
|
||||
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { ChipProps } from '@mui/material/Chip';
|
||||
import StatusChip, { StatusKind } from '@/components/StatusChip';
|
||||
import type { PaymentTransactionStatus } from '@/services/payment/types';
|
||||
|
||||
// The full b10 enum → chip kind; a status missing here fails the type check, so a contract enum change
|
||||
// surfaces at build time instead of rendering an unmapped chip.
|
||||
const STATUS_KIND: Record<PaymentTransactionStatus, StatusKind> = {
|
||||
pending: 'pending',
|
||||
succeeded: 'verified',
|
||||
failed: 'rejected',
|
||||
};
|
||||
|
||||
export interface PaymentStatusBadgeProps extends Omit<ChipProps, 'color' | 'icon' | 'label'> {
|
||||
status: PaymentTransactionStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment-transaction status chip: maps the b10 wire code to a `--bal-*` semantic StatusChip variant and
|
||||
* an i18n label (`payment.pstatus_*` — labels are keys off the code, never derived from it). Shared by
|
||||
* the checkout return surface now and the f10 refund / f11 BNPL surfaces later.
|
||||
* @component PaymentStatusBadge
|
||||
*/
|
||||
const PaymentStatusBadge: FunctionComponent<PaymentStatusBadgeProps> = ({ status, ...rest }) => {
|
||||
const t = useTranslations('payment');
|
||||
return <StatusChip status={STATUS_KIND[status]} label={t(`pstatus_${status}`)} {...rest} />;
|
||||
};
|
||||
|
||||
export default PaymentStatusBadge;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PaymentStatusBadge';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
@@ -0,0 +1,56 @@
|
||||
import { FunctionComponent } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ThemeProvider } from '../../theme';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
// next-intl mocked to echo keys; locale = en so money formats with ASCII digits we can assert on.
|
||||
jest.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'en',
|
||||
}));
|
||||
|
||||
import PriceBreakdown, { PriceBreakdownProps } from './PriceBreakdown';
|
||||
|
||||
const ComponentToTest: FunctionComponent<PriceBreakdownProps> = (props) => (
|
||||
<ThemeProvider>
|
||||
<PriceBreakdown {...props} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// Served-shaped figures: service + commission + VAT = total (the phase's reconciliation rule).
|
||||
const ROWS = [
|
||||
{ key: 'service_cost', label: 'Service cost', amountIrr: '39060000' },
|
||||
{ key: 'commission', label: 'Balinyaar fee', amountIrr: '5400000' },
|
||||
{ key: 'vat', label: 'VAT', amountIrr: '540000' },
|
||||
];
|
||||
const TOTAL = '45000000';
|
||||
|
||||
describe('<PriceBreakdown/> component', () => {
|
||||
it('renders every row label with its Toman-formatted amount', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
for (const row of ROWS) {
|
||||
expect(screen.getByText(row.label)).toBeInTheDocument();
|
||||
expect(screen.getByText(formatIrrToToman(row.amountIrr, 'en'))).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders a total equal to the integer sum of the served rows', () => {
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
const sum = ROWS.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
expect(sum.toString()).toBe(TOTAL);
|
||||
expect(screen.getByText(new RegExp(formatIrrToToman(TOTAL, 'en')))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes each row via a data attribute', () => {
|
||||
const { container } = render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr={TOTAL} />);
|
||||
expect(container.querySelector('[data-row="service_cost"]')).toBeInTheDocument();
|
||||
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns loudly in dev when the rows do not reconcile to the total', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
render(<ComponentToTest rows={ROWS} totalLabel="Total" totalAmountIrr="45000001" />);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('must reconcile'));
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { Divider, Paper, Stack, Typography } from '@mui/material';
|
||||
import { formatIrrToToman, parseIrr } from '@/utils';
|
||||
|
||||
export interface PriceBreakdownRow {
|
||||
/** Stable row key (e.g. `service_cost`) — also exposed as `data-row` for tests/automation. */
|
||||
key: string;
|
||||
/** Display label — already translated by the caller (labels are i18n keys, never derived from codes). */
|
||||
label: string;
|
||||
/** IRR digit-string, straight off the wire. */
|
||||
amountIrr: string;
|
||||
}
|
||||
|
||||
export interface PriceBreakdownProps {
|
||||
rows: PriceBreakdownRow[];
|
||||
totalLabel: string;
|
||||
/** IRR digit-string. Must equal the integer sum of `rows` — see the reconciliation guard below. */
|
||||
totalAmountIrr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reconciling money breakdown (C6 checkout, invoice; f10/f11 reuse it for refunds/BNPL). Rows and
|
||||
* total are **served amounts** — this component only formats (BigInt-safe, Toman display via the money
|
||||
* util) and never computes a figure. The one thing it enforces is the phase's hard rule: the displayed
|
||||
* rows must sum to the displayed total. A mismatch is a data bug upstream, so it is surfaced loudly in
|
||||
* dev (console.error) rather than silently rendered.
|
||||
* @component PriceBreakdown
|
||||
*/
|
||||
const PriceBreakdown: FunctionComponent<PriceBreakdownProps> = ({ rows, totalLabel, totalAmountIrr }) => {
|
||||
const locale = useLocale();
|
||||
const tc = useTranslations('common');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const sum = rows.reduce((acc, row) => acc + parseIrr(row.amountIrr), BigInt(0));
|
||||
if (sum !== parseIrr(totalAmountIrr)) {
|
||||
console.error(
|
||||
`PriceBreakdown: rows sum to ${sum} but total is ${totalAmountIrr} — a breakdown must reconcile to the rial.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
|
||||
<Stack sx={{ gap: 1.25 }}>
|
||||
{rows.map((row) => (
|
||||
<Stack key={row.key} data-row={row.key} direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
{row.label}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatIrrToToman(row.amountIrr, locale)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
<Divider />
|
||||
<Stack data-row="total" direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
|
||||
{totalLabel}
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
|
||||
{formatIrrToToman(totalAmountIrr, locale)} {tc('currency_toman')}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriceBreakdown;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './PriceBreakdown';
|
||||
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
@@ -21,6 +21,9 @@ import NurseResultCard from './NurseResultCard';
|
||||
import ServicePriceRow from './ServicePriceRow';
|
||||
import CountdownTimer from './CountdownTimer';
|
||||
import BookingRequestSummaryCard from './BookingRequestSummaryCard';
|
||||
import PriceBreakdown from './PriceBreakdown';
|
||||
import EscrowNotice from './EscrowNotice';
|
||||
import PaymentStatusBadge from './PaymentStatusBadge';
|
||||
|
||||
export {
|
||||
UserInfo,
|
||||
@@ -44,6 +47,9 @@ export {
|
||||
ServicePriceRow,
|
||||
CountdownTimer,
|
||||
BookingRequestSummaryCard,
|
||||
PriceBreakdown,
|
||||
EscrowNotice,
|
||||
PaymentStatusBadge,
|
||||
};
|
||||
export type { PlaceholderScreenProps } from './PlaceholderScreen';
|
||||
export type { OtpInputProps } from './OtpInput';
|
||||
@@ -65,3 +71,5 @@ export type { NurseResultCardProps } from './NurseResultCard';
|
||||
export type { ServicePriceRowProps } from './ServicePriceRow';
|
||||
export type { CountdownTimerProps } from './CountdownTimer';
|
||||
export type { BookingRequestSummaryCardProps } from './BookingRequestSummaryCard';
|
||||
export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
|
||||
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
|
||||
|
||||
@@ -18,8 +18,14 @@ export const ROUTES = {
|
||||
BOOKING_REQUEST: '/bookings/request',
|
||||
// C5 awaiting-acceptance base — append `/{id}`; create navigates here, the id keys the polled status.
|
||||
BOOKING_REQUEST_STATUS: '/bookings/request',
|
||||
// Checkout (pay & confirm) — the C5 accept CTA hands off here (screen itself is DEFERRED → f9 stub).
|
||||
// Checkout (f9) — C6 summary & pay; the C5 accept CTA hands off here with `?request_id=`.
|
||||
CHECKOUT: '/bookings/checkout',
|
||||
// Dev mock-gateway harness (test-only stand-in for the PSP redirect; the mock initiate points here).
|
||||
CHECKOUT_GATEWAY: '/bookings/checkout/gateway',
|
||||
// Return-from-gateway surface — pending-callback poll → succeeded/failed states.
|
||||
CHECKOUT_RETURN: '/bookings/checkout/return',
|
||||
// Post-payment success screen — links to the booking detail + invoice.
|
||||
CHECKOUT_CONFIRMATION: '/bookings/checkout/confirmation',
|
||||
PATIENTS: '/patients',
|
||||
// Address book — cascading region dropdowns + map-pin picker; reached from the profile hub.
|
||||
ADDRESSES: '/addresses',
|
||||
@@ -49,5 +55,9 @@ export const ROUTES = {
|
||||
ADMIN_NOTIFICATIONS: '/admin/notifications',
|
||||
} as const;
|
||||
|
||||
/** A booking's invoice view (f9) — keyed by the booking the invoice belongs to. */
|
||||
export const bookingInvoicePath = (bookingId: number | string): string =>
|
||||
`${ROUTES.BOOKINGS}/${bookingId}/invoice`;
|
||||
|
||||
/** Paths (without locale prefix) that bypass auth in middleware. */
|
||||
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
|
||||
|
||||
@@ -14,13 +14,14 @@ const BASE = '/api/v1/booking_requests';
|
||||
|
||||
/**
|
||||
* The b8 wire `BookingRequestDto` — identical to our app DTO minus the client-augmented `variantPrice`
|
||||
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price).
|
||||
* (REQ-013: the contract returns `variantLabel` + `variantPriceUnit` but no price) and `bookingId`
|
||||
* (REQ-017: a `converted` request gives no way to reach the booking it became).
|
||||
*/
|
||||
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice'>;
|
||||
type BookingRequestWireDto = Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>;
|
||||
|
||||
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted `variantPrice` to `null`. */
|
||||
/** Map the wire DTO to the app DTO, defaulting the not-yet-contracted fields to `null`. */
|
||||
function toDto(wire: BookingRequestWireDto): BookingRequestDto {
|
||||
return { ...wire, variantPrice: null };
|
||||
return { ...wire, variantPrice: null, bookingId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -36,6 +36,7 @@ function seedRow(overrides: Partial<BookingRequestDto>): BookingRequestDto {
|
||||
return {
|
||||
id,
|
||||
status: 'pending_nurse_response',
|
||||
bookingId: null,
|
||||
nurseId: 1,
|
||||
nurseName: 'مریم رضایی',
|
||||
nurseRating: 4.9,
|
||||
@@ -167,6 +168,7 @@ function buildFromContext(
|
||||
return {
|
||||
id,
|
||||
status: 'pending_nurse_response',
|
||||
bookingId: null,
|
||||
nurseId: payload.nurseId,
|
||||
nurseName: context?.nurseName ?? '',
|
||||
nurseRating: context?.nurseRating ?? 0,
|
||||
@@ -283,3 +285,20 @@ export const bookingRequestsMockApi: BookingRequestsApi = {
|
||||
return updated;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock-only capture bridge (f9): the payment mock's stand-in for the server's webhook-confirm →
|
||||
* `BookingFactory` step. Marks a paid request `converted` and stamps the client-augmented `bookingId`
|
||||
* (REQ-017) so C5's terminal card and the checkout confirmation can deep-link the booking. NOT part of
|
||||
* the `BookingRequestsApi` seam — no real endpoint does this from the client; only `services/payment`'s
|
||||
* mock imports it.
|
||||
*/
|
||||
export function mockMarkBookingRequestConverted(id: number, bookingId: number): BookingRequestDto {
|
||||
const dto = find(id);
|
||||
if (dto.status !== 'accepted_awaiting_payment') {
|
||||
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
|
||||
}
|
||||
const updated: BookingRequestDto = { ...dto, status: 'converted', bookingId };
|
||||
store = store.map((row) => (row.id === id ? updated : row));
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -59,10 +59,17 @@ export function isTerminalBookingRequestStatus(status: BookingRequestStatus): bo
|
||||
* `variantPrice` is **client-augmented**: the contract DTO returns `variantLabel` + `variantPriceUnit`
|
||||
* but no price (filed as REQ-013). The mock supplies it so the summary card can price the service; the
|
||||
* real client leaves it `null` (the summary then hides the amount) until the field lands.
|
||||
*
|
||||
* `bookingId` is likewise **client-augmented** (filed as REQ-017): once a paid request converts, the b8
|
||||
* DTO gives no way to reach the booking it became. The mock stamps it at capture so C5's `converted`
|
||||
* card and the checkout confirmation can deep-link the booking; the real client leaves it `null` (those
|
||||
* surfaces then fall back to the bookings list) until the field lands.
|
||||
*/
|
||||
export interface BookingRequestDto {
|
||||
id: number;
|
||||
status: BookingRequestStatus;
|
||||
/** Client-augmented (REQ-017): the converted booking's id, or `null` until converted / on the real path. */
|
||||
bookingId: number | null;
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
nurseRating: number;
|
||||
|
||||
@@ -380,3 +380,84 @@ export const bookingsMockApi: BookingsApi = {
|
||||
return { ...updated };
|
||||
},
|
||||
};
|
||||
|
||||
// Converted bookings get ids above the seeded 5001/5002 range; their sessions above the seeded 700xx range.
|
||||
let nextConvertedBookingId = 6001;
|
||||
let nextConvertedSessionId = 80001;
|
||||
|
||||
/** What the payment mock knows about a captured request — enough to snapshot a faithful booking. */
|
||||
export interface ConvertedBookingSeed {
|
||||
bookingRequestId: number;
|
||||
nurseId: number;
|
||||
nurseName: string;
|
||||
patientId: number;
|
||||
patientName: string;
|
||||
variantId: number;
|
||||
variantLabel: string;
|
||||
variantPriceUnit: string;
|
||||
customerAddressId: number;
|
||||
/** Pre-serialized address snapshot (customer view; the nurse view masks it), or `null` if unknown. */
|
||||
addressSnapshotJson: string | null;
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
pspFeeAmount: string | null;
|
||||
platformFeeRate: number;
|
||||
scheduledDate: string;
|
||||
scheduledTimeStart: string;
|
||||
scheduledTimeEnd: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock-only capture bridge (f9): the client stand-in for the server's webhook-confirm → `BookingFactory`
|
||||
* step (b10) that creates & confirms the booking. Inserts a **confirmed** single-session booking built
|
||||
* from the paid request's snapshot, so the f8 booking detail/list show the conversion immediately. NOT
|
||||
* part of the `BookingsApi` seam — no real endpoint does this from the client; only `services/payment`'s
|
||||
* mock imports it.
|
||||
*/
|
||||
export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingDetailDto {
|
||||
const now = new Date().toISOString();
|
||||
const bookingId = nextConvertedBookingId++;
|
||||
const booking: BookingDetailDto = {
|
||||
id: bookingId,
|
||||
bookingRequestId: seed.bookingRequestId,
|
||||
status: 'confirmed',
|
||||
nurseId: seed.nurseId,
|
||||
nurseName: seed.nurseName,
|
||||
patientId: seed.patientId,
|
||||
patientName: seed.patientName,
|
||||
variantId: seed.variantId,
|
||||
variantSnapshotJson: JSON.stringify({ displayName: seed.variantLabel, priceUnit: seed.variantPriceUnit }),
|
||||
customerAddressId: seed.customerAddressId,
|
||||
addressSnapshotJson: seed.addressSnapshotJson,
|
||||
grossPriceIrr: seed.grossPriceIrr,
|
||||
balinyaarCommissionIrr: seed.balinyaarCommissionIrr,
|
||||
nursePayoutAmount: seed.nursePayoutAmount,
|
||||
pspFeeAmount: seed.pspFeeAmount,
|
||||
platformFeeRate: seed.platformFeeRate,
|
||||
sessionCount: 1,
|
||||
scheduledDate: seed.scheduledDate,
|
||||
scheduledTimeStart: seed.scheduledTimeStart,
|
||||
scheduledTimeEnd: seed.scheduledTimeEnd,
|
||||
confirmedAt: now,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
cancelledBy: null,
|
||||
cancellationReason: null,
|
||||
cancellationPolicyCode: null,
|
||||
cancellationRefundPercentage: null,
|
||||
refundableAmountIrr: null,
|
||||
disputeWindowEndsAt: null,
|
||||
createdAt: now,
|
||||
sessions: [
|
||||
{
|
||||
...makeSession(nextConvertedSessionId++, 1, 0, seed.nursePayoutAmount),
|
||||
scheduledDate: seed.scheduledDate,
|
||||
scheduledTimeStart: seed.scheduledTimeStart,
|
||||
scheduledTimeEnd: seed.scheduledTimeEnd,
|
||||
},
|
||||
],
|
||||
};
|
||||
bookings = [booking, ...bookings];
|
||||
return cloneBooking(booking);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import type { BookingRequestDto } from '@/services/bookingRequests/types';
|
||||
import type {
|
||||
CheckoutSummaryDto,
|
||||
ConfirmGatewayReturnInput,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentResult,
|
||||
InvoiceDto,
|
||||
PaymentApi,
|
||||
PaymentOutcomeDto,
|
||||
} from '../types';
|
||||
|
||||
const BOOKINGS = '/api/v1/bookings';
|
||||
const BOOKING_REQUESTS = '/api/v1/booking_requests';
|
||||
const INVOICES = '/api/v1/invoices';
|
||||
|
||||
/** The header b10's initiate (and b12's BNPL initiate) read the per-attempt idempotency key from. */
|
||||
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `PaymentApi` seam. `initiatePayment` and `getInvoice` map the
|
||||
* published b10/b11 contracts 1:1. The other two calls cover contract gaps the frontend filed:
|
||||
* - `getCheckoutSummary` targets the **REQ-016 proposed route** (no checkout-summary endpoint exists;
|
||||
* the b8 request DTO is money-free, and the client must never derive commission/VAT itself).
|
||||
* - `getPaymentOutcome`/`confirmGatewayReturn` poll the existing `booking_requests/get/{id}` and map its
|
||||
* status (`converted` → succeeded, `payment_deadline_expired` → failed, else pending) — the b10
|
||||
* contract has no client transaction read (REQ-017); the server verifies captures inside the webhook
|
||||
* handler, so on return from the PSP there is nothing to "verify" client-side, only an outcome to read.
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_PAYMENT_MOCK = true`) — see `constants.ts` for why.
|
||||
*/
|
||||
export const paymentClientApi: PaymentApi = {
|
||||
getCheckoutSummary: async (bookingRequestId: number) =>
|
||||
// REQ-016 proposed slug (action-style, mirrors b8's `[controller]/[action]` routing). 404s until the
|
||||
// backend delivers it — which is why the domain stays mock-primary.
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<CheckoutSummaryDto>>(
|
||||
`${BOOKING_REQUESTS}/checkout_summary/${bookingRequestId}`,
|
||||
),
|
||||
),
|
||||
|
||||
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<InitiatePaymentResult>>(`${BOOKINGS}/${bookingRequestId}/payments`, {
|
||||
method: 'POST',
|
||||
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||
}),
|
||||
),
|
||||
|
||||
confirmGatewayReturn: async ({ bookingRequestId }: ConfirmGatewayReturnInput) =>
|
||||
// The PSP hit the webhook before redirecting back — there is nothing to submit; read the outcome.
|
||||
paymentClientApi.getPaymentOutcome(bookingRequestId),
|
||||
|
||||
getPaymentOutcome: async (bookingRequestId: number) => {
|
||||
const request = unwrap(
|
||||
await clientFetch<ApiEnvelope<Omit<BookingRequestDto, 'variantPrice' | 'bookingId'>>>(
|
||||
`${BOOKING_REQUESTS}/get/${bookingRequestId}`,
|
||||
),
|
||||
);
|
||||
const outcome: PaymentOutcomeDto = {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
transactionStatus:
|
||||
request.status === 'converted'
|
||||
? 'succeeded'
|
||||
: request.status === 'payment_deadline_expired'
|
||||
? 'failed'
|
||||
: 'pending',
|
||||
// REQ-017: the b8 DTO gives no way to reach the converted booking — the confirmation falls back
|
||||
// to the bookings list until the field lands.
|
||||
bookingId: null,
|
||||
};
|
||||
return outcome;
|
||||
},
|
||||
|
||||
getInvoice: async (bookingId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<InvoiceDto>>(`${INVOICES}/${bookingId}`)),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_PAYMENT_MOCK } from '../constants';
|
||||
import type { PaymentApi } from '../types';
|
||||
import { paymentClientApi } from './clientApi';
|
||||
import { paymentMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected `PaymentApi` implementation — the single seam the hooks import. Selection is by config
|
||||
* (`USE_PAYMENT_MOCK`), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const paymentApi: PaymentApi = USE_PAYMENT_MOCK ? paymentMockApi : paymentClientApi;
|
||||
@@ -0,0 +1,267 @@
|
||||
import { multiplyIrr, parseIrr, sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { ROUTES } from '@/constants';
|
||||
import {
|
||||
bookingRequestsMockApi,
|
||||
mockMarkBookingRequestConverted,
|
||||
} from '@/services/bookingRequests/apis/mockApi';
|
||||
import { mockInsertConvertedBooking } from '@/services/bookings/apis/mockApi';
|
||||
import type { BookingRequestDto } from '@/services/bookingRequests/types';
|
||||
import {
|
||||
CHECKOUT_QUERY_REQUEST_ID,
|
||||
CHECKOUT_QUERY_TRANSACTION_ID,
|
||||
MOCK_PLATFORM_FEE_RATE,
|
||||
MOCK_VAT_RATE,
|
||||
} from '../constants';
|
||||
import type {
|
||||
CheckoutSummaryDto,
|
||||
ConfirmGatewayReturnInput,
|
||||
InitiatePaymentInput,
|
||||
InitiatePaymentResult,
|
||||
InvoiceDto,
|
||||
PaymentApi,
|
||||
PaymentOutcomeDto,
|
||||
PaymentTransactionStatus,
|
||||
} from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 350;
|
||||
|
||||
/** A request is a single visit until multi-session requests exist (b8 carries one date/time window). */
|
||||
const SESSION_COUNT = 1;
|
||||
|
||||
// Integer-only rate math: rates as parts-per-10000 so the money path never touches a float.
|
||||
// (BigInt via the constructor — the tsconfig target predates ES2020 literals, matching utils/money.ts.)
|
||||
const RATE_SCALE = BigInt(10_000);
|
||||
const FEE_RATE_PPM = BigInt(Math.round(MOCK_PLATFORM_FEE_RATE * Number(RATE_SCALE)));
|
||||
const VAT_RATE_PPM = BigInt(Math.round(MOCK_VAT_RATE * Number(RATE_SCALE)));
|
||||
|
||||
/**
|
||||
* The C6 money decomposition, all BigInt (mirrors what REQ-016 asks the server to serve):
|
||||
* commission (net) = gross × feeRate; VAT = commission × vatRate (b11 rule: VAT is computed ON the
|
||||
* commission, the platform's taxable supply); service cost = the remainder (= the nurse payout leg).
|
||||
* Rows reconcile by construction: service + commission + vat = gross, and the b10 three-amount
|
||||
* invariant holds with balinyaarCommission = commission + vat.
|
||||
*/
|
||||
function splitAmounts(grossIrr: string) {
|
||||
const gross = parseIrr(grossIrr);
|
||||
const commissionNet = (gross * FEE_RATE_PPM) / RATE_SCALE;
|
||||
const vat = (commissionNet * VAT_RATE_PPM) / RATE_SCALE;
|
||||
const serviceCost = gross - commissionNet - vat;
|
||||
return {
|
||||
grossPriceIrr: gross.toString(),
|
||||
commissionIrr: commissionNet.toString(),
|
||||
vatIrr: vat.toString(),
|
||||
serviceCostIrr: serviceCost.toString(),
|
||||
balinyaarCommissionIrr: (commissionNet + vat).toString(),
|
||||
nursePayoutAmount: serviceCost.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
interface MockTransaction {
|
||||
transactionId: number;
|
||||
bookingRequestId: number;
|
||||
idempotencyKey: string;
|
||||
status: PaymentTransactionStatus;
|
||||
gatewayReferenceCode: string;
|
||||
grossPriceIrr: string;
|
||||
bookingId: number | null;
|
||||
}
|
||||
|
||||
// Module-level stores (one browser session) — the same singleton pattern as the f7/f8 mocks, so the
|
||||
// checkout, the C5 poll, and the bookings list all observe the same capture.
|
||||
let transactions: MockTransaction[] = [];
|
||||
const invoices: Record<number, InvoiceDto> = {};
|
||||
let nextTransactionId = 42_001;
|
||||
let nextInvoiceId = 101;
|
||||
|
||||
function latestTransactionFor(bookingRequestId: number): MockTransaction | undefined {
|
||||
return transactions.find((t) => t.bookingRequestId === bookingRequestId);
|
||||
}
|
||||
|
||||
function requestGross(request: BookingRequestDto): string {
|
||||
// The frozen gross the server would charge: variant price × session count, never client-supplied.
|
||||
// A request without a price (REQ-013 unmet) must fail loudly — never a reconciling 0-rial checkout.
|
||||
if (request.variantPrice == null) {
|
||||
throw new ApiError(409, 'Request has no priced variant', 'unpriced_request');
|
||||
}
|
||||
return multiplyIrr(request.variantPrice, SESSION_COUNT);
|
||||
}
|
||||
|
||||
function toOutcome(request: BookingRequestDto, transaction: MockTransaction | undefined): PaymentOutcomeDto {
|
||||
return {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
transactionStatus: transaction?.status ?? null,
|
||||
bookingId: request.bookingId ?? transaction?.bookingId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The webhook-confirm stand-in: flip the transaction, convert the request, insert the booking, invoice it. */
|
||||
function capture(request: BookingRequestDto, transaction: MockTransaction): PaymentOutcomeDto {
|
||||
const amounts = splitAmounts(transaction.grossPriceIrr);
|
||||
const booking = mockInsertConvertedBooking({
|
||||
bookingRequestId: request.id,
|
||||
nurseId: request.nurseId,
|
||||
nurseName: request.nurseName,
|
||||
patientId: request.patientId,
|
||||
patientName: request.patientName,
|
||||
variantId: request.variantId,
|
||||
variantLabel: request.variantLabel,
|
||||
variantPriceUnit: request.variantPriceUnit,
|
||||
customerAddressId: request.customerAddressId,
|
||||
addressSnapshotJson: JSON.stringify({
|
||||
title: request.addressTitle,
|
||||
city: request.cityNameFa,
|
||||
district: request.districtNameFa,
|
||||
line: request.addressLine,
|
||||
postalCode: request.postalCode,
|
||||
}),
|
||||
grossPriceIrr: amounts.grossPriceIrr,
|
||||
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
|
||||
nursePayoutAmount: amounts.nursePayoutAmount,
|
||||
// The PSP's cut is a platform expense outside the three-amount split; 2% matches the f8 seeds.
|
||||
pspFeeAmount: ((parseIrr(amounts.grossPriceIrr) * BigInt(200)) / RATE_SCALE).toString(),
|
||||
platformFeeRate: MOCK_PLATFORM_FEE_RATE,
|
||||
scheduledDate: request.requestedDate,
|
||||
scheduledTimeStart: request.requestedTimeStart,
|
||||
scheduledTimeEnd: request.requestedTimeEnd,
|
||||
});
|
||||
|
||||
const converted = mockMarkBookingRequestConverted(request.id, booking.id);
|
||||
transaction.status = 'succeeded';
|
||||
transaction.bookingId = booking.id;
|
||||
|
||||
invoices[booking.id] = {
|
||||
id: nextInvoiceId,
|
||||
bookingId: booking.id,
|
||||
invoiceNumber: `INV-${String(nextInvoiceId).padStart(10, '0')}`,
|
||||
issuingEntityType: 'platform',
|
||||
grossIrr: amounts.grossPriceIrr,
|
||||
platformCommissionIrr: amounts.commissionIrr,
|
||||
bnplCommissionIrr: null,
|
||||
vatRate: MOCK_VAT_RATE,
|
||||
vatIrr: amounts.vatIrr,
|
||||
moadianReferenceNumber: null,
|
||||
moadianStatus: 'pending',
|
||||
pdfUrl: null,
|
||||
issuedAt: new Date().toISOString(),
|
||||
};
|
||||
nextInvoiceId += 1;
|
||||
|
||||
return toOutcome(converted, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the `PaymentApi` seam — the missing conversion trigger between the f7 and f8
|
||||
* mock stores. It serves the unserved C6 summary (REQ-016), plays the PSP + webhook roles (initiate →
|
||||
* app-relative gateway-harness redirect → capture on the success return), enforces b10's idempotency
|
||||
* semantics (same-key retry reuses the attempt; a repeat initiate after capture is a `409`; a replayed
|
||||
* return is a no-op), and issues the b11-shaped invoice at capture. Swap to the real `clientApi` once
|
||||
* the upstream domains are real and REQ-016/017 land (`USE_PAYMENT_MOCK = false`).
|
||||
*/
|
||||
export const paymentMockApi: PaymentApi = {
|
||||
getCheckoutSummary: async (bookingRequestId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
const amounts = splitAmounts(requestGross(request));
|
||||
const summary: CheckoutSummaryDto = {
|
||||
bookingRequestId: request.id,
|
||||
requestStatus: request.status,
|
||||
nurseName: request.nurseName,
|
||||
patientName: request.patientName,
|
||||
variantLabel: request.variantLabel,
|
||||
variantPriceUnit: request.variantPriceUnit,
|
||||
sessionCount: SESSION_COUNT,
|
||||
requestedDate: request.requestedDate,
|
||||
requestedTimeStart: request.requestedTimeStart,
|
||||
requestedTimeEnd: request.requestedTimeEnd,
|
||||
paymentDeadlineAt: request.paymentDeadlineAt,
|
||||
serviceCostIrr: amounts.serviceCostIrr,
|
||||
commissionIrr: amounts.commissionIrr,
|
||||
vatIrr: amounts.vatIrr,
|
||||
vatRate: MOCK_VAT_RATE,
|
||||
totalIrr: amounts.grossPriceIrr,
|
||||
grossPriceIrr: amounts.grossPriceIrr,
|
||||
balinyaarCommissionIrr: amounts.balinyaarCommissionIrr,
|
||||
nursePayoutAmount: amounts.nursePayoutAmount,
|
||||
};
|
||||
return summary;
|
||||
},
|
||||
|
||||
initiatePayment: async ({ bookingRequestId, idempotencyKey }: InitiatePaymentInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
|
||||
const existing = latestTransactionFor(bookingRequestId);
|
||||
if (request.status === 'converted' || existing?.status === 'succeeded') {
|
||||
throw new ApiError(409, 'Request already paid', 'already_paid');
|
||||
}
|
||||
if (request.status !== 'accepted_awaiting_payment') {
|
||||
// Covers the lapsed 30-minute window (`payment_deadline_expired`) and every other non-payable state.
|
||||
throw new ApiError(409, 'Request is not awaiting payment', 'not_awaiting_payment');
|
||||
}
|
||||
// b10 idempotency: a retried start with the same `Idempotency-Key` reuses the attempt/reference.
|
||||
if (existing && existing.idempotencyKey === idempotencyKey && existing.status === 'pending') {
|
||||
return toInitiateResult(existing);
|
||||
}
|
||||
|
||||
const transaction: MockTransaction = {
|
||||
transactionId: nextTransactionId++,
|
||||
bookingRequestId,
|
||||
idempotencyKey,
|
||||
status: 'pending',
|
||||
gatewayReferenceCode: `mock-ref-${bookingRequestId}-${idempotencyKey.slice(0, 8)}`,
|
||||
grossPriceIrr: requestGross(request),
|
||||
bookingId: null,
|
||||
};
|
||||
transactions = [transaction, ...transactions];
|
||||
return toInitiateResult(transaction);
|
||||
},
|
||||
|
||||
confirmGatewayReturn: async ({ bookingRequestId, transactionId, outcome }: ConfirmGatewayReturnInput) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
const transaction =
|
||||
(transactionId != null ? transactions.find((t) => t.transactionId === transactionId) : undefined) ??
|
||||
latestTransactionFor(bookingRequestId);
|
||||
|
||||
// Replays / double-taps converge (the webhook dedup analogue): if capture already happened, report it.
|
||||
if (request.status === 'converted' || transaction?.status === 'succeeded') {
|
||||
return toOutcome(request, transaction);
|
||||
}
|
||||
if (!transaction) throw new ApiError(404, 'Payment transaction not found', 'not_found');
|
||||
|
||||
if (outcome === 'failure' || request.status !== 'accepted_awaiting_payment') {
|
||||
// A declined gateway, or the payment window lapsed while the customer was at the gateway.
|
||||
transaction.status = 'failed';
|
||||
return toOutcome(request, transaction);
|
||||
}
|
||||
return capture(request, transaction);
|
||||
},
|
||||
|
||||
getPaymentOutcome: async (bookingRequestId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const request = await bookingRequestsMockApi.get(bookingRequestId);
|
||||
return toOutcome(request, latestTransactionFor(bookingRequestId));
|
||||
},
|
||||
|
||||
getInvoice: async (bookingId) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
const invoice = invoices[bookingId];
|
||||
// b11: the invoice exists only once issued — a clean 404 until then (the UI shows "not issued yet").
|
||||
if (!invoice) throw new ApiError(404, 'Invoice not issued', 'not_issued');
|
||||
return { ...invoice };
|
||||
},
|
||||
};
|
||||
|
||||
function toInitiateResult(transaction: MockTransaction): InitiatePaymentResult {
|
||||
const query = new URLSearchParams();
|
||||
query.set(CHECKOUT_QUERY_TRANSACTION_ID, String(transaction.transactionId));
|
||||
query.set(CHECKOUT_QUERY_REQUEST_ID, String(transaction.bookingRequestId));
|
||||
return {
|
||||
transactionId: transaction.transactionId,
|
||||
// App-relative (the checkout prepends the locale); the real PSP returns an absolute https URL.
|
||||
redirectUrl: `${ROUTES.CHECKOUT_GATEWAY}?${query.toString()}`,
|
||||
gatewayReferenceCode: transaction.gatewayReferenceCode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* When true, the payment domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `PaymentApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** The b10 `initiate` endpoint and the b11 `invoices/{bookingId}` read are
|
||||
* live server-side, but the checkout cannot run real end-to-end from the client yet:
|
||||
* - the accepted request being paid comes from the **mock-primary** `bookingRequests` store (f7), so a
|
||||
* real initiate would reference an id that exists only in memory;
|
||||
* - the contract serves **no checkout summary** (gross/commission/VAT for C6 — REQ-016) and **no
|
||||
* client-readable transaction status** for the return poll (REQ-017);
|
||||
* - nothing fires the PSP webhook in dev (b10's mock provider returns a fake redirect URL and the
|
||||
* "webhook simulator" is a manual server-side POST), so a real payment would never confirm.
|
||||
* The mock closes the loop: capture converts the f7 request, inserts a **confirmed** booking into the f8
|
||||
* store, and issues the invoice — so C5 → C6 → gateway → confirmation → booking detail demos end-to-end.
|
||||
* Flip to `false` once the upstream domains are real and REQ-016/017 land — no hook/component change.
|
||||
*/
|
||||
export const USE_PAYMENT_MOCK = true;
|
||||
|
||||
/**
|
||||
* f11 wires the BNPL method screens (D1–D5); until then C6 renders the «یا پرداخت اقساطی» seam as a
|
||||
* clearly-deferred secondary (disabled + "coming soon"), never a dead button.
|
||||
*/
|
||||
export const BNPL_ENABLED = false;
|
||||
|
||||
/** Prices/deadlines can move between visits — keep the checkout summary short-lived. */
|
||||
export const CHECKOUT_SUMMARY_STALE_TIME = 10 * 1000;
|
||||
|
||||
/** An issued invoice is immutable — cache it for the session. */
|
||||
export const INVOICE_STALE_TIME = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The pending-callback poll ("PSP received ≠ cash in bank" — a pending state is normal, reflect it
|
||||
* calmly). Starts at ~2s, grows geometrically, caps, and gives up after a bounded number of attempts —
|
||||
* never a tight loop. Terminal statuses stop the poll regardless (see `usePaymentOutcome`).
|
||||
*/
|
||||
export const PAYMENT_OUTCOME_POLL_BASE_MS = 2 * 1000;
|
||||
export const PAYMENT_OUTCOME_POLL_GROWTH = 1.5;
|
||||
export const PAYMENT_OUTCOME_POLL_MAX_MS = 15 * 1000;
|
||||
export const PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS = 40;
|
||||
|
||||
/** Checkout deep-link query params (C5 hands off `request_id`; the gateway round-trip carries the rest). */
|
||||
export const CHECKOUT_QUERY_REQUEST_ID = 'request_id';
|
||||
export const CHECKOUT_QUERY_TRANSACTION_ID = 'transaction_id';
|
||||
export const CHECKOUT_QUERY_BOOKING_ID = 'booking_id';
|
||||
export const CHECKOUT_QUERY_OUTCOME = 'outcome';
|
||||
|
||||
/**
|
||||
* Mock-only money rates. `MOCK_PLATFORM_FEE_RATE` matches the f8 seeds' snapshotted `platformFeeRate`
|
||||
* (0.12); `MOCK_VAT_RATE` is the current 10% VAT (product: VAT applies to **Balinyaar's commission
|
||||
* only** and the rate is config-driven server-side — b11 serves `vatRate`/`vatIrr`; the client never
|
||||
* derives tax on the real path).
|
||||
*/
|
||||
export const MOCK_PLATFORM_FEE_RATE = 0.12;
|
||||
export const MOCK_VAT_RATE = 0.1;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { CHECKOUT_SUMMARY_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The C6 payload — badge status, nurse/service mini-summary, and the served, reconciling money rows
|
||||
* (service + commission + VAT = total). Short `staleTime`: the price/deadline can change between visits,
|
||||
* and after a capture the summary's `requestStatus` must flip promptly (it is also invalidated by
|
||||
* `invalidateAfterPaymentSuccess`). Enabled only when an id is present.
|
||||
*/
|
||||
export function useCheckoutSummary(bookingRequestId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.summary(bookingRequestId ?? -1),
|
||||
queryFn: () => paymentApi.getCheckoutSummary(bookingRequestId as number),
|
||||
enabled: bookingRequestId != null && bookingRequestId > 0,
|
||||
staleTime: CHECKOUT_SUMMARY_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { invalidateAfterPaymentSuccess } from '../invalidations';
|
||||
import type { ConfirmGatewayReturnInput } from '../types';
|
||||
|
||||
/**
|
||||
* Report the return from the gateway and read the outcome. On the real path the PSP already confirmed
|
||||
* via the webhook, so this is a read; in the mock it is the capture trigger. When the outcome is already
|
||||
* `succeeded`, the affected booking/request caches are invalidated here (the booking flips to confirmed
|
||||
* by cache invalidation, never a blanket refetch); a still-`pending` outcome is primed into the outcome
|
||||
* key so the pending-callback poll (`usePaymentOutcome`) starts from fresh data.
|
||||
*/
|
||||
export function useConfirmGatewayReturn() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: ConfirmGatewayReturnInput) => paymentApi.confirmGatewayReturn(input),
|
||||
onSuccess: (outcome) => {
|
||||
queryClient.setQueryData(paymentKeys.outcome(outcome.bookingRequestId), outcome);
|
||||
if (outcome.transactionStatus === 'succeeded') {
|
||||
invalidateAfterPaymentSuccess(queryClient, outcome.bookingRequestId, outcome.bookingId);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import type { InitiatePaymentInput } from '../types';
|
||||
|
||||
/**
|
||||
* Start a card payment for an accepted request. The caller owns the **per-attempt idempotency key**
|
||||
* (generated once per attempt and reused across retries of that attempt — see the C6 page), because only
|
||||
* the UI knows when a *new* attempt starts (after a failed/cancelled outcome). On success the caller
|
||||
* navigates to `redirectUrl`. A `409` here means "already paid / already in progress / window lapsed" —
|
||||
* the page converges (routes to the return surface to read the outcome) instead of toasting; other
|
||||
* domain 4xx render inline via `mutation.error` (the fetch layer owns 401/403/5xx toasts).
|
||||
*/
|
||||
export function useInitiatePayment() {
|
||||
return useMutation({
|
||||
mutationFn: (input: InitiatePaymentInput) => paymentApi.initiatePayment(input),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import { INVOICE_STALE_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The booking's commission invoice (b11). An issued invoice is immutable — long `staleTime`, no polling.
|
||||
* A 404 is a domain state, not a transient failure ("not issued yet" — b11 issues via an admin action;
|
||||
* auto-issue on capture is filed as REQ-018), so it renders as an empty state and is **not retried**.
|
||||
*/
|
||||
export function useInvoice(bookingId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.invoice(bookingId ?? -1),
|
||||
queryFn: () => paymentApi.getInvoice(bookingId as number),
|
||||
enabled: bookingId != null && bookingId > 0,
|
||||
staleTime: INVOICE_STALE_TIME,
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { paymentApi } from '../apis';
|
||||
import { paymentKeys } from '../keys';
|
||||
import {
|
||||
PAYMENT_OUTCOME_POLL_BASE_MS,
|
||||
PAYMENT_OUTCOME_POLL_GROWTH,
|
||||
PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS,
|
||||
PAYMENT_OUTCOME_POLL_MAX_MS,
|
||||
} from '../constants';
|
||||
import { isTerminalPaymentOutcome } from '../types';
|
||||
|
||||
/**
|
||||
* The pending-callback poll. "PSP received ≠ cash in bank" — a pending state after the gateway return is
|
||||
* normal, so this polls **with geometric backoff** (base → ×growth → cap) and **stops** on any terminal
|
||||
* outcome or after a bounded number of attempts — never a tight loop, never hammering the endpoint. The
|
||||
* return surface offers a manual re-check once the budget is spent. `dataUpdateCount` is the attempt
|
||||
* counter (it increments per successful fetch, which is exactly the cadence backoff should follow).
|
||||
*/
|
||||
export function usePaymentOutcome(bookingRequestId: number | undefined, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: paymentKeys.outcome(bookingRequestId ?? -1),
|
||||
queryFn: () => paymentApi.getPaymentOutcome(bookingRequestId as number),
|
||||
enabled: (options?.enabled ?? true) && bookingRequestId != null && bookingRequestId > 0,
|
||||
refetchInterval: (query) => {
|
||||
const outcome = query.state.data;
|
||||
if (outcome && isTerminalPaymentOutcome(outcome)) return false;
|
||||
const attempt = query.state.dataUpdateCount;
|
||||
if (attempt >= PAYMENT_OUTCOME_POLL_MAX_ATTEMPTS) return false;
|
||||
return Math.min(
|
||||
PAYMENT_OUTCOME_POLL_BASE_MS * PAYMENT_OUTCOME_POLL_GROWTH ** attempt,
|
||||
PAYMENT_OUTCOME_POLL_MAX_MS,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Payment domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
|
||||
* Import types/keys/apis directly from their files when needed.
|
||||
*/
|
||||
export { useCheckoutSummary } from './hooks/useCheckoutSummary';
|
||||
export { useInitiatePayment } from './hooks/useInitiatePayment';
|
||||
export { useConfirmGatewayReturn } from './hooks/useConfirmGatewayReturn';
|
||||
export { usePaymentOutcome } from './hooks/usePaymentOutcome';
|
||||
export { useInvoice } from './hooks/useInvoice';
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { bookingKeys } from '@/services/bookings/keys';
|
||||
import { bookingRequestKeys } from '@/services/bookingRequests/keys';
|
||||
import { paymentKeys } from './keys';
|
||||
|
||||
/**
|
||||
* The one cache transition a successful capture causes: the request flipped `converted` and a confirmed
|
||||
* booking now exists. Invalidate exactly the affected keys — the request detail + inboxes, the bookings
|
||||
* lists (a new row), the specific booking detail when known, and this request's checkout summary/outcome
|
||||
* — never a blanket refetch (phase rule: the booking flips to confirmed *by cache invalidation*, no
|
||||
* refetch storm). Shared by `useConfirmGatewayReturn` (immediate success) and the pending-callback poll
|
||||
* (late success).
|
||||
*/
|
||||
export function invalidateAfterPaymentSuccess(
|
||||
queryClient: QueryClient,
|
||||
bookingRequestId: number,
|
||||
bookingId: number | null,
|
||||
): void {
|
||||
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.detail(bookingRequestId) });
|
||||
queryClient.invalidateQueries({ queryKey: bookingRequestKeys.lists() });
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
|
||||
if (bookingId != null) {
|
||||
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: paymentKeys.summary(bookingRequestId) });
|
||||
queryClient.invalidateQueries({ queryKey: paymentKeys.outcome(bookingRequestId) });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* React Query key factory for the payment domain (hierarchical, per the `services/{domain}` pattern).
|
||||
* Summary and outcome are keyed by the booking-request id (payment runs against the accepted request);
|
||||
* the invoice is keyed by the booking id it belongs to.
|
||||
*/
|
||||
export const paymentKeys = {
|
||||
all: ['payment'] as const,
|
||||
summaries: () => [...paymentKeys.all, 'summary'] as const,
|
||||
summary: (bookingRequestId: number) => [...paymentKeys.summaries(), bookingRequestId] as const,
|
||||
outcomes: () => [...paymentKeys.all, 'outcome'] as const,
|
||||
outcome: (bookingRequestId: number) => [...paymentKeys.outcomes(), bookingRequestId] as const,
|
||||
invoices: () => [...paymentKeys.all, 'invoice'] as const,
|
||||
invoice: (bookingId: number) => [...paymentKeys.invoices(), bookingId] as const,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { PriceUnit } from '@/services/catalog/types';
|
||||
import { isTerminalBookingRequestStatus, type BookingRequestStatus } from '@/services/bookingRequests/types';
|
||||
|
||||
/**
|
||||
* Payment domain — the checkout/card-capture layer (b10) plus the customer invoice read (b11). Shapes
|
||||
* mirror the published contracts (`dev/contracts/domains/payments.md`, `refunds-invoices.md`; camelCase
|
||||
* wire, `clientFetch` unwraps the `ApiEnvelope<T>`).
|
||||
*
|
||||
* Load-bearing semantics (contracts + phase §5):
|
||||
* - **Money is IRR integer, on the wire as a digit-string.** Never coerce to a JS number; parse with the
|
||||
* `@/utils` BigInt helpers, display as Toman via `formatIrrToToman`. No float math on the money path.
|
||||
* - **Payment is initiated against the accepted REQUEST, not a booking** — the `bookings` row is created
|
||||
* & confirmed on capture (the PSP webhook), never on initiate.
|
||||
* - **Idempotency is per attempt.** One stable `Idempotency-Key` (header) per payment attempt, reused
|
||||
* across retries of that attempt; a NEW attempt gets a new key. A `409` on initiate means "already in
|
||||
* progress / already captured" — a benign convergence, never an error toast.
|
||||
* - **There is no client verify endpoint.** The server re-verifies inside the webhook handler; the client
|
||||
* learns the outcome by polling (`getPaymentOutcome`). On the real path that poll maps the request
|
||||
* status (`converted` → succeeded); a first-class transaction-status read is filed as REQ-017.
|
||||
* - **VAT is on Balinyaar's commission only** (the platform's taxable supply) — never the nurse payout.
|
||||
*/
|
||||
|
||||
/** `payment_transactions.status` (b10 contract enum): no other states cross the wire. */
|
||||
export type PaymentTransactionStatus = 'pending' | 'succeeded' | 'failed';
|
||||
|
||||
/** `invoices.moadian_status` (b11) — the e-invoicing registration state, surfaced read-only. */
|
||||
export type MoadianStatus = 'pending' | 'submitted' | 'registered' | 'failed';
|
||||
|
||||
/** What the dev gateway round-trip reports back (mock harness); the real PSP signals via the webhook. */
|
||||
export type GatewayReturnOutcome = 'success' | 'failure';
|
||||
|
||||
/**
|
||||
* The C6 payload. **Not yet served by the backend** (REQ-016): the b8 `BookingRequestDto` is money-free
|
||||
* and no checkout-summary endpoint exists, so the mock builds this and the real `clientApi` targets the
|
||||
* proposed route until it lands. Display rows are **served, never client-derived** — in particular
|
||||
* `vatIrr` (the client must not derive tax with a float rate) — and reconcile by construction:
|
||||
* `serviceCostIrr + commissionIrr + vatIrr = totalIrr`, with `totalIrr = grossPriceIrr` and the b10
|
||||
* three-amount invariant `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`.
|
||||
*/
|
||||
export interface CheckoutSummaryDto {
|
||||
bookingRequestId: number;
|
||||
/** C6 renders only for `accepted_awaiting_payment`; other statuses get a convergence/terminal card. */
|
||||
requestStatus: BookingRequestStatus;
|
||||
nurseName: string;
|
||||
patientName: string;
|
||||
variantLabel: string;
|
||||
variantPriceUnit: PriceUnit;
|
||||
sessionCount: number;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
requestedDate: string;
|
||||
/** `HH:mm:ss`. */
|
||||
requestedTimeStart: string;
|
||||
requestedTimeEnd: string;
|
||||
/** Server-frozen UTC end of the 30-minute payment window. */
|
||||
paymentDeadlineAt: string | null;
|
||||
/** Display row: the nursing service itself (= the nurse payout leg). IRR digit-string. */
|
||||
serviceCostIrr: string;
|
||||
/** Display row: Balinyaar's commission **net of VAT**. IRR digit-string. */
|
||||
commissionIrr: string;
|
||||
/** Display row: VAT on the commission (the served figure — never derived client-side). */
|
||||
vatIrr: string;
|
||||
/** Snapshotted VAT rate (decimal, e.g. 0.1) — informational; the rial figure is `vatIrr`. */
|
||||
vatRate: number;
|
||||
/** Display total (= `grossPriceIrr`); the rows above sum to exactly this. */
|
||||
totalIrr: string;
|
||||
/** The b10 three-amount split (gross = commission + payout, guaranteed server-side). */
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
}
|
||||
|
||||
/** `POST bookings/{bookingRequestId}/payments` input — the key travels as the `Idempotency-Key` header. */
|
||||
export interface InitiatePaymentInput {
|
||||
bookingRequestId: number;
|
||||
/** Stable per payment attempt (reused across retries of the same attempt); a new attempt gets a new one. */
|
||||
idempotencyKey: string;
|
||||
}
|
||||
|
||||
/** `InitiatePaymentResult` (b10 swagger): where to send the customer, and the PSP reference. */
|
||||
export interface InitiatePaymentResult {
|
||||
transactionId: number;
|
||||
/** Absolute PSP URL on the real path; an app-relative gateway-harness path from the mock. */
|
||||
redirectUrl: string | null;
|
||||
gatewayReferenceCode: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the return-from-gateway surface reports: the harness outcome plus which transaction came back.
|
||||
* On the real path the PSP has already hit the webhook before redirecting — the impl just re-reads the
|
||||
* outcome; in the mock this IS the capture trigger (the client stand-in for the webhook confirm).
|
||||
*/
|
||||
export interface ConfirmGatewayReturnInput {
|
||||
bookingRequestId: number;
|
||||
transactionId: number | null;
|
||||
outcome: GatewayReturnOutcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pending-callback poll target. No client transaction read exists in the b10 contract (REQ-017), so
|
||||
* the real path derives `transactionStatus` from the request status (`converted` → succeeded,
|
||||
* `payment_deadline_expired` → failed, else pending) and `bookingId` stays `null` until the backend
|
||||
* serves it; the mock knows both first-hand.
|
||||
*/
|
||||
export interface PaymentOutcomeDto {
|
||||
bookingRequestId: number;
|
||||
requestStatus: BookingRequestStatus;
|
||||
transactionStatus: PaymentTransactionStatus | null;
|
||||
/** The confirmed booking to link to (client-augmented; `null` on the real path until REQ-017 lands). */
|
||||
bookingId: number | null;
|
||||
}
|
||||
|
||||
/** succeeded/failed transaction, or a request that left the payable state — nothing left to poll. */
|
||||
export function isTerminalPaymentOutcome(outcome: PaymentOutcomeDto): boolean {
|
||||
return (
|
||||
outcome.transactionStatus === 'succeeded' ||
|
||||
outcome.transactionStatus === 'failed' ||
|
||||
isTerminalBookingRequestStatus(outcome.requestStatus)
|
||||
);
|
||||
}
|
||||
|
||||
/** `InvoiceDto` (b11 swagger, `GET invoices/{bookingId}`) — flat totals; VAT is on the commission line only. */
|
||||
export interface InvoiceDto {
|
||||
id: number;
|
||||
bookingId: number;
|
||||
/** Sequential, gap-free human-facing reference (e.g. `INV-0000000001`) — treat as opaque. */
|
||||
invoiceNumber: string;
|
||||
issuingEntityType: 'platform' | 'partner_center';
|
||||
grossIrr: string;
|
||||
/** Balinyaar's commission — the VAT-relevant line (`vatIrr = round(commission × vatRate)`). */
|
||||
platformCommissionIrr: string;
|
||||
bnplCommissionIrr: string | null;
|
||||
vatRate: number;
|
||||
vatIrr: string;
|
||||
moadianReferenceNumber: string | null;
|
||||
moadianStatus: MoadianStatus | null;
|
||||
pdfUrl: string | null;
|
||||
issuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The payment API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_PAYMENT_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface PaymentApi {
|
||||
getCheckoutSummary(bookingRequestId: number): Promise<CheckoutSummaryDto>;
|
||||
initiatePayment(input: InitiatePaymentInput): Promise<InitiatePaymentResult>;
|
||||
confirmGatewayReturn(input: ConfirmGatewayReturnInput): Promise<PaymentOutcomeDto>;
|
||||
getPaymentOutcome(bookingRequestId: number): Promise<PaymentOutcomeDto>;
|
||||
getInvoice(bookingId: number): Promise<InvoiceDto>;
|
||||
}
|
||||
Reference in New Issue
Block a user