frontend phase 10

This commit is contained in:
hamid
2026-07-10 12:51:53 +03:30
parent 40cc1d163b
commit ccfa27aff6
32 changed files with 2151 additions and 3 deletions
+8 -1
View File
@@ -127,10 +127,12 @@ client/
│ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient)
│ │ │ ├── bookings/ │ │ │ ├── bookings/
│ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail │ │ │ │ ├── page.tsx # /bookings — f8 رزروها list (useBookingList('customer')); rows → booking detail
│ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") │ │ │ │ ├── [id]/page.tsx # /bookings/[id] — f8 customer booking detail (BookingDetailView viewerRole="customer") + f10 cancel/refund entry (CustomerBookingActions: Cancel CTA while cancellable, refund section once cancelled — reuses the cached booking query)
│ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5 │ │ │ │ ├── request/page.tsx # /bookings/request — f7 C4 request form (patient/variant/address/date/time + first-class caregiver-gender + stage-1 notes); C3 hands off the nurse/variant/required_gender here → creates a request → C5
│ │ │ │ ├── request/[id]/page.tsx # /bookings/request/[id] — f7 C5 awaiting screen: summary card + 3-step tracker + polled status; response countdown → (accept) 30-min payment countdown + checkout CTA / (reject/expire/cancel) terminal cards; converted → booking deep-link (bookingId, REQ-017) │ │ │ │ ├── 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 │ │ │ │ ├── [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
│ │ │ │ ├── [id]/cancel/page.tsx # /bookings/[id]/cancel — f10 cancellation flow: policy-fee disclosure (CancellationPolicyDisclosure) + reason + acknowledge → confirm → useCancelBooking → refund status
│ │ │ │ ├── [id]/refund_status/page.tsx # /bookings/[id]/refund_status — f10 customer refund status (RefundStatusCard): pending → on-its-way → completed, BNPL ~710-day ETA, failed=contact-support; polls only while non-terminal
│ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=) │ │ │ │ └── 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) │ │ │ │ ├── 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) │ │ │ │ ├── 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)
@@ -188,6 +190,9 @@ client/
│ ├── 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) │ ├── 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) │ ├── 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) │ ├── PaymentStatusBadge/ # f9 b10 payment status (pending/succeeded/failed) → StatusChip kind + payment.pstatus_* label (tested)
│ ├── CancellationPolicyDisclosure/ # f10 pre-confirm cancel disclosure: policy-tier label (off cancellation_policy_code) + refund %/fee % + PriceBreakdown refund-vs-fee split (reconciles) + multi-session refundable/locked breakdown + admin-approval explainer + RefundEtaBanner (tested)
│ ├── RefundStatusCard/ # f10 customer refund view: 3-step stepper (submitted→on-its-way→completed) + refunded amount + optional fee-leg split + masked ref + failed=contact-support (no retry); reused on booking detail + refund-status page (tested)
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
│ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate) │ ├── booking/ # f8 post-payment engagement composites (import from @/components/booking). BookingDetailView (both-roles smart container, role-conditioned EVV+gated care), BookingStatusTimeline (server-truth 7-status timeline over StepperHeader), SessionList→SessionCard (per-session schedule/status/EVV CTA), EvvStatusBanner (advisory in/out-of-range/no-gps), CareInstructionsCard (decrypted clinical read), BookingMoneySummary (gross/commission/payout display-only); useEvvController (GPS-capture + check-in/out orchestration), format.ts + statusKind.ts helpers. Each composite tested; the BookingDetailView test proves the customer never fires the care query (two-stage-disclosure gate)
│ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested) │ ├── geography/ # F3 geo composites: CascadingRegionSelect, AddressMapPicker (map-pin stand-in), AddressForm, AddressCard (each tested)
│ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown │ └── auth/ # Auth-flow composites: LoginFlow, PhoneStep, OtpStep, RoleRouter, SelectRole, AuthCard, BrandMark, AuthSplash, useCountdown
@@ -243,6 +248,7 @@ client/
│ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary │ ├── bookingRequests/ # F7 pre-payment request lifecycle (b8). Money-free create→accept/reject/cancel + role-scoped inbox + single get. useCreateBookingRequest/useBookingRequest(polls until terminal)/useNurseRequestInbox/useCustomerRequests/useAccept/useReject/useCancel; seam+mock(PRIMARY, shared in-memory state machine — customer create ↔ nurse inbox ↔ accept flips C5; lazy expiry sweep)+client. Server-frozen UTC deadlines rendered by CountdownTimer (never recomputed); two-stage disclosure (nurse `get(id,'nurse')` masks address); variantPrice client-augmented (REQ-013). Contract-live but mock-primary because inputs (search/patients/addresses) are mock-primary
│ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list │ ├── bookings/ # F8 post-payment engagement (b9) — the SIBLING of bookingRequests, NOT a rename. useBookingDetail/useBookingSessions(select over detail — sessions are embedded)/useBookingList/useTodaySessions/useSessionEvv/useCareInstructions(enabled-gated)/useCheckInVisit/useCheckOutVisit; seam+mock(PRIMARY, seeded confirmed bookings + sessions + care + EVV state machine)+client(1:1 b9)+serverApi(RSC-prefetch seam, real-path). evv/locationProvider.ts = the ILocationProvider GPS seam (real navigator.geolocation vs mock coords by NEXT_PUBLIC_EVV_MOCK_GPS in_range|out_of_range|denied). Money display-only (gross=commission+payout server-side); timeline=server truth; care read gated to assigned nurse; EVV mismatch/denial advisory (never blocks); EVV mutations invalidate detail+session+today+list
│ ├── payment/ # F9 checkout & card capture (b10) + customer invoice read (b11). useCheckoutSummary/useInitiatePayment(caller owns the per-ATTEMPT Idempotency-Key)/useConfirmGatewayReturn/usePaymentOutcome(backoff poll, stops on terminal + bounded attempts)/useInvoice(immutable, long staleTime, 404=not-issued not error); invalidations.ts = the one post-capture cache transition (request detail/lists + bookings lists/detail + summary/outcome — never a blanket refetch); seam+mock(PRIMARY — the conversion trigger bridging the f7↔f8 mock stores: capture converts the request, inserts a confirmed booking, issues the b11-shaped invoice)+client (initiate/invoice = real b10/b11 contract; summary = REQ-016 proposed route; outcome = mapped booking_requests/get, REQ-017). Money = served IRR digit-strings; rows reconcile by construction; a 409 on the money path is benign convergence, never a toast │ ├── 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
│ ├── refunds/ # F10 customer cancellation + refund status (b11). resolveCancellationPolicy/cancelBooking/getRefundByBooking/getRefund. useCancellationPolicyPreview/useCancelBooking/useRefundStatus(polls only while non-terminal); invalidations.ts primes the fresh refund + invalidates booking detail/lists on cancel; seam+mock(PRIMARY — reads the f8 bookings store to resolve tier+per-session refundability, flips the booking cancelled, drives card-immediate/BNPL-processing refunds)+client. Contract is admin-only (REQ-019/020/021 fill the customer cancel command, policy preview, refund-by-booking + decomposition). Money = IRR digit-strings, BigInt; refund %+fee disclosed before confirm; refunds never self-issued
│ └── {domain}/ │ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical) │ ├── keys.ts # React Query key factory (hierarchical)
@@ -339,6 +345,7 @@ async function MyServerComponent() {
- `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites - `'booking'` — the f7 booking-request flow (C4 form fields/validation, C5 tracker steps + dual-countdown + terminal-state copy, the nurse inbox + detail, gender labels, per-status labels, summary-card captions) **and the f8 post-payment engagement** (booking-status timeline labels `bstatus_*`, session-status labels `sstatus_*`, the EVV banner variants `evv_banner_{in_range,out_of_range,no_gps}` + check-in/out CTAs + GPS-acquiring copy, the care-instructions section labels `care_*` + the customer "visible to your nurse only" copy, the money summary `money_*`, the dispute-window note, the bookings list `list_*`); consumed by the C4/C5 pages, the nurse requests pages, the f8 booking-detail/EVV pages, and the shared `BookingRequestSummaryCard` + `booking/` composites
- `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages - `'verification'` — the f5 nurse trust flow: B3/B4/B5/B6 copy, per-step labels + status labels (keyed off code, never derived), the DocumentUpload state chrome, TrustBadge labels, the honesty-sensitive manual-vs-auto copy, the publish-gate + shared-SIM/mismatch messages
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge` - `'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`
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*``bnpl_revert` 710-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
- `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark) - `'auth'` — the phone-OTP login flow, role router, and SelectRole screen (`common.brand`/`brand_tagline` for the wordmark)
**Namespace conventions for the phases to come** (seed each when its feature lands, in both locale **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale
+73
View File
@@ -766,5 +766,78 @@
"upload_retry": "Try again", "upload_retry": "Try again",
"upload_rejected": "This document was rejected", "upload_rejected": "This document was rejected",
"upload_reupload": "Upload again" "upload_reupload": "Upload again"
},
"refunds": {
"cancel_title": "Cancel booking",
"step_review": "Review",
"step_confirm": "Confirm",
"policy_free_24h": "Free cancellation",
"policy_partial_under_24h": "Partial refund",
"policy_customer_no_show": "No refund",
"lead_gt_24h": "More than 24 hours before the visit",
"lead_lt_24h": "Less than 24 hours before the visit",
"lead_started": "The visit has already started",
"refund_percent": "{percent}% refunded",
"fee_percent": "Cancellation fee: {percent}%",
"row_refund": "Refund to you",
"row_fee": "Cancellation fee",
"row_refundable_total": "Amount for the remaining service",
"sessions_title": "Sessions",
"session_refundable": "Refundable",
"reason_un_started": "Not started",
"reason_completed": "Completed",
"reason_in_progress": "In progress",
"reason_missed": "Missed",
"reason_cancelled": "Cancelled",
"admin_approval_explainer": "Your cancellation request is submitted and the refund is reviewed and processed by our team — you never issue the refund yourself.",
"reason_field_label": "Reason for cancelling",
"reason_cat_changed_mind": "Changed my mind",
"reason_cat_schedule_conflict": "Schedule conflict",
"reason_cat_found_other_care": "Found other care",
"reason_cat_other": "Other",
"reason_notes_label": "Notes (optional)",
"acknowledge_label": "I understand the refund and fee shown above.",
"continue_cta": "Continue",
"confirm_title": "Confirm cancellation",
"confirm_restate": "You'll be refunded {refund} and a cancellation fee of {fee} applies.",
"confirm_cta": "Confirm cancellation",
"submitting": "Submitting…",
"not_cancellable_title": "This booking can't be cancelled",
"not_cancellable_body": "There are no remaining sessions to cancel. If it was already cancelled, follow the refund status.",
"view_refund_status": "View refund status",
"back_to_booking": "Back to booking",
"error_title": "Something went wrong",
"error_body": "We couldn't load the cancellation details.",
"err_not_cancellable": "This booking can no longer be cancelled.",
"err_nothing_refundable": "There's nothing refundable to cancel.",
"err_generic": "The cancellation could not be completed. Please try again.",
"status_title": "Refund status",
"status_subtitle": "Follow your refund below.",
"no_refund_title": "No refund for this booking",
"no_refund_body": "This booking hasn't been cancelled, so there's no refund to track.",
"step_submitted": "Submitted",
"step_on_its_way": "On its way",
"step_completed": "Completed",
"rstatus_submitted": "Submitted",
"rstatus_on_its_way": "On its way",
"rstatus_completed": "Completed",
"rstatus_failed": "Needs attention",
"total_refunded_label": "Refund amount",
"reference_label": "Reference",
"fee_split_title": "Refund breakdown",
"row_platform_fee_refunded": "Balinyaar fee refunded",
"row_nurse_payout_refunded": "Service cost refunded",
"failed_title": "Your refund needs attention",
"failed_body": "Something interrupted this refund. Please contact support and our team will resolve it — you don't need to do anything else.",
"eta_card_title": "Refunded to your card",
"eta_card_body": "The amount is returned to the bank card you paid with.",
"eta_bnpl_title": "Returning through your installment provider",
"eta_bnpl_body": "Unpaid installments are cancelled and credited back to your installment wallet; any installment you already paid is returned to your bank account.",
"eta_manual_title": "Manual bank transfer",
"eta_manual_body": "The amount is transferred to your bank account by our team.",
"eta_business_days": "About 710 business days",
"eta_expected_label": "estimated by {date}",
"cancel_booking_cta": "Cancel booking",
"refund_section_title": "Refund"
} }
} }
+73
View File
@@ -766,5 +766,78 @@
"upload_retry": "تلاش مجدد", "upload_retry": "تلاش مجدد",
"upload_rejected": "این مدرک رد شد", "upload_rejected": "این مدرک رد شد",
"upload_reupload": "بارگذاری دوباره" "upload_reupload": "بارگذاری دوباره"
},
"refunds": {
"cancel_title": "لغو رزرو",
"step_review": "بازبینی",
"step_confirm": "تایید",
"policy_free_24h": "لغو رایگان",
"policy_partial_under_24h": "بازپرداخت جزئی",
"policy_customer_no_show": "بدون بازپرداخت",
"lead_gt_24h": "بیش از ۲۴ ساعت مانده به ویزیت",
"lead_lt_24h": "کمتر از ۲۴ ساعت مانده به ویزیت",
"lead_started": "ویزیت آغاز شده است",
"refund_percent": "{percent}٪ بازپرداخت می‌شود",
"fee_percent": "کارمزد لغو: {percent}٪",
"row_refund": "بازپرداخت به شما",
"row_fee": "کارمزد لغو",
"row_refundable_total": "مبلغ خدمت باقی‌مانده",
"sessions_title": "جلسات",
"session_refundable": "قابل بازپرداخت",
"reason_un_started": "شروع‌نشده",
"reason_completed": "انجام‌شده",
"reason_in_progress": "در حال انجام",
"reason_missed": "انجام‌نشده",
"reason_cancelled": "لغوشده",
"admin_approval_explainer": "درخواست لغو شما ثبت می‌شود و بازپرداخت توسط تیم بالین‌یار بررسی و انجام می‌شود؛ بازپرداخت را هرگز خودتان انجام نمی‌دهید.",
"reason_field_label": "دلیل لغو",
"reason_cat_changed_mind": "نظرم عوض شد",
"reason_cat_schedule_conflict": "تداخل زمانی",
"reason_cat_found_other_care": "مراقب دیگری پیدا کردم",
"reason_cat_other": "سایر",
"reason_notes_label": "توضیحات (اختیاری)",
"acknowledge_label": "بازپرداخت و کارمزد نمایش‌داده‌شده را می‌پذیرم.",
"continue_cta": "ادامه",
"confirm_title": "تایید لغو",
"confirm_restate": "مبلغ {refund} به شما بازپرداخت می‌شود و کارمزد لغو {fee} کسر می‌گردد.",
"confirm_cta": "تایید لغو",
"submitting": "در حال ثبت…",
"not_cancellable_title": "این رزرو قابل لغو نیست",
"not_cancellable_body": "جلسهٔ باقی‌مانده‌ای برای لغو وجود ندارد. اگر پیش‌تر لغو شده است، وضعیت بازپرداخت را دنبال کنید.",
"view_refund_status": "مشاهدهٔ وضعیت بازپرداخت",
"back_to_booking": "بازگشت به رزرو",
"error_title": "مشکلی پیش آمد",
"error_body": "بارگذاری جزئیات لغو ممکن نشد.",
"err_not_cancellable": "این رزرو دیگر قابل لغو نیست.",
"err_nothing_refundable": "چیزی برای بازپرداخت و لغو وجود ندارد.",
"err_generic": "لغو انجام نشد. لطفاً دوباره تلاش کنید.",
"status_title": "وضعیت بازپرداخت",
"status_subtitle": "روند بازپرداخت خود را در ادامه دنبال کنید.",
"no_refund_title": "بازپرداختی برای این رزرو وجود ندارد",
"no_refund_body": "این رزرو لغو نشده است، بنابراین بازپرداختی برای پیگیری وجود ندارد.",
"step_submitted": "ثبت‌شده",
"step_on_its_way": "در راه",
"step_completed": "انجام‌شده",
"rstatus_submitted": "ثبت‌شده",
"rstatus_on_its_way": "در راه",
"rstatus_completed": "انجام‌شده",
"rstatus_failed": "نیازمند بررسی",
"total_refunded_label": "مبلغ بازپرداخت",
"reference_label": "کد پیگیری",
"fee_split_title": "ریز بازپرداخت",
"row_platform_fee_refunded": "بازپرداخت کارمزد بالین‌یار",
"row_nurse_payout_refunded": "بازپرداخت هزینهٔ خدمت",
"failed_title": "بازپرداخت شما نیازمند بررسی است",
"failed_body": "بازپرداخت این رزرو با مشکلی مواجه شد. لطفاً با پشتیبانی تماس بگیرید؛ تیم ما آن را پیگیری و حل می‌کند — نیازی به اقدام دیگری نیست.",
"eta_card_title": "بازگشت به کارت شما",
"eta_card_body": "مبلغ به همان کارت بانکی که با آن پرداخت کرده‌اید بازگردانده می‌شود.",
"eta_bnpl_title": "بازگشت از طریق ارائه‌دهندهٔ اقساط",
"eta_bnpl_body": "اقساط پرداخت‌نشده لغو و معادل آن به کیف اعتباری اقساطی شما بازگردانده می‌شود؛ هر قسطی که پرداخت کرده‌اید به حساب بانکی شما برمی‌گردد.",
"eta_manual_title": "واریز بانکی دستی",
"eta_manual_body": "مبلغ توسط تیم بالین‌یار به حساب بانکی شما واریز می‌شود.",
"eta_business_days": "حدود ۷ تا ۱۰ روز کاری",
"eta_expected_label": "تا حدود {date}",
"cancel_booking_cta": "لغو رزرو",
"refund_section_title": "بازپرداخت"
} }
} }
@@ -0,0 +1,213 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Checkbox, FormControlLabel, MenuItem, Paper, Stack, TextField, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import StepperHeader from '@/components/StepperHeader';
import CancellationPolicyDisclosure from '@/components/CancellationPolicyDisclosure';
import { ApiError } from '@/lib/api/errors';
import { bookingRefundStatusPath, ROUTES } from '@/constants';
import { formatIrrToToman } from '@/utils';
import { useCancelBooking, useCancellationPolicyPreview } from '@/services/refunds';
import type { CancelReasonCategory } from '@/services/refunds/types';
const REASON_CATEGORIES: CancelReasonCategory[] = [
'changed_mind',
'schedule_conflict',
'found_other_care',
'other',
];
/** Maps the cancel mutation's `409` code to a user-facing message; anything else is the generic failure. */
function cancelErrorKey(error: unknown): string {
if (error instanceof ApiError) {
if (error.code === 'not_cancellable') return 'err_not_cancellable';
if (error.code === 'nothing_refundable' || error.code === 'session_not_refundable') {
return 'err_nothing_refundable';
}
}
return 'err_generic';
}
/**
* Cancellation flow (f10) — the trust-first exit. Step 1 **discloses** the resolved policy tier, the
* refund % + fee %, and the concrete Toman amounts (refunded vs kept) **before** anything is submitted;
* the confirm button is gated behind an explicit acknowledgement. Step 2 restates the numbers and submits
* via `useCancelBooking` (which invalidates the booking + primes the refund cache), then routes to the
* refund status. Refunds are admin-approved — the copy makes clear the request is *submitted* and
* *processed by the team*, never self-issued.
*/
export default function CancelBookingPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const tc = useTranslations('common');
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
const { data: preview, isLoading, isError } = useCancellationPolicyPreview(bookingId);
const cancel = useCancelBooking();
const [step, setStep] = useState<0 | 1>(0);
const [acknowledged, setAcknowledged] = useState(false);
const [reasonCategory, setReasonCategory] = useState<CancelReasonCategory>('changed_mind');
const [reasonNotes, setReasonNotes] = useState('');
const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`;
if (isLoading) return <AppLoading />;
if (isError || !preview || bookingId == null) {
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2">{t('error_body')}</Typography>
</Stack>
</AppAlert>
</Stack>
);
}
if (!preview.cancellable) {
return (
<Stack sx={{ gap: 2, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('not_cancellable_title')}
</Typography>
<Typography variant="body2">{t('not_cancellable_body')}</Typography>
</Stack>
</AppAlert>
<Stack direction="row" sx={{ gap: 1, flexWrap: 'wrap' }}>
<AppButton
variant="contained"
color="primary"
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('view_refund_status')}
</AppButton>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
{t('back_to_booking')}
</AppButton>
</Stack>
</Stack>
);
}
const refundToman = `${formatIrrToToman(preview.refundAmountIrr, locale)} ${tc('currency_toman')}`;
const feeToman = `${formatIrrToToman(preview.feeAmountIrr, locale)} ${tc('currency_toman')}`;
const submit = () =>
cancel.mutate(
{
bookingId,
sessionIds: preview.refundableSessionIds,
reasonCategory,
reasonNotes: reasonNotes.trim() || undefined,
},
{ onSuccess: () => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`) },
);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('cancel_title')}
</Typography>
<StepperHeader steps={[t('step_review'), t('step_confirm')]} activeStep={step} />
{step === 0 ? (
<>
<CancellationPolicyDisclosure preview={preview} />
<TextField
select
label={t('reason_field_label')}
value={reasonCategory}
onChange={(event) => setReasonCategory(event.target.value as CancelReasonCategory)}
fullWidth
>
{REASON_CATEGORIES.map((category) => (
<MenuItem key={category} value={category}>
{t(`reason_cat_${category}`)}
</MenuItem>
))}
</TextField>
<TextField
label={t('reason_notes_label')}
value={reasonNotes}
onChange={(event) => setReasonNotes(event.target.value)}
multiline
minRows={2}
fullWidth
/>
<FormControlLabel
control={<Checkbox checked={acknowledged} onChange={(event) => setAcknowledged(event.target.checked)} />}
label={t('acknowledge_label')}
/>
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton variant="text" color="inherit" onClick={() => router.push(bookingHref)} sx={{ m: 0 }}>
{t('back_to_booking')}
</AppButton>
<AppButton
variant="contained"
color="primary"
disabled={!acknowledged}
onClick={() => setStep(1)}
sx={{ m: 0 }}
>
{t('continue_cta')}
</AppButton>
</Stack>
</>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800, mb: 1 }}>
{t('confirm_title')}
</Typography>
<Typography variant="body2">{t('confirm_restate', { refund: refundToman, fee: feeToman })}</Typography>
</Paper>
{cancel.isError && (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
{t(cancelErrorKey(cancel.error))}
</AppAlert>
)}
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', flexWrap: 'wrap' }}>
<AppButton
variant="text"
color="inherit"
onClick={() => setStep(0)}
disabled={cancel.isPending}
sx={{ m: 0 }}
>
{tc('back')}
</AppButton>
<AppButton
variant="contained"
color="error"
onClick={submit}
disabled={cancel.isPending}
sx={{ m: 0 }}
>
{cancel.isPending ? t('submitting') : t('confirm_cta')}
</AppButton>
</Stack>
</>
)}
</Stack>
);
}
@@ -1,14 +1,87 @@
'use client'; 'use client';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import { BookingDetailView } from '@/components/booking'; import { BookingDetailView } from '@/components/booking';
import RefundStatusCard from '@/components/RefundStatusCard';
import AppButton from '@/components/common/AppButton';
import { bookingCancelPath, bookingRefundStatusPath } from '@/constants';
import { useBookingDetail } from '@/services/bookings';
import { useRefundStatus } from '@/services/refunds';
import { isBookingCancellable } from '@/services/refunds/types';
/** /**
* Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell: * Customer booking detail (`/bookings/{id}`) — the read-only both-roles view in the **customer** shell:
* server-truth status timeline, session schedule, and money summary. Care instructions are gated to the * server-truth status timeline, session schedule, and money summary. Care instructions are gated to the
* assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires). * assigned nurse, so the customer sees the "visible to your nurse only" affordance (the query never fires).
*
* f10 hangs the cancellation/refund entry off this screen: a **Cancel booking** CTA while the booking is
* cancellable, or the **refund status** section once it's cancelled — both page-only glue (the booking
* domain stays decoupled from refunds).
*/ */
export default function CustomerBookingDetailPage() { export default function CustomerBookingDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = Number(params.id); const id = Number(params.id);
return <BookingDetailView bookingId={Number.isInteger(id) && id > 0 ? id : -1} viewerRole="customer" />; const bookingId = Number.isInteger(id) && id > 0 ? id : -1;
return (
<Stack sx={{ gap: 3 }}>
<BookingDetailView bookingId={bookingId} viewerRole="customer" />
{bookingId > 0 && <CustomerBookingActions bookingId={bookingId} />}
</Stack>
);
}
/**
* The customer's cancel/refund entry — reads the already-cached booking detail (same query key as
* `BookingDetailView`, so no extra fetch) to decide between the Cancel CTA and the refund section. The
* refund read is enabled only once the booking is cancelled, so an active booking triggers no refund query.
*/
function CustomerBookingActions({ bookingId }: { bookingId: number }) {
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const { data: booking } = useBookingDetail(bookingId, 'customer');
const isCancelled = booking?.status === 'cancelled';
const { data: refund } = useRefundStatus(bookingId, { enabled: isCancelled });
if (!booking) return null;
if (isBookingCancellable(booking.status)) {
return (
<Stack sx={{ maxWidth: 640, mx: 'auto', width: '100%' }}>
<AppButton
variant="outlined"
color="error"
startIcon="rejected"
onClick={() => router.push(`/${locale}${bookingCancelPath(bookingId)}`)}
sx={{ m: 0 }}
>
{t('cancel_booking_cta')}
</AppButton>
</Stack>
);
}
if (isCancelled && refund) {
return (
<Stack sx={{ gap: 1.5, maxWidth: 640, mx: 'auto', width: '100%' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('refund_section_title')}
</Typography>
<RefundStatusCard refund={refund} />
<AppButton
variant="text"
color="primary"
onClick={() => router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('view_refund_status')}
</AppButton>
</Stack>
);
}
return null;
} }
@@ -0,0 +1,75 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppButton from '@/components/common/AppButton';
import AppAlert from '@/components/common/AppAlert';
import AppLoading from '@/components/common/AppLoading';
import RefundStatusCard from '@/components/RefundStatusCard';
import { ROUTES } from '@/constants';
import { useRefundStatus } from '@/services/refunds';
/**
* Customer refund status (f10) — read-only. Renders the three-step progress (pending → on-its-way →
* completed), the refunded amount, the honest per-channel ETA (BNPL's ~710-day window), and — where the
* backend serves it — the fee-leg split. `failed` shows a needs-attention / contact-support state, never a
* retry (retry is admin-only, DEFERRED to f15). Polling runs only while the refund is non-terminal (see
* `useRefundStatus`). An empty state renders when the booking has no refund (e.g. it wasn't cancelled).
*/
export default function RefundStatusPage() {
const params = useParams<{ id: string }>();
const router = useRouter();
const locale = useLocale();
const t = useTranslations('refunds');
const rawId = Number(params.id);
const bookingId = Number.isInteger(rawId) && rawId > 0 ? rawId : undefined;
const { data: refund, isLoading, isError } = useRefundStatus(bookingId);
return (
<Stack sx={{ gap: 3, maxWidth: 640, mx: 'auto', width: '100%', py: 2 }}>
<Typography variant="h5" component="h1" sx={{ fontWeight: 800 }}>
{t('status_title')}
</Typography>
{isLoading ? (
<AppLoading />
) : isError ? (
<AppAlert severity="error" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('error_title')}
</Typography>
<Typography variant="body2">{t('error_body')}</Typography>
</Stack>
</AppAlert>
) : refund ? (
<>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('status_subtitle')}
</Typography>
<RefundStatusCard refund={refund} />
</>
) : (
<AppAlert severity="info" variant="outlined" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('no_refund_title')}
</Typography>
<Typography variant="body2">{t('no_refund_body')}</Typography>
</Stack>
</AppAlert>
)}
<AppButton
variant="text"
color="inherit"
onClick={() => router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_booking')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,80 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import { formatIrrToToman } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import CancellationPolicyDisclosure, {
CancellationPolicyDisclosureProps,
} from './CancellationPolicyDisclosure';
const ComponentToTest: FunctionComponent<CancellationPolicyDisclosureProps> = (props) => (
<ThemeProvider>
<CancellationPolicyDisclosure {...props} />
</ThemeProvider>
);
// Multi-session, free tier: two un-started (refundable) + one completed (locked). refund + fee = total.
const PREVIEW: CancellationPolicyPreview = {
bookingId: 5003,
cancellable: true,
cancellationPolicyCode: 'free_24h',
refundPercentageApplied: 1,
feePercentage: 0,
refundAmountIrr: '17600000',
feeAmountIrr: '0',
refundableAmountIrr: '17600000',
platformFeeRefundedIrr: '2400000',
nursePayoutRefundedIrr: '15200000',
appliesTo: 'remaining_sessions',
leadTimeLabel: 'gt_24h',
refundChannel: 'psp_card',
expectedCustomerRefundEta: null,
refundableSessionIds: [70032, 70033],
sessions: [
{ bookingSessionId: 70031, sessionIndex: 1, scheduledDate: '2026-08-01', refundable: false, reasonCode: 'completed' },
{ bookingSessionId: 70032, sessionIndex: 2, scheduledDate: '2026-08-05', refundable: true, reasonCode: 'un_started' },
{ bookingSessionId: 70033, sessionIndex: 3, scheduledDate: '2026-08-07', refundable: true, reasonCode: 'un_started' },
],
};
describe('<CancellationPolicyDisclosure/> component', () => {
it('renders the tier label off the policy code (never the raw code) and the fee %', () => {
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-policy-code="free_24h"]')).toBeInTheDocument();
expect(screen.getByText('policy_free_24h')).toBeInTheDocument();
expect(screen.getByText('fee_percent')).toBeInTheDocument();
});
it('renders a refund-vs-fee breakdown that reconciles to the refundable total', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-row="refund"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="fee"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="total"]')).toBeInTheDocument();
// Free tier: the full refundable amount is returned, so it appears on both the refund row and the total.
expect(screen.getAllByText(new RegExp(formatIrrToToman('17600000', 'en'))).length).toBeGreaterThanOrEqual(1);
// No reconciliation warning — refund + fee equals the refundable total.
expect(errorSpy).not.toHaveBeenCalled();
errorSpy.mockRestore();
});
it('marks un-started sessions refundable and completed sessions locked', () => {
const { container } = render(<ComponentToTest preview={PREVIEW} />);
expect(container.querySelector('[data-session-id="70031"][data-refundable="false"]')).toBeInTheDocument();
expect(container.querySelector('[data-session-id="70032"][data-refundable="true"]')).toBeInTheDocument();
expect(screen.getByText('reason_completed')).toBeInTheDocument();
expect(screen.getAllByText('session_refundable').length).toBe(2);
});
it('shows the admin-approval explainer and the channel ETA banner', () => {
render(<ComponentToTest preview={PREVIEW} />);
expect(screen.getByTestId('admin-approval-explainer')).toBeInTheDocument();
expect(screen.getByTestId('refund-eta-banner')).toBeInTheDocument();
});
});
@@ -0,0 +1,113 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import PriceBreakdown from '@/components/PriceBreakdown';
import StatusChip from '@/components/StatusChip';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import RefundEtaBanner from '@/components/RefundEtaBanner';
import { formatShamsiDate } from '@/utils';
import type { CancellationPolicyPreview } from '@/services/refunds/types';
export interface CancellationPolicyDisclosureProps {
preview: CancellationPolicyPreview;
}
/** Percent (integer) from a 01 fraction — a small display number, never money, so JS math is safe. */
function toPercent(fraction: number): number {
return Math.round(fraction * 100);
}
/**
* The pre-confirm cancellation disclosure (f10) — the whole point of the cancel screen: the applicable
* policy tier (label off the `cancellation_policy_code` i18n key, never the raw code), the **refund % + fee
* %**, the concrete refund-vs-fee split (reusing `PriceBreakdown`, which reconciles to the rial), the
* multi-session refundable/locked breakdown, the admin-approved reality, and a per-channel ETA preview
* (BNPL surfaces the ~710-day window honestly). Display-only — the reason field, acknowledgement and
* confirm live in the page; shared so the confirm dialog and any future per-session cancel reuse it.
* @component CancellationPolicyDisclosure
*/
const CancellationPolicyDisclosure: FunctionComponent<CancellationPolicyDisclosureProps> = ({ preview }) => {
const t = useTranslations('refunds');
const locale = useLocale();
const refundPercent = toPercent(preview.refundPercentageApplied);
const feePercent = toPercent(preview.feePercentage);
const isMultiSession = preview.sessions.length > 1;
return (
<Stack sx={{ gap: 2.5 }} data-testid="cancellation-disclosure" data-policy-code={preview.cancellationPolicyCode}>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1 }}>
<Stack direction="row" sx={{ alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{t(`policy_${preview.cancellationPolicyCode}`)}
</Typography>
<StatusChip
status={refundPercent > 0 ? 'active' : 'rejected'}
label={t('refund_percent', { percent: refundPercent })}
/>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t(`lead_${preview.leadTimeLabel}`)}
</Typography>
<Typography variant="body2" sx={{ color: 'var(--bal-secondary)', fontWeight: 600 }}>
{t('fee_percent', { percent: feePercent })}
</Typography>
</Stack>
</Paper>
<PriceBreakdown
rows={[
{ key: 'refund', label: t('row_refund'), amountIrr: preview.refundAmountIrr },
{ key: 'fee', label: t('row_fee'), amountIrr: preview.feeAmountIrr },
]}
totalLabel={t('row_refundable_total')}
totalAmountIrr={preview.refundableAmountIrr}
/>
{isMultiSession && (
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('sessions_title')}
</Typography>
<Divider />
{preview.sessions.map((session) => (
<Stack
key={session.bookingSessionId}
data-session-id={session.bookingSessionId}
data-refundable={session.refundable}
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 2, opacity: session.refundable ? 1 : 0.6 }}
>
<Typography variant="body2">
{`${session.sessionIndex}. ${formatShamsiDate(session.scheduledDate, locale)}`}
</Typography>
<StatusChip
status={session.refundable ? 'active' : 'neutral'}
label={session.refundable ? t('session_refundable') : t(`reason_${session.reasonCode}`)}
/>
</Stack>
))}
</Stack>
</Paper>
)}
<RefundEtaBanner channel={preview.refundChannel} eta={preview.expectedCustomerRefundEta} />
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon="info" size={20} color="var(--bal-info)" />}
data-testid="admin-approval-explainer"
sx={{ marginY: 0 }}
>
{t('admin_approval_explainer')}
</AppAlert>
</Stack>
);
};
export default CancellationPolicyDisclosure;
@@ -0,0 +1,2 @@
export { default } from './CancellationPolicyDisclosure';
export type { CancellationPolicyDisclosureProps } from './CancellationPolicyDisclosure';
@@ -0,0 +1,39 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
// next-intl mocked to echo keys; locale = en so any date formats with ASCII we don't assert on.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import RefundEtaBanner, { RefundEtaBannerProps } from './RefundEtaBanner';
const ComponentToTest: FunctionComponent<RefundEtaBannerProps> = (props) => (
<ThemeProvider>
<RefundEtaBanner {...props} />
</ThemeProvider>
);
describe('<RefundEtaBanner/> component', () => {
it('renders the BNPL wording + the ~710 business-day window and the ETA', () => {
const { container } = render(<ComponentToTest channel="bnpl_revert" eta="2026-08-24" />);
expect(container.querySelector('[data-channel="bnpl_revert"]')).toBeInTheDocument();
expect(screen.getByText('eta_bnpl_title')).toBeInTheDocument();
// The honest window note only renders for the BNPL channel.
expect(screen.getByTestId('refund-eta-window')).toBeInTheDocument();
});
it('renders the card wording and no BNPL window for a card refund', () => {
render(<ComponentToTest channel="psp_card" eta={null} />);
expect(screen.getByText('eta_card_title')).toBeInTheDocument();
expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument();
});
it('renders the manual-transfer wording for a manual refund', () => {
render(<ComponentToTest channel="manual" eta={null} />);
expect(screen.getByText('eta_manual_title')).toBeInTheDocument();
expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Stack, Typography } from '@mui/material';
import AppAlert from '@/components/common/AppAlert';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate } from '@/utils';
import type { RefundChannel } from '@/services/refunds/types';
export interface RefundEtaBannerProps {
channel: RefundChannel;
/** Populated for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
eta: string | null;
}
/** Per-channel title/body i18n keys + the icon glyph. One branch drives all three refund channels. */
const CHANNEL_COPY: Record<RefundChannel, { titleKey: string; bodyKey: string; icon: string }> = {
psp_card: { titleKey: 'eta_card_title', bodyKey: 'eta_card_body', icon: 'payment' },
bnpl_revert: { titleKey: 'eta_bnpl_title', bodyKey: 'eta_bnpl_body', icon: 'schedule' },
manual: { titleKey: 'eta_manual_title', bodyKey: 'eta_manual_body', icon: 'bank' },
};
/**
* How the refund reaches the customer, told honestly per channel. For `bnpl_revert` it surfaces the
* `expected_customer_refund_eta` and the ~710 business-day window in plain language (the money returns
* *through the provider*, never instantly) — the phase's load-bearing BNPL honesty rule. `psp_card` shows
* the card-refund wording; `manual` the manual-transfer wording. Info tone (never error), tokens only.
* @component RefundEtaBanner
*/
const RefundEtaBanner: FunctionComponent<RefundEtaBannerProps> = ({ channel, eta }) => {
const t = useTranslations('refunds');
const locale = useLocale();
const copy = CHANNEL_COPY[channel];
return (
<AppAlert
severity="info"
variant="outlined"
icon={<AppIcon icon={copy.icon} size={20} color="var(--bal-primary)" />}
data-testid="refund-eta-banner"
data-channel={channel}
sx={{
marginY: 0,
borderColor: 'var(--bal-primary)',
color: 'var(--bal-primary)',
backgroundColor: 'var(--bal-primary-soft)',
}}
>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t(copy.titleKey)}
</Typography>
<Typography variant="body2">{t(copy.bodyKey)}</Typography>
{channel === 'bnpl_revert' && (
<Typography variant="caption" sx={{ fontWeight: 600 }} data-testid="refund-eta-window">
{t('eta_business_days')}
{eta ? ` · ${t('eta_expected_label', { date: formatShamsiDate(eta, locale) })}` : ''}
</Typography>
)}
</Stack>
</AppAlert>
);
};
export default RefundEtaBanner;
@@ -0,0 +1,2 @@
export { default } from './RefundEtaBanner';
export type { RefundEtaBannerProps } from './RefundEtaBanner';
@@ -0,0 +1,83 @@
import { FunctionComponent } from 'react';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { RefundSummary } from '@/services/refunds/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import RefundStatusCard, { RefundStatusCardProps } from './RefundStatusCard';
const ComponentToTest: FunctionComponent<RefundStatusCardProps> = (props) => (
<ThemeProvider>
<RefundStatusCard {...props} />
</ThemeProvider>
);
const base: RefundSummary = {
id: 7,
bookingId: 5001,
refundStatus: 'succeeded',
refundChannel: 'psp_card',
totalRefundedIrr: '45000000',
expectedCustomerRefundEta: null,
externalRevertReference: null,
refundPercentageApplied: 1,
cancellationPolicyCode: 'free_24h',
platformFeeRefundedIrr: '5400000',
nursePayoutRefundedIrr: '39600000',
createdAt: '2026-07-10T09:00:00Z',
completedAt: '2026-07-10T09:00:00Z',
};
describe('<RefundStatusCard/> component', () => {
it('renders the three-step stepper + a reconciling fee-leg split for a succeeded card refund', () => {
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const { container } = render(<ComponentToTest refund={base} />);
expect(container.querySelector('[data-status="succeeded"]')).toBeInTheDocument();
expect(screen.getByText('step_submitted')).toBeInTheDocument();
expect(screen.getByText('step_completed')).toBeInTheDocument();
// Fee-leg split renders when the decomposition is present, reconciling to the total.
expect(container.querySelector('[data-row="platform_fee_refunded"]')).toBeInTheDocument();
expect(container.querySelector('[data-row="nurse_payout_refunded"]')).toBeInTheDocument();
expect(errorSpy).not.toHaveBeenCalled();
expect(screen.queryByTestId('refund-failed')).not.toBeInTheDocument();
errorSpy.mockRestore();
});
it('shows the BNPL ETA banner while a refund is on its way', () => {
render(
<ComponentToTest
refund={{
...base,
refundStatus: 'processing',
refundChannel: 'bnpl_revert',
totalRefundedIrr: '9000000',
expectedCustomerRefundEta: '2026-08-24',
externalRevertReference: '••••••5002',
platformFeeRefundedIrr: '1080000',
nursePayoutRefundedIrr: '7920000',
completedAt: null,
}}
/>,
);
expect(screen.getByTestId('refund-status-card')).toHaveAttribute('data-status', 'processing');
expect(screen.getByText('rstatus_on_its_way')).toBeInTheDocument();
expect(screen.getByTestId('refund-eta-window')).toBeInTheDocument();
});
it('renders a contact-support failed state with no stepper, no ETA banner, and no retry control', () => {
const { container } = render(<ComponentToTest refund={{ ...base, refundStatus: 'failed', completedAt: null }} />);
expect(screen.getByTestId('refund-failed')).toBeInTheDocument();
expect(screen.getByText('failed_title')).toBeInTheDocument();
// Failed collapses the happy stepper (no fourth step) and offers no retry — retry is admin-only.
expect(screen.queryByText('step_submitted')).not.toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
// No success-framed 'money is on its way' surfaces on a failed refund (BNPL-honesty rule).
expect(screen.queryByTestId('refund-eta-banner')).not.toBeInTheDocument();
expect(screen.queryByText('total_refunded_label')).not.toBeInTheDocument();
expect(container.querySelector('[data-row="platform_fee_refunded"]')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,135 @@
'use client';
import { FunctionComponent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Divider, Paper, Stack, Typography } from '@mui/material';
import StepperHeader from '@/components/StepperHeader';
import StatusChip, { StatusKind } from '@/components/StatusChip';
import PriceBreakdown from '@/components/PriceBreakdown';
import RefundEtaBanner from '@/components/RefundEtaBanner';
import AppAlert from '@/components/common/AppAlert';
import { formatIrrToToman } from '@/utils';
import {
CUSTOMER_REFUND_STEP_ORDER,
refundCustomerStep,
refundStepIndex,
type CustomerRefundStep,
type RefundSummary,
} from '@/services/refunds/types';
export interface RefundStatusCardProps {
refund: RefundSummary;
}
/** The three happy steps → the semantic chip kind. `failed` is handled distinctly (never a fourth step). */
const STEP_KIND: Record<CustomerRefundStep, StatusKind> = {
submitted: 'pending',
on_its_way: 'info',
completed: 'verified',
};
/**
* The customer's read-only refund view (f10): the three-step progress (submitted → on its way →
* completed), the refunded amount, the per-channel ETA (BNPL's honest ~710-day window), and — when the
* backend serves the decomposition — the fee-leg split for transparency. `failed`/`rejected` render a
* distinct needs-attention state with contact-support copy, **never a retry button** (retry is admin-only,
* DEFERRED to f15). Shared: the dedicated refund-status screen and the booking-detail refund section both
* render it. Money is display-only via the money util; labels are i18n keys, never raw codes.
* @component RefundStatusCard
*/
const RefundStatusCard: FunctionComponent<RefundStatusCardProps> = ({ refund }) => {
const t = useTranslations('refunds');
const tc = useTranslations('common');
const locale = useLocale();
const step = refundCustomerStep(refund.refundStatus);
const isFailed = step === 'failed';
// Succeeded → mark all steps complete (activeStep past the last); otherwise sit on the mapped step.
const activeStep =
refund.refundStatus === 'succeeded' ? CUSTOMER_REFUND_STEP_ORDER.length : refundStepIndex(refund.refundStatus);
const hasDecomposition = refund.platformFeeRefundedIrr != null && refund.nursePayoutRefundedIrr != null;
return (
<Stack sx={{ gap: 2.5 }} data-testid="refund-status-card" data-status={refund.refundStatus}>
{isFailed ? (
// A failed/rejected refund must NOT show any success-framed progress, amount, or ETA — that would
// contradict "needs attention" and, for BNPL, falsely imply the money is on its way. Only the
// contact-support copy (+ the reference, for support) renders.
<AppAlert severity="error" variant="outlined" data-testid="refund-failed" sx={{ marginY: 0 }}>
<Stack sx={{ gap: 0.5 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('failed_title')}
</Typography>
<Typography variant="body2">{t('failed_body')}</Typography>
{refund.externalRevertReference && (
<Typography variant="caption" sx={{ fontWeight: 600, direction: 'ltr' }}>
{t('reference_label')}: {refund.externalRevertReference}
</Typography>
)}
</Stack>
</AppAlert>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5, alignItems: 'center' }}>
<StatusChip status={STEP_KIND[step]} label={t(`rstatus_${step}`)} />
<StepperHeader
steps={[t('step_submitted'), t('step_on_its_way'), t('step_completed')]}
activeStep={activeStep}
/>
</Stack>
</Paper>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.25 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'baseline', gap: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('total_refunded_label')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 800, color: 'var(--bal-secondary)' }}>
{formatIrrToToman(refund.totalRefundedIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
{refund.externalRevertReference && (
<>
<Divider />
<Stack direction="row" sx={{ justifyContent: 'space-between', gap: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('reference_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, direction: 'ltr' }}>
{refund.externalRevertReference}
</Typography>
</Stack>
</>
)}
</Stack>
</Paper>
{hasDecomposition && (
<PriceBreakdown
rows={[
{
key: 'platform_fee_refunded',
label: t('row_platform_fee_refunded'),
amountIrr: refund.platformFeeRefundedIrr as string,
},
{
key: 'nurse_payout_refunded',
label: t('row_nurse_payout_refunded'),
amountIrr: refund.nursePayoutRefundedIrr as string,
},
]}
totalLabel={t('fee_split_title')}
totalAmountIrr={refund.totalRefundedIrr}
/>
)}
<RefundEtaBanner channel={refund.refundChannel} eta={refund.expectedCustomerRefundEta} />
</>
)}
</Stack>
);
};
export default RefundStatusCard;
@@ -0,0 +1,2 @@
export { default } from './RefundStatusCard';
export type { RefundStatusCardProps } from './RefundStatusCard';
+8
View File
@@ -59,5 +59,13 @@ export const ROUTES = {
export const bookingInvoicePath = (bookingId: number | string): string => export const bookingInvoicePath = (bookingId: number | string): string =>
`${ROUTES.BOOKINGS}/${bookingId}/invoice`; `${ROUTES.BOOKINGS}/${bookingId}/invoice`;
/** The cancellation flow (f10) — policy-fee disclosure → confirm; keyed by the booking being cancelled. */
export const bookingCancelPath = (bookingId: number | string): string =>
`${ROUTES.BOOKINGS}/${bookingId}/cancel`;
/** The customer refund-status view (f10) — pending → on-its-way → completed; keyed by the booking. */
export const bookingRefundStatusPath = (bookingId: number | string): string =>
`${ROUTES.BOOKINGS}/${bookingId}/refund_status`;
/** Paths (without locale prefix) that bypass auth in middleware. */ /** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN]; export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
@@ -160,6 +160,109 @@ function seed(): void {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
sessions: [{ ...makeSession(70021, 1, 0, '15840000'), scheduledTimeStart: '15:00:00', scheduledTimeEnd: '19:00:00' }], sessions: [{ ...makeSession(70021, 1, 0, '15840000'), scheduledTimeStart: '15:00:00', scheduledTimeEnd: '19:00:00' }],
}, },
// Mid-engagement multi-session booking (f10 refund demo): session 1 is completed-and-verified (locked,
// stays payout-eligible) while sessions 2 & 3 are un-started and > 24h out — so the cancellation flow
// shows a mixed refundable/locked breakdown at the free-cancellation tier out of the box.
{
id: 5003,
bookingRequestId: 9003,
status: 'in_progress',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 903,
patientName: 'آقای کریمی',
variantId: 13,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت سالمند — شیفت روز', priceUnit: 'per_day' }),
customerAddressId: 803,
addressSnapshotJson: JSON.stringify({
title: 'منزل',
city: 'تهران',
district: 'پونک',
line: 'بلوار عدل، کوچه سوم، پلاک ۸',
postalCode: '1477889900',
}),
grossPriceIrr: '30000000',
balinyaarCommissionIrr: '3600000',
nursePayoutAmount: '26400000',
pspFeeAmount: '600000',
platformFeeRate: 0.12,
sessionCount: 3,
scheduledDate: isoDate(3),
scheduledTimeStart: '09:00:00',
scheduledTimeEnd: '13:00:00',
confirmedAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
completedAt: null,
cancelledAt: null,
cancelledBy: null,
cancellationReason: null,
cancellationPolicyCode: null,
cancellationRefundPercentage: null,
refundableAmountIrr: null,
disputeWindowEndsAt: null,
createdAt: new Date(Date.now() - 3 * 86_400_000).toISOString(),
sessions: [
{
...makeSession(70031, 1, -1, '8800000'),
status: 'completed',
evvStatus: 'completed',
checkInAt: new Date(Date.now() - 86_400_000).toISOString(),
checkOutAt: new Date(Date.now() - 72_000_000).toISOString(),
payoutEligibleAt: new Date(Date.now() + DISPUTE_WINDOW_HOURS * 3_600_000).toISOString(),
checkInAddressMatch: true,
},
makeSession(70032, 2, 3, '8800000'),
makeSession(70033, 3, 5, '8800000'),
],
},
// Already-cancelled booking whose refund FAILED (f10 refund-status demo): the customer sees the
// needs-attention / contact-support state (never a retry — retry is admin-only, DEFERRED to f15). Its
// failed refund is seeded in the refunds mock; here it just carries the cancellation snapshot b9 stamps.
{
id: 5004,
bookingRequestId: 9004,
status: 'cancelled',
nurseId: NURSE_ID,
nurseName: NURSE_NAME,
patientId: 904,
patientName: 'خانم صادقی',
variantId: 14,
variantSnapshotJson: JSON.stringify({ displayName: 'مراقبت پس از جراحی', priceUnit: 'per_session' }),
customerAddressId: 804,
addressSnapshotJson: JSON.stringify({
title: 'منزل',
city: 'تهران',
district: 'جنت‌آباد',
line: 'خیابان لاله، پلاک ۲۲، واحد ۳',
postalCode: '1476612345',
}),
grossPriceIrr: '12000000',
balinyaarCommissionIrr: '1440000',
nursePayoutAmount: '10560000',
pspFeeAmount: '240000',
platformFeeRate: 0.12,
sessionCount: 1,
scheduledDate: isoDate(-3),
scheduledTimeStart: '10:00:00',
scheduledTimeEnd: '14:00:00',
confirmedAt: new Date(Date.now() - 5 * 86_400_000).toISOString(),
completedAt: null,
cancelledAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
cancelledBy: 'customer',
cancellationReason: 'changed_mind',
cancellationPolicyCode: 'partial_under_24h',
cancellationRefundPercentage: 0.5,
refundableAmountIrr: '12000000',
disputeWindowEndsAt: null,
createdAt: new Date(Date.now() - 6 * 86_400_000).toISOString(),
sessions: [
{
...makeSession(70041, 1, -3, '10560000'),
status: 'cancelled',
scheduledTimeStart: '10:00:00',
scheduledTimeEnd: '14:00:00',
},
],
},
]; ];
care[5001] = { care[5001] = {
@@ -461,3 +564,52 @@ export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingD
bookings = [booking, ...bookings]; bookings = [booking, ...bookings];
return cloneBooking(booking); return cloneBooking(booking);
} }
/**
* Mock-only read for the refunds domain (f10): the booking + its sessions (a safe clone), so the refunds
* mock can resolve the cancellation tier by lead time and per-session refundability without the
* viewer-masking `getBookingDetail`. Throws `404` if the booking is unknown. NOT part of the `BookingsApi`
* seam — only `services/refunds`' mock imports it.
*/
export function mockGetBookingForRefund(bookingId: number): BookingDetailDto {
return cloneBooking(findBooking(bookingId));
}
/** The cancellation snapshot the refunds mock writes onto a booking when a customer cancels (f10). */
export interface CancelBookingSnapshot {
cancelledBy: string;
cancellationReason: string | null;
cancellationPolicyCode: string;
cancellationRefundPercentage: number;
refundableAmountIrr: string;
/** The un-started sessions being cancelled; completed-and-verified sessions stay payout-eligible. */
cancelledSessionIds: number[];
}
/**
* Mock-only cancellation bridge (f10): flip a booking to `cancelled` and stamp the cancellation snapshot
* the b9 `BookingDetailDto` already declares (currently only ever read, never written). Mirrors the
* in-place mutation pattern of `checkOutVisit` — it mutates the live store object (a reference into
* `bookings`), so the next `getBookingDetail`/`listBookings` reflects it once the cancel mutation
* invalidates the caches. Only still-`scheduled` sessions in `cancelledSessionIds` are marked `cancelled`
* (per-remaining-session cancellation). NOT part of the `BookingsApi` seam.
*/
export function mockMarkBookingCancelled(
bookingId: number,
snapshot: CancelBookingSnapshot,
): BookingDetailDto {
const booking = findBooking(bookingId);
booking.status = 'cancelled';
booking.cancelledAt = new Date().toISOString();
booking.cancelledBy = snapshot.cancelledBy;
booking.cancellationReason = snapshot.cancellationReason;
booking.cancellationPolicyCode = snapshot.cancellationPolicyCode;
booking.cancellationRefundPercentage = snapshot.cancellationRefundPercentage;
booking.refundableAmountIrr = snapshot.refundableAmountIrr;
for (const session of booking.sessions) {
if (snapshot.cancelledSessionIds.includes(session.id) && session.status === 'scheduled') {
session.status = 'cancelled';
}
}
return cloneBooking(booking);
}
@@ -0,0 +1,96 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
import { ApiError } from '@/lib/api/errors';
import type {
CancelBookingInput,
CancellationPolicyPreview,
RefundChannel,
RefundStatus,
RefundSummary,
RefundsApi,
} from '../types';
const BOOKINGS = '/api/v1/bookings';
const REFUNDS = '/api/v1/refunds';
/**
* The thin b11 customer refund payload (`GET refunds/{id}/status`) — the only refund shape the contract
* exposes to a customer. The fee-leg decomposition + policy snapshot live on the admin-only
* `RefundListItem`, so this maps into `RefundSummary` with those fields `null` until REQ-021 serves them.
*/
interface RefundStatusWire {
id: number;
bookingId: number;
status: RefundStatus;
refundChannel: RefundChannel;
amount: string;
expectedCustomerRefundEta: string | null;
reference: string | null;
}
function toSummary(wire: RefundStatusWire): RefundSummary {
return {
id: wire.id,
bookingId: wire.bookingId,
refundStatus: wire.status,
refundChannel: wire.refundChannel,
totalRefundedIrr: wire.amount,
expectedCustomerRefundEta: wire.expectedCustomerRefundEta,
externalRevertReference: wire.reference,
// REQ-021: the customer status carries no decomposition/policy/timestamps yet — the fee-split section
// is hidden until these are served (the mock fills them so the transparency split demos end-to-end).
refundPercentageApplied: null,
cancellationPolicyCode: null,
platformFeeRefundedIrr: null,
nursePayoutRefundedIrr: null,
createdAt: null,
completedAt: null,
};
}
/**
* Real HTTP implementation of the `RefundsApi` seam. Only `getRefund` maps a **published** b11 route
* (`GET refunds/{id}/status`, tenancy-scoped); the other three target contract gaps the frontend filed
* (which is why the domain stays mock-primary — see `constants.ts`):
* - `resolveCancellationPolicy` → REQ-020 (`GET bookings/{id}/cancellation_policy`): b9 snapshots the
* policy only *after* a cancel; there is no pre-cancel preview resolving the tier by current lead time
* + per-session refundability.
* - `cancelBooking` → REQ-019 (`POST bookings/{id}/cancel`): b11 refunds are admin-only, no customer path.
* - `getRefundByBooking` → REQ-021 (`GET refunds/by_booking/{id}`): the customer cannot obtain a refund
* id from the admin-only worklist, so it needs to reach its refund from the booking. `404` = no refund.
*
* NOT the primary implementation this phase (`USE_REFUNDS_MOCK = true`).
*/
export const refundsClientApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId: number) =>
unwrap(
await clientFetch<ApiEnvelope<CancellationPolicyPreview>>(
`${BOOKINGS}/${bookingId}/cancellation_policy`,
),
),
cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) =>
toSummary(
unwrap(
await clientFetch<ApiEnvelope<RefundStatusWire>>(`${BOOKINGS}/${bookingId}/cancel`, {
method: 'POST',
body: JSON.stringify({ sessionIds, reasonCategory, reasonNotes }),
}),
),
),
getRefundByBooking: async (bookingId: number) => {
try {
return toSummary(
unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/by_booking/${bookingId}`)),
);
} catch (error) {
// No refund for this booking (e.g. not cancelled) is a clean empty state, not a failure.
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
},
getRefund: async (refundId: number) =>
toSummary(unwrap(await clientFetch<ApiEnvelope<RefundStatusWire>>(`${REFUNDS}/${refundId}/status`))),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_REFUNDS_MOCK } from '../constants';
import type { RefundsApi } from '../types';
import { refundsClientApi } from './clientApi';
import { refundsMockApi } from './mockApi';
/**
* The selected `RefundsApi` implementation — the single seam the hooks import. Selection is by config
* (`USE_REFUNDS_MOCK`), never by scattered `if (mock)` checks.
*/
export const refundsApi: RefundsApi = USE_REFUNDS_MOCK ? refundsMockApi : refundsClientApi;
+261
View File
@@ -0,0 +1,261 @@
import { parseIrr, sleep } from '@/utils';
import { ApiError } from '@/lib/api/errors';
import { mockGetBookingForRefund, mockMarkBookingCancelled } from '@/services/bookings/apis/mockApi';
import { BNPL_REFUND_ETA_BUSINESS_DAYS, MOCK_POLICY_TIERS } from '../constants';
import {
isBookingCancellable,
isTerminalRefundStatus,
type CancelBookingInput,
type CancellationPolicyCode,
type CancellationPolicyPreview,
type CancellationSessionPreview,
type RefundChannel,
type RefundSummary,
type RefundsApi,
} from '../types';
const MOCK_LATENCY_MS = 350;
// Integer-only rate math: fractions 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 ZERO = BigInt(0);
function fractionPpm(fraction: number): bigint {
return BigInt(Math.round(fraction * Number(RATE_SCALE)));
}
/**
* Which channel a booking's refund runs through. In production this is derived from the original payment
* method; the mock pins booking 5002 to BNPL so the ~710-business-day ETA banner is demoable, and defaults
* everything else (incl. f9-converted bookings, which were paid by card) to `psp_card`.
*/
const CHANNEL_BY_BOOKING: Record<number, RefundChannel> = {
5002: 'bnpl_revert',
};
function channelFor(bookingId: number): RefundChannel {
return CHANNEL_BY_BOOKING[bookingId] ?? 'psp_card';
}
/**
* Integer day difference between a `YYYY-MM-DD` date and today, both on the **UTC calendar day** — the
* bookings mock seeds dates via `new Date().toISOString().slice(0,10)` (UTC), so the tier resolution must
* use the same basis or a positive-offset timezone (e.g. Asia/Tehran +3:30, just after local midnight)
* would compute a "today" session as -1 day and flip the tier from partial to no-show.
*/
function daysUntil(dateStr: string): number {
const todayKey = new Date().toISOString().slice(0, 10);
const target = Date.parse(`${dateStr}T00:00:00Z`);
const today = Date.parse(`${todayKey}T00:00:00Z`);
return Math.round((target - today) / 86_400_000);
}
/** Resolves the tier code from the earliest un-started session's lead time (mock stand-in for the server). */
function policyCodeForLead(days: number): CancellationPolicyCode {
if (days >= 1) return 'free_24h';
if (days === 0) return 'partial_under_24h';
return 'customer_no_show';
}
/** The BNPL customer cash-back ETA: N business days out, Fridays skipped (product: ~710 business days). */
function businessDaysFromNow(days: number): string {
const d = new Date();
let added = 0;
while (added < days) {
d.setDate(d.getDate() + 1);
if (d.getDay() !== 5) added += 1; // getDay() 5 = Friday
}
return d.toISOString().slice(0, 10);
}
/** A plausible masked (last-4) external reference for a non-card revert — opaque, never parsed. */
function maskedReference(bookingId: number): string {
return `••••••${String(bookingId % 10000).padStart(4, '0')}`;
}
/**
* Resolve the cancellation preview from the shared f8 bookings store: which sessions are refundable
* (un-started) vs locked (completed-and-verified, still payout-eligible), the tier by lead time, and the
* refund-vs-fee split decomposed across the two fee legs. All money is BigInt; `refundAmount + fee =
* refundableGross` by construction, so the disclosure's `PriceBreakdown` reconciles to the rial.
*/
function computePreview(bookingId: number): CancellationPolicyPreview {
const booking = mockGetBookingForRefund(bookingId); // throws 404 if unknown
const channel = channelFor(bookingId);
const sessions: CancellationSessionPreview[] = booking.sessions.map((s) => ({
bookingSessionId: s.id,
sessionIndex: s.sessionIndex,
scheduledDate: s.scheduledDate,
refundable: s.status === 'scheduled',
reasonCode: s.status === 'scheduled' ? 'un_started' : s.status,
}));
const refundableSessions = booking.sessions.filter((s) => s.status === 'scheduled');
const cancellable = isBookingCancellable(booking.status) && refundableSessions.length > 0;
// Proportional decomposition: the refundable slice of the payout leg is exact (Σ per-session payout);
// its commission share is proportional. refundableGross = refundablePayout + refundableCommission.
const totalPayout = parseIrr(booking.nursePayoutAmount);
const commission = parseIrr(booking.balinyaarCommissionIrr);
const refundablePayout = refundableSessions.reduce((acc, s) => acc + parseIrr(s.visitPayoutAmount), ZERO);
const refundableCommission = totalPayout > ZERO ? (commission * refundablePayout) / totalPayout : ZERO;
const refundableGross = refundablePayout + refundableCommission;
const earliestDate = refundableSessions
.map((s) => s.scheduledDate)
.sort()
.at(0);
const policyCode = policyCodeForLead(earliestDate ? daysUntil(earliestDate) : 0);
const tier = MOCK_POLICY_TIERS[policyCode];
const ppm = fractionPpm(tier.refundFraction);
const nursePayoutRefunded = (refundablePayout * ppm) / RATE_SCALE;
const platformFeeRefunded = (refundableCommission * ppm) / RATE_SCALE;
const refundAmount = nursePayoutRefunded + platformFeeRefunded;
const feeAmount = refundableGross - refundAmount; // the retained remainder — reconciles by construction
return {
bookingId,
cancellable,
cancellationPolicyCode: policyCode,
refundPercentageApplied: tier.refundFraction,
feePercentage: Math.round((1 - tier.refundFraction) * 100) / 100,
refundAmountIrr: refundAmount.toString(),
feeAmountIrr: feeAmount.toString(),
refundableAmountIrr: refundableGross.toString(),
platformFeeRefundedIrr: platformFeeRefunded.toString(),
nursePayoutRefundedIrr: nursePayoutRefunded.toString(),
appliesTo: refundableSessions.length === booking.sessions.length ? 'whole_booking' : 'remaining_sessions',
leadTimeLabel: tier.leadTimeLabel,
refundChannel: channel,
expectedCustomerRefundEta: channel === 'bnpl_revert' ? businessDaysFromNow(BNPL_REFUND_ETA_BUSINESS_DAYS) : null,
refundableSessionIds: refundableSessions.map((s) => s.id),
sessions,
};
}
/** The mock's stored refund — a `RefundSummary` plus a read counter that drives the BNPL walk. */
interface MockRefund extends RefundSummary {
reads: number;
}
let nextRefundId = 7001;
const refundsByBooking: Record<number, MockRefund> = {};
// Seed: a FAILED refund on the already-cancelled booking 5004, so the refund-status screen shows the
// needs-attention / contact-support state out of the box (retry is admin-only — DEFERRED to f15).
refundsByBooking[5004] = {
id: nextRefundId++,
bookingId: 5004,
refundStatus: 'failed',
refundChannel: 'psp_card',
totalRefundedIrr: '6000000',
expectedCustomerRefundEta: null,
externalRevertReference: maskedReference(5004),
refundPercentageApplied: 0.5,
cancellationPolicyCode: 'partial_under_24h',
platformFeeRefundedIrr: '720000',
nursePayoutRefundedIrr: '5280000',
createdAt: new Date(Date.now() - 2 * 86_400_000).toISOString(),
completedAt: null,
reads: 0,
};
function toRefundSummary(refund: MockRefund): RefundSummary {
const { reads: _reads, ...summary } = refund;
return { ...summary };
}
/**
* Accelerated mock reconciliation so a BNPL refund's stepper visibly walks *submitted → on its way →
* completed* as the status poll ticks (a real BNPL revert takes ~710 business days). A card refund is
* already `succeeded` at creation — nothing to advance. Forward-only, and stops once terminal.
*/
function advanceRefund(refund: MockRefund): void {
if (refund.refundChannel !== 'bnpl_revert' || isTerminalRefundStatus(refund.refundStatus)) return;
refund.reads += 1;
if (refund.reads >= 4) {
refund.refundStatus = 'succeeded';
refund.completedAt = new Date().toISOString();
} else {
refund.refundStatus = 'processing';
}
}
/**
* In-memory mock behind the `RefundsApi` seam — the whole customer cancel + refund surface b11 doesn't
* serve (admin-only refunds; no cancel command / policy preview / refund-by-booking / decomposition on the
* customer status → REQ-019/020/021). It reads the shared f8 bookings store to resolve the tier + per-
* session refundability, flips the booking to `cancelled` on confirm (so the booking-detail cache reflects
* it after invalidation), enforces the outside-policy `409`, and drives the refund through the customer
* steps (card immediate `succeeded`; BNPL `processing` with an ETA that reconciles over polls). Swap to the
* real `clientApi` once REQ-019/020/021 land (`USE_REFUNDS_MOCK = false`).
*/
export const refundsMockApi: RefundsApi = {
resolveCancellationPolicy: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
return computePreview(bookingId);
},
cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) => {
await sleep(MOCK_LATENCY_MS);
const preview = computePreview(bookingId);
if (!preview.cancellable) {
throw new ApiError(409, 'Booking cannot be cancelled', 'not_cancellable');
}
if (sessionIds && sessionIds.some((id) => !preview.refundableSessionIds.includes(id))) {
// Never offer to refund a session the policy marks non-refundable (completed-and-verified).
throw new ApiError(409, 'Session is not refundable', 'session_not_refundable');
}
const channel = preview.refundChannel;
const now = new Date().toISOString();
mockMarkBookingCancelled(bookingId, {
cancelledBy: 'customer',
cancellationReason: reasonNotes?.trim() || reasonCategory,
cancellationPolicyCode: preview.cancellationPolicyCode,
cancellationRefundPercentage: preview.refundPercentageApplied,
refundableAmountIrr: preview.refundableAmountIrr,
cancelledSessionIds: preview.refundableSessionIds,
});
const refund: MockRefund = {
id: nextRefundId++,
bookingId,
// Card refunds succeed immediately; BNPL/manual sit in the reconciliation window (start approved →
// processing → succeeded so the customer sees the walk).
refundStatus: channel === 'psp_card' ? 'succeeded' : 'approved',
refundChannel: channel,
totalRefundedIrr: preview.refundAmountIrr,
expectedCustomerRefundEta: preview.expectedCustomerRefundEta,
externalRevertReference: channel === 'psp_card' ? null : maskedReference(bookingId),
refundPercentageApplied: preview.refundPercentageApplied,
cancellationPolicyCode: preview.cancellationPolicyCode,
platformFeeRefundedIrr: preview.platformFeeRefundedIrr,
nursePayoutRefundedIrr: preview.nursePayoutRefundedIrr,
createdAt: now,
completedAt: channel === 'psp_card' ? now : null,
reads: 0,
};
refundsByBooking[bookingId] = refund;
return toRefundSummary(refund);
},
getRefundByBooking: async (bookingId) => {
await sleep(MOCK_LATENCY_MS);
const refund = refundsByBooking[bookingId];
if (!refund) return null; // no refund (e.g. not cancelled) — a clean empty state, not an error
advanceRefund(refund);
return toRefundSummary(refund);
},
getRefund: async (refundId) => {
await sleep(MOCK_LATENCY_MS);
const refund = Object.values(refundsByBooking).find((r) => r.id === refundId);
if (!refund) throw new ApiError(404, 'Refund not found', 'not_found');
advanceRefund(refund);
return toRefundSummary(refund);
},
};
+58
View File
@@ -0,0 +1,58 @@
import type { CancellationPolicyCode } from './types';
/**
* When true, the refunds domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `RefundsApi` seam.
*
* **Mock is primary this phase.** b11 shipped the refund lifecycle **admin-only**: the only
* customer-visible surface is `GET refunds/{id}/status` (thin: status/channel/amount/ETA/masked ref).
* There is **no** customer cancel command, **no** cancellation-policy preview, **no** refund-by-booking
* lookup, and the customer status carries **no** fee-leg decomposition — all filed as REQ-019/020/021.
* So the whole cancel + policy-disclosure + fee-split surface is mocked behind this seam. The mock reads
* the shared f8 bookings store (lead time + per-session refundability), flips the booking to `cancelled`
* on confirm (so the booking-detail cache reflects it), and drives a refund through
* `submitted → on_its_way → completed` (card immediate; BNPL processing with an ETA). Flip to `false`
* once REQ-019/020/021 land — no hook/component change.
*/
export const USE_REFUNDS_MOCK = true;
/**
* The cancellation preview depends on `now` vs the booking start (the resolved tier moves as the visit
* approaches), so keep it short-lived — never serve a stale tier that under/over-states the fee.
*/
export const POLICY_PREVIEW_STALE_TIME = 10 * 1000;
/**
* Refund status is read-heavy and mostly stable between visits; a modest stale window avoids a refetch on
* re-entry, while the poll (below) keeps a non-terminal refund fresh.
*/
export const REFUND_STATUS_STALE_TIME = 15 * 1000;
export const REFUND_STATUS_GC_TIME = 5 * 60 * 1000;
/**
* The refund-status poll runs **only while the refund is non-terminal** (`requested`/`approved`/
* `processing`); it stops at `succeeded`/`failed`/`rejected`. A calm, fixed interval — a refund moves on
* the order of days (BNPL) or is already terminal (card), so there is no need for tight backoff.
*/
export const REFUND_STATUS_POLL_INTERVAL_MS = 5 * 1000;
/**
* Mock-only policy tiers. The product doc pins the shape (free > 24h = 100%/0%; partial < 24h ≈ 50%;
* customer no-show up to 100% charge) but flags the 50% as **illustrative/config**, so these are the
* mock's stand-in figures until the backend serves `cancellation_policies`. `refundFraction` is 01.
*/
export const MOCK_POLICY_TIERS: Record<
CancellationPolicyCode,
{ refundFraction: number; leadTimeLabel: 'gt_24h' | 'lt_24h' | 'started' }
> = {
free_24h: { refundFraction: 1, leadTimeLabel: 'gt_24h' },
partial_under_24h: { refundFraction: 0.5, leadTimeLabel: 'lt_24h' },
customer_no_show: { refundFraction: 0, leadTimeLabel: 'started' },
};
/**
* The BNPL customer cash-back window the mock projects onto `expectedCustomerRefundEta` — the product's
* ~710 business-day truth, Fridays skipped (see `cancellation-and-payout.md`). Surface it honestly;
* never imply the money is back instantly.
*/
export const BNPL_REFUND_ETA_BUSINESS_DAYS = 10;
@@ -0,0 +1,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { invalidateAfterCancellation } from '../invalidations';
import type { CancelBookingInput } from '../types';
/**
* Submit a customer-initiated cancellation. On success the returned refund is primed into its `byBooking`
* key and the affected booking + refund caches are invalidated (so the booking-detail screen reflects the
* new cancelled/refund state without a manual refetch, and the refund-status screen renders warm). A `409`
* (outside-policy / already-cancelled / nothing-refundable) surfaces inline via `mutation.error` — the
* fetch layer already toasts 401/403/5xx, so this hook never double-toasts.
*/
export function useCancelBooking() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CancelBookingInput) => refundsApi.cancelBooking(input),
onSuccess: (refund, input) => invalidateAfterCancellation(queryClient, input.bookingId, refund),
});
}
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import { POLICY_PREVIEW_STALE_TIME } from '../constants';
/**
* Resolve the cancellation policy for a booking **before** the customer confirms — the tier (by lead time),
* its refund % + fee %, the concrete refund-vs-fee amounts, and the per-session refundable/locked
* breakdown. Short `staleTime` because the tier depends on `now` vs the booking start (a stale preview
* could under/over-state the fee); enabled only when a booking id is present.
*/
export function useCancellationPolicyPreview(bookingId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: refundKeys.policyPreview(bookingId ?? -1),
queryFn: () => refundsApi.resolveCancellationPolicy(bookingId as number),
enabled: (options?.enabled ?? true) && bookingId != null && bookingId > 0,
staleTime: POLICY_PREVIEW_STALE_TIME,
});
}
@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { refundsApi } from '../apis';
import { refundKeys } from '../keys';
import {
REFUND_STATUS_GC_TIME,
REFUND_STATUS_POLL_INTERVAL_MS,
REFUND_STATUS_STALE_TIME,
} from '../constants';
import { isTerminalRefundStatus } from '../types';
/**
* The customer's read-only refund status for a booking. Polls (`refetchInterval`) **only while the refund
* is non-terminal** (`requested`/`approved`/`processing`) and **stops** at `succeeded`/`failed`/`rejected`
* — and never polls the empty state (no refund → `null`). A modest `staleTime`/`gcTime` means re-entering
* the screen doesn't re-hit the network needlessly; the cancel mutation primes this key so the first render
* is warm. `data` is `null` when the booking has no refund.
*/
export function useRefundStatus(bookingId: number | undefined, options?: { enabled?: boolean }) {
return useQuery({
queryKey: refundKeys.byBooking(bookingId ?? -1),
queryFn: () => refundsApi.getRefundByBooking(bookingId as number),
enabled: (options?.enabled ?? true) && bookingId != null && bookingId > 0,
staleTime: REFUND_STATUS_STALE_TIME,
gcTime: REFUND_STATUS_GC_TIME,
refetchInterval: (query) => {
const refund = query.state.data;
if (!refund) return false;
return isTerminalRefundStatus(refund.refundStatus) ? false : REFUND_STATUS_POLL_INTERVAL_MS;
},
});
}
+7
View File
@@ -0,0 +1,7 @@
/**
* Refunds domain barrel — re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useCancellationPolicyPreview } from './hooks/useCancellationPolicyPreview';
export { useCancelBooking } from './hooks/useCancelBooking';
export { useRefundStatus } from './hooks/useRefundStatus';
@@ -0,0 +1,22 @@
import type { QueryClient } from '@tanstack/react-query';
import { bookingKeys } from '@/services/bookings/keys';
import { refundKeys } from './keys';
import type { RefundSummary } from './types';
/**
* The one cache transition a successful cancellation causes: the booking flipped `cancelled` and a refund
* now exists. Prime the fresh refund into its `byBooking` key (so the refund-status screen renders warm,
* no first-render spinner) and invalidate exactly the affected keys — the booking detail (its status/note
* changed), the bookings lists (the row moved to cancelled), the policy preview (no longer cancellable),
* and the refund's `byBooking` — never a blanket refetch. Called from `useCancelBooking`.
*/
export function invalidateAfterCancellation(
queryClient: QueryClient,
bookingId: number,
refund: RefundSummary,
): void {
queryClient.setQueryData(refundKeys.byBooking(bookingId), refund);
queryClient.invalidateQueries({ queryKey: bookingKeys.bookingDetail(bookingId) });
queryClient.invalidateQueries({ queryKey: bookingKeys.lists() });
queryClient.invalidateQueries({ queryKey: refundKeys.policyPreview(bookingId) });
}
+18
View File
@@ -0,0 +1,18 @@
/**
* React Query key factory for the refunds domain (hierarchical, per the `services/{domain}` pattern).
* The cancellation preview and the refund status are keyed by the **booking** id (the customer reaches a
* refund through its booking, never through an admin-only refund id); `detail` keys the by-refund-id read
* used by the real `refunds/{id}/status` route.
*/
export const refundKeys = {
all: ['refunds'] as const,
policyPreviews: () => [...refundKeys.all, 'policy_preview'] as const,
policyPreview: (bookingId: number) => [...refundKeys.policyPreviews(), bookingId] as const,
byBookings: () => [...refundKeys.all, 'by_booking'] as const,
byBooking: (bookingId: number) => [...refundKeys.byBookings(), bookingId] as const,
details: () => [...refundKeys.all, 'detail'] as const,
detail: (refundId: number) => [...refundKeys.details(), refundId] as const,
};
+195
View File
@@ -0,0 +1,195 @@
import type { BookingSessionStatus, BookingStatus } from '@/services/bookings/types';
/**
* Refunds & cancellation domain (b11 contract `dev/contracts/domains/refunds-invoices.md`). This is the
* **customer** half of the refund story: resolve the applicable cancellation policy by lead time, disclose
* the fee/refund split before confirming, request the cancellation, then follow the read-only refund
* status. The admin refund console (create/approve, leg-split editor, clawback banner, retry) is DEFERRED
* to f15-b15.
*
* Load-bearing semantics (contract + product `07-cancellation-and-refunds.md` / `cancellation-and-payout.md`):
* - **Refunds are admin-only.** The customer can *request* a cancellation and *read* the refund's progress;
* it can never self-issue money. The copy reflects the admin-approved, ticket-linked reality.
* - **Money is IRR integer, on the wire as a digit-string.** Parse/format only via the `@/utils` BigInt
* helpers; Toman is display-only. The refund is the decomposition of `gross = commission + payout` —
* render `platformFeeRefundedIrr` / `nursePayoutRefundedIrr` as served; never recompute the split.
* - **Disclose the fee/refund % BEFORE confirm.** The policy (resolved by lead time + actor) and its
* refund % + fee % must be on screen and acknowledged before the cancellation can be submitted.
* - **BNPL is asynchronous.** For `bnpl_revert`, surface the `expectedCustomerRefundEta` (~710 business
* days) honestly — the money returns *through the provider*, never instantly, never Balinyaar → customer.
* - **Per-session, not all-or-nothing.** Only un-started sessions are refundable; completed-and-verified
* sessions stay payout-eligible and render as locked.
* - **Never render a label off a raw enum code** — codes map to i18n keys in both locales.
*/
/**
* `refunds.status` (b11 contract enum, forward-only). A card refund goes `approved → succeeded`
* immediately; a BNPL/manual refund sits in `processing` until the async customer cash-back reconciles.
* The customer-facing UI maps these six codes onto three steps (see `refundCustomerStep`).
*/
export type RefundStatus = 'requested' | 'approved' | 'processing' | 'succeeded' | 'failed' | 'rejected';
/** `refunds.refund_channel` (b11). The data-model's `manual_bank` is served as the canonical `manual`. */
export type RefundChannel = 'psp_card' | 'bnpl_revert' | 'manual';
/** The three customer-facing refund steps the six contract statuses collapse onto. */
export const CUSTOMER_REFUND_STEP_ORDER = ['submitted', 'on_its_way', 'completed'] as const;
export type CustomerRefundStep = (typeof CUSTOMER_REFUND_STEP_ORDER)[number];
/**
* Maps the contract `RefundStatus` onto the customer's mental model: *submitted → on its way → completed*,
* with `failed`/`rejected` collapsing to a distinct error state (never a fourth happy step).
*/
export function refundCustomerStep(status: RefundStatus): CustomerRefundStep | 'failed' {
switch (status) {
case 'requested':
case 'approved':
return 'submitted';
case 'processing':
return 'on_its_way';
case 'succeeded':
return 'completed';
case 'failed':
case 'rejected':
return 'failed';
}
}
/** Zero-based `activeStep` for the three-step refund stepper (only meaningful for the non-`failed` steps). */
export function refundStepIndex(status: RefundStatus): number {
const step = refundCustomerStep(status);
if (step === 'failed') return CUSTOMER_REFUND_STEP_ORDER.length - 1;
return CUSTOMER_REFUND_STEP_ORDER.indexOf(step);
}
/** `succeeded` (completed) or `failed`/`rejected` (dead) — nothing left to poll. */
export function isTerminalRefundStatus(status: RefundStatus): boolean {
return status === 'succeeded' || status === 'failed' || status === 'rejected';
}
/**
* Proposed cancellation-policy tier codes (REQ-020). The product docs describe the tiers (free > 24h,
* partial < 24h, customer no-show) but pin **no** wire code-names, so these are the client-mock codes the
* UI maps to i18n keys; when the backend defines the real `cancellation_policy_code` set the map updates.
* **Never** render a label off the raw code.
*/
export type CancellationPolicyCode = 'free_24h' | 'partial_under_24h' | 'customer_no_show';
/** The lead-time bucket that resolved the tier — drives an explanatory i18n label, not the money. */
export type CancellationLeadTime = 'gt_24h' | 'lt_24h' | 'started';
/** Whether the whole booking or only the remaining (un-started) sessions are being cancelled. */
export type CancellationScope = 'whole_booking' | 'remaining_sessions';
/** Why a session is refundable or locked — maps to an i18n reason chip. `un_started` ⇔ refundable. */
export type CancellationSessionReason = 'un_started' | BookingSessionStatus;
/** One session's refundability in the cancellation preview (refundable ⇔ un-started; locked otherwise). */
export interface CancellationSessionPreview {
bookingSessionId: number;
sessionIndex: number;
/** ISO date `YYYY-MM-DD`. */
scheduledDate: string;
refundable: boolean;
/** `un_started` when refundable; otherwise the blocking session status (`completed`/`in_progress`/…). */
reasonCode: CancellationSessionReason;
}
/**
* The resolved cancellation preview shown BEFORE confirm (REQ-020 — not served by b11 yet, mock-primary).
* `refundAmountIrr + feeAmountIrr = refundableAmountIrr` by construction, so the disclosure's
* `PriceBreakdown` (refund-vs-fee split) reconciles to the rial. The fee-leg decomposition
* (`platformFeeRefundedIrr` / `nursePayoutRefundedIrr`) is served, never recomputed client-side.
*/
export interface CancellationPolicyPreview {
bookingId: number;
/** `false` when nothing is refundable (already cancelled/completed, or no un-started sessions). */
cancellable: boolean;
cancellationPolicyCode: CancellationPolicyCode;
/** 01 fraction of the refundable amount returned to the customer. */
refundPercentageApplied: number;
/** 01 fraction retained as the cancellation fee/penalty (`= 1 - refundPercentageApplied`). */
feePercentage: number;
/** IRR digit-string — the amount refunded to the customer. */
refundAmountIrr: string;
/** IRR digit-string — the amount retained as the fee. */
feeAmountIrr: string;
/** IRR digit-string — the base being decided (`refundAmountIrr + feeAmountIrr`; the un-started gross). */
refundableAmountIrr: string;
/** Decomposition of `refundAmountIrr` across the two fee legs (served, never recomputed). */
platformFeeRefundedIrr: string;
nursePayoutRefundedIrr: string;
appliesTo: CancellationScope;
leadTimeLabel: CancellationLeadTime;
/** The channel the refund will run through — drives the ETA preview wording. */
refundChannel: RefundChannel;
/** Populated only for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
expectedCustomerRefundEta: string | null;
/** The refundable session ids the confirm submits (all un-started sessions). */
refundableSessionIds: number[];
sessions: CancellationSessionPreview[];
}
/** The customer's stated reason category for cancelling — maps to an i18n label, never rendered raw. */
export type CancelReasonCategory =
| 'changed_mind'
| 'schedule_conflict'
| 'found_other_care'
| 'other';
/** `POST bookings/{bookingId}/cancel` input (REQ-019 — customer-initiated; not served by b11 yet). */
export interface CancelBookingInput {
bookingId: number;
/** The un-started sessions to cancel (whole-remaining by default); omitted = all refundable. */
sessionIds?: number[];
reasonCategory: CancelReasonCategory;
reasonNotes?: string;
}
/**
* The customer-facing refund view. A superset of the thin b11 `GET refunds/{id}/status` payload
* (`{ id, bookingId, status, refundChannel, amount, expectedCustomerRefundEta, reference }`) — the mock
* fills the whole shape; the real `clientApi` maps the thin contract and leaves the decomposition fields
* `null` until REQ-021 exposes them to the customer. The UI renders the fee-leg split only when present.
*/
export interface RefundSummary {
id: number;
bookingId: number;
refundStatus: RefundStatus;
refundChannel: RefundChannel;
/** IRR digit-string — the total refunded to the customer (the contract's `amount`). */
totalRefundedIrr: string;
/** Populated for `bnpl_revert` (the ~710 business-day window); a date `YYYY-MM-DD`. */
expectedCustomerRefundEta: string | null;
/** Opaque, **masked** (last 4 only) external reference — never parse it. */
externalRevertReference: string | null;
/** --- Fee-leg decomposition + policy snapshot (REQ-021: `null` on the real path until served). --- */
refundPercentageApplied: number | null;
cancellationPolicyCode: string | null;
platformFeeRefundedIrr: string | null;
nursePayoutRefundedIrr: string | null;
createdAt: string | null;
completedAt: string | null;
}
/**
* Whether a booking can still be cancelled by the customer (has an un-started remainder). `confirmed` or
* `in_progress` are candidates; the resolved preview reports `cancellable: false` if no un-started session
* actually remains. Terminal/settled states (`completed`/`closed`/`disputed`/`cancelled`/`pending_payment`)
* are never customer-cancellable here.
*/
export function isBookingCancellable(status: BookingStatus): boolean {
return status === 'confirmed' || status === 'in_progress';
}
/**
* The refunds API seam — the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_REFUNDS_MOCK`), never scattered `if (mock)` checks.
*/
export interface RefundsApi {
resolveCancellationPolicy(bookingId: number): Promise<CancellationPolicyPreview>;
cancelBooking(input: CancelBookingInput): Promise<RefundSummary>;
/** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */
getRefundByBooking(bookingId: number): Promise<RefundSummary | null>;
getRefund(refundId: number): Promise<RefundSummary>;
}
@@ -12,6 +12,45 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no) - **Requests filed:** frontend/requests/for-backend.md (yes/no)
--> -->
## frontend-phase-10-b11 — Cancellation & refund status (customer) — 2026-07-10
- **Shipped:** the trust-first **exit** — a **new `services/refunds` domain** (types/constants/keys/
invalidations/apis[client+mock]/3 hooks + barrel) and two customer screens: the **cancellation flow**
`/bookings/[id]/cancel` (discloses the resolved policy tier + **refund % + fee %** + the Toman
refund-vs-fee split + the multi-session refundable/locked breakdown **before** confirm, gated behind an
explicit acknowledgement → `useCancelBooking` → refund status) and the read-only **refund status**
`/bookings/[id]/refund_status` (3-step **submitted → on-its-way → completed** stepper, the honest BNPL
**~710-business-day** ETA, `failed`=contact-support with **no retry**, empty state). The customer
booking detail now hangs a **Cancel** CTA / **refund section** (`CustomerBookingActions`, reuses the
cached booking query). Three shared tested composites: `CancellationPolicyDisclosure`, `RefundStatusCard`,
`RefundEtaBanner`. New `refunds` i18n namespace (71 keys, both locales).
- **Load-bearing rules honored:** refunds are **admin-approved — the customer never self-issues** (no
issue/approve/retry control anywhere; failed = contact-support); the fee/refund % is **disclosed +
acknowledged before confirm**; money = IRR digit-strings, **BigInt only** (integer parts-per-10000 —
refund+fee and the fee-leg split reconcile to the rial, `PriceBreakdown` dev-guarded); **BNPL surfaced
honestly** (the ~710-day window + `expected_customer_refund_eta`, money flows *through the provider*,
never instant, never Balinyaar→customer) — and **suppressed on a failed refund** (no success-framed
"money is on its way"); **per-session** — only un-started sessions refundable, completed-and-verified stay
locked; labels are i18n keys off the codes, **never raw enum codes**; polling **only while non-terminal**;
cancel invalidates booking detail/lists + **primes** the refund cache.
- **Consumes:** dev/contracts/domains/refunds-invoices.md (b11 — `refunds/{id}/status` shape + enums;
`refund_status`=requested|approved|processing|succeeded|failed|rejected, `refund_channel`=psp_card|
bnpl_revert|manual, `expected_customer_refund_eta` is a date). **Not served by the contract (admin-only):**
the customer cancel command, the pre-cancel policy preview + per-session flags, refund-by-booking, and the
fee-leg decomposition on the customer status → REQ-019/020/021.
- **Mocked client-side:** `services/refunds` via `refundsMockApi` (**USE_REFUNDS_MOCK=true, primary**) — it
reads the shared f8 bookings store to resolve the tier by lead time + per-session refundability, flips the
booking to `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot), and drives card-immediate
(`succeeded`) / BNPL-`processing``succeeded` refunds with a real ETA + a seeded `failed` refund (5004) and
the outside-policy `409`. Added bookings-store seeds 5003 (mid-engagement mixed) + 5004 (cancelled) + two
non-seam exports. Real `refundsClientApi` maps `refunds/{id}/status` 1:1 and targets the proposed slugs for
the gaps; one flag flip when REQ-019/020/021 land.
- **Reviewed:** 4-dimension adversarial review + per-finding verify — 3 confirmed findings, all fixed
(failed-state ETA/amount suppression = BNPL-honesty; two missing `no_refund_*` i18n keys; a UTC/local
`daysUntil` mismatch that flipped the tier in +offset timezones).
- **Gate:** npm run check green · npm run test:ci green (**214 tests, +10**).
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-019 cancel command, REQ-020 policy
preview + per-session flags + canonical policy codes, REQ-021 refund-by-booking + customer decomposition).
## frontend-phase-9-b10 — Checkout, card payment & invoice — 2026-07-10 ## frontend-phase-9-b10 — Checkout, card payment & invoice — 2026-07-10
- **Shipped:** the money moment — a **new `services/payment` domain** (types/keys/constants/ - **Shipped:** the money moment — a **new `services/payment` domain** (types/keys/constants/
apis[client+mock]/invalidations/5 hooks + barrel) and the customer checkout flow: **C6 خلاصه و پرداخت** apis[client+mock]/invalidations/5 hooks + barrel) and the customer checkout flow: **C6 خلاصه و پرداخت**
@@ -263,3 +263,63 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
capture to demo the full flow), but on the real rails every fresh payment would land on that empty capture to demo the full flow), but on the real rails every fresh payment would land on that empty
state. state.
- **Status:** open - **Status:** open
## REQ-019 — Customer-initiated booking cancellation command — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A **customer-facing** command to cancel a booking (post-payment) and open its refund, e.g.
`POST api/v1/bookings/{bookingId}/cancel` (owner-scoped) with body
`{ sessionIds?: long[], reasonCategory: string, reasonNotes?: string }`
the created refund summary (`{ id, bookingId, status, refundChannel, amount, expectedCustomerRefundEta,
reference }`, i.e. the `refunds/{id}/status` shape). `sessionIds` omitted = cancel all un-started
(remaining) sessions; completed-and-verified sessions stay payout-eligible.
- **Why:** b11 shipped refunds **admin-only** (`POST admin_refunds`) — there is **no** customer path to
request a cancellation, but f10's whole cancel flow is customer-initiated (the customer *requests*; an
admin still *approves/processes* the money). The client mocks this behind the `services/refunds` seam:
the mock flips the booking to `cancelled` (stamping the b9 cancellation snapshot), decomposes the refund
across the two fee legs, and returns a card-immediate (`succeeded`) or BNPL-`processing` refund. The real
`refundsClientApi.cancelBooking` already targets this slug.
- **Proposed shape:** `POST api/v1/bookings/{bookingId}/cancel` body as above → `RefundStatusDto`. The
server resolves the snapshotted policy, enforces the outside-policy/state rules (`409`), posts the
balanced reversal, and (per the admin-only rule) may route the refund through an admin/ticket step — the
customer surface just needs to *create* the cancellation request and read the resulting refund.
- **Status:** open
## REQ-020 — Cancellation-policy preview (pre-cancel, per-session) — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** A read that **resolves the applicable cancellation policy by current lead time** *before* the
customer confirms, incl. the per-session refundability breakdown. Proposed
`GET api/v1/bookings/{bookingId}/cancellation_policy` (owner-scoped) →
`{ bookingId, cancellable, cancellationPolicyCode, refundPercentageApplied, feePercentage,
refundAmountIrr, feeAmountIrr, refundableAmountIrr, platformFeeRefundedIrr, nursePayoutRefundedIrr,
appliesTo, leadTimeLabel, refundChannel, expectedCustomerRefundEta,
sessions: [{ bookingSessionId, sessionIndex, scheduledDate, refundable, reasonCode }] }` (IRR fields are
digit-strings; `refundAmountIrr + feeAmountIrr = refundableAmountIrr`; the fee-leg split is served,
never client-derived).
- **Why:** The phase's load-bearing rule is **disclose the fee/refund % before confirm** — an
outside-policy fee is never a surprise. b9 snapshots `cancellationPolicyCode`/`cancellationRefundPercentage`/
`refundableAmountIrr` on the booking only *after* a cancel; there is no pre-cancel preview that resolves
the tier by current lead time and enumerates which sessions are refundable (un-started) vs locked
(completed-and-verified). The client mocks the whole preview behind the `services/refunds` seam; the tier
**codes** (`free_24h` / `partial_under_24h` / `customer_no_show`) are client-invented placeholders (the
product doc pins no wire codes) mapped to i18n keys — please define the canonical `cancellation_policy_code`
set so the client maps the real codes.
- **Proposed shape:** as above. The `cancellationPolicyCode` set + the per-session `reasonCode` set
(`un_started` / the blocking session status) should be documented as stable enum codes → i18n keys.
- **Status:** open
## REQ-021 — Customer refund lookup-by-booking + fee-leg decomposition on the customer status — filed by frontend-phase-10-b11 — 2026-07-10
- **Need:** Two additions to the customer refund surface:
1. **Reach a refund from its booking.** `GET api/v1/refunds/by_booking/{bookingId}` (owner-scoped) →
the `refunds/{id}/status` shape, or `404` when the booking has no refund. Today the only customer read
is `GET refunds/{id}/status` keyed by a **refund id** the customer can't obtain (the refund id lives on
the admin-only `GET admin_refunds` worklist).
2. **Expose the decomposition to the customer.** Add `platformFeeRefundedIrr`, `nursePayoutRefundedIrr`,
`refundPercentageApplied`, `cancellationPolicyCode`, `createdAt`, `completedAt` to the customer
`refunds/{id}/status` payload (they exist on the admin-only `RefundListItem`).
- **Why:** f10's refund-status screen deep-links from a booking and renders (where the design calls for it)
a **fee-leg transparency split** (Balinyaar-fee-refunded vs service-cost-refunded). Without (1) the
customer can't find their refund; without (2) the split can't render on the real path (the client mock
fills all six fields; the real `refundsClientApi` leaves them `null` and the split is hidden).
- **Also (minor):** please confirm `provider_commission_reversed_amount` (the BNPL provider's own
commission on a revert) is **nullable** on the refund shape and reconciled from the provider response —
the b12 `IBnplProvider` mock echoes it as nullable and the client treats any provider-commission figure
as opaque/never customer-facing.
- **Status:** open
@@ -0,0 +1,117 @@
# Frontend Phase 10 — Cancellation & refund status (customer) — report
**Consumes:** [`dev/contracts/domains/refunds-invoices.md`](../../contracts/domains/refunds-invoices.md) (b11).
**Depends on:** f8 (`services/bookings`, booking detail), f9 (`services/payment`, checkout/invoice, the
`PriceBreakdown`/`EscrowNotice` composites, the money util). **Gate:** `npm run check` green ·
`npm run test:ci` green (**214 tests, +10**). **Mock-primary** behind `USE_REFUNDS_MOCK` (default `true`).
---
## What was built
A vertical slice — the **customer** half of the refund story: *what cancelling costs* (disclosed before
confirm) and *where the money is* (a read-only refund status that tells the truth about the BNPL window).
### `services/refunds` domain (new)
`types.ts` / `constants.ts` / `keys.ts` / `invalidations.ts` / `apis/{clientApi,mockApi,index}.ts` /
`hooks/{useCancellationPolicyPreview,useCancelBooking,useRefundStatus}.ts` / `index.ts`, mirroring the
`services/payment` + `services/bookings` shape exactly.
- **Contract-derived enums:** `RefundStatus` = `requested|approved|processing|succeeded|failed|rejected`
(the b11 set, forward-only), `RefundChannel` = `psp_card|bnpl_revert|manual`. The six statuses collapse
onto three customer steps via `refundCustomerStep` (*submitted → on its way → completed*, `failed`/
`rejected` → a distinct error state). `isTerminalRefundStatus` gates the poll.
- **Money:** the refund is the decomposition of `gross = commission + payout`. All IRR math is **BigInt**
(integer parts-per-10000), never a float; `refundAmount + fee = refundableGross` and
`platformFeeRefunded + nursePayoutRefunded = totalRefunded` reconcile to the rial (so `PriceBreakdown`'s
dev-guard never trips).
- **Caching:** `useRefundStatus` polls (`refetchInterval`) **only while non-terminal** and stops at
`succeeded`/`failed`/`rejected` (and never polls the `null` no-refund empty state); the cancel mutation
`setQueryData`-primes the fresh refund into `refundKeys.byBooking` and invalidates the booking detail +
lists + the policy preview (`invalidations.ts`) — no refetch storm.
### Screens
- **Cancellation flow** `/bookings/[id]/cancel` — step 1 **discloses** the resolved tier (label off the
`cancellation_policy_code` i18n key, never the raw code), the **refund % + fee %**, the concrete Toman
split (refunded vs kept, via the money util), and the multi-session refundable/locked breakdown; the
confirm button is gated behind an explicit acknowledgement. Step 2 restates the amounts and submits via
`useCancelBooking` → routes to the refund status. Copy makes the **admin-approved, never-self-issued**
reality explicit.
- **Refund status** `/bookings/[id]/refund_status` — the three-step stepper, the refunded amount, the
per-channel ETA (BNPL's honest ~710-business-day window from `expected_customer_refund_eta`), the
optional fee-leg split, and a `failed`/`rejected` **contact-support** state (**no retry** — retry is
admin-only, DEFERRED to f15). An empty state renders when the booking has no refund.
- **Booking detail** `/bookings/[id]` now composes `CustomerBookingActions` (page-only glue): the **Cancel
booking** CTA while cancellable, or the **refund section** once cancelled — reusing the cached booking
query key (no extra fetch); the refund read is enabled only after the booking is cancelled.
### Shared composites (each with a co-located `*.test.tsx`)
- **`CancellationPolicyDisclosure`** — the pre-confirm disclosure block (policy tier + %/fee + reconciling
`PriceBreakdown` refund-vs-fee split + per-session refundable/locked + admin-approval explainer + ETA).
- **`RefundStatusCard`** — the 3-step stepper + amount + masked reference + optional fee-leg split +
failed/contact-support state. Reused on the refund-status page and the booking-detail refund section.
- **`RefundEtaBanner`** — one branch on `refund_channel`: `bnpl_revert` surfaces the ~710-business-day
window honestly (never instant), `psp_card`/`manual` their wording.
### i18n
A new **`refunds`** namespace (69 keys) added to **both** `messages/en.json` and `messages/fa.json` in
sync (fa brand `بالین‌یار`, RTL-first). Policy-tier, refund-status, per-channel-ETA, per-session-reason,
and failed/contact-support strings are all i18n keys off the enum codes — never hardcoded off a raw code.
---
## What is now testable and exactly how
Run `npm run dev` (the `services/refunds` mock is primary). Seeded bookings live in the f8 store:
| Booking | State | Demonstrates |
| --- | --- | --- |
| **5001** | confirmed, 3 sessions (today) | multi-session cancel at the **partial** tier (<24h) |
| **5002** | confirmed, single (today) | **BNPL** cancel — `processing` refund + the ~710-day ETA banner, walks to completed over polls |
| **5003** | in_progress, 3 sessions (1 completed, 2 un-started >24h) | the **mixed refundable/locked** breakdown at the **free** tier |
| **5004** | already cancelled | the **failed** refund → contact-support state (no retry) |
1. **Disclosure before confirm.** Open booking 5003 → *Cancel booking* → the resolved tier label, refund %
+ fee %, and the Toman refund-vs-fee split render **before** confirm is enabled (acknowledgement gate).
2. **Multi-session breakdown.** 5003 shows session 1 **locked** (completed) with a reason chip and sessions
23 **refundable**. 5001 shows all three refundable (partial tier).
3. **Refund progression.** Cancel 5002 (BNPL) → refund status walks *submitted → on its way → completed*;
polling stops at completed. Cancel 5001/5003 (card) → **completed** immediately.
4. **BNPL ETA.** 5002's refund shows the ~710-business-day window + a Shamsi ETA + provider-routed wording.
5. **No self-refund.** There is **no** issue/approve/retry control anywhere; 5004 shows contact-support copy.
6. **Locale + RTL.** Toggle `fa`/`en` → every string flips and is present in both files.
7. **Caching (Devtools).** The cancel mutation invalidates `bookingKeys.bookingDetail`/`lists` and primes
`refundKeys.byBooking`; the refund poll is active only while non-terminal; re-entry doesn't refetch.
---
## What is mocked (and how to make it real)
The whole customer cancel + refund surface is mocked behind `RefundsApi` (`USE_REFUNDS_MOCK = true`) because
b11 shipped refunds **admin-only** — there is **no** customer cancel command, policy preview,
refund-by-booking lookup, or fee-leg decomposition on the customer status. The mock reads the shared f8
bookings store (tier by lead time + per-session refundability), flips the booking to `cancelled`, and drives
card-immediate / BNPL-`processing` refunds. See the [mock registry](./mocks-registry.md) `RefundsApi` row.
The real `refundsClientApi` maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for
the gaps; when REQ-019/020/021 land, flip the flag — no hook/component change.
**Contract gaps filed** ([`for-backend.md`](../frontend/requests/for-backend.md)):
- **REQ-019** — customer-initiated cancellation command (`POST bookings/{id}/cancel`).
- **REQ-020** — pre-cancel cancellation-policy preview + per-session refundability + the canonical
`cancellation_policy_code` set (the tier codes `free_24h`/`partial_under_24h`/`customer_no_show` are
client placeholders mapped to i18n keys).
- **REQ-021**`GET refunds/by_booking/{id}` + the fee-leg decomposition / policy / timestamps on the
customer `refunds/{id}/status` (today admin-only); confirm `provider_commission_reversed_amount` nullable.
## Prior-phase files touched (in place, noted per operating-rules §0.3)
- `services/bookings/apis/mockApi.ts` — added two **non-seam** exports (`mockGetBookingForRefund`,
`mockMarkBookingCancelled`) alongside `mockInsertConvertedBooking`, and two additive seeds (5003
mid-engagement, 5004 cancelled). Dated so they don't disturb the f8 *today* check-in demo; f8 tests build
their own fixtures and don't read the store, so unaffected.
- `constants/routes.ts``bookingCancelPath` / `bookingRefundStatusPath` helpers (id-keyed, like
`bookingInvoicePath`).
- `app/.../bookings/[id]/page.tsx` — composes `CustomerBookingActions`.
## Follow-ups for f15-b15 (admin)
The admin refund console (create/approve, leg-split editor, ticket linkage, the clawback "nurse already
paid" banner, **refund retry**), self-service *partial* refund UI, and holiday-specific policy overrides
remain DEFERRED. This phase is strictly the customer read + cancel-request surface.
@@ -73,3 +73,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 | | `ILocationProvider` | `client/src/services/bookings/evv/locationProvider.ts` | **EVV GPS capture** — the only client seam f8 introduces. `getCurrentPosition()` never rejects (denied/unavailable → `null`, so a GPS problem is **advisory, never a block**). The **real** provider wraps `navigator.geolocation.getCurrentPosition`; the **mock** returns canned coordinates per mode so the in-range / advisory-out-of-range / denied paths are all demoable without a device (the mock `BookingsApi` computes the match against the same seeded reference point) | `NEXT_PUBLIC_EVV_MOCK_GPS` = `in_range` \| `out_of_range` \| `denied` \| `off` (default `in_range` while `USE_BOOKINGS_MOCK`, else `off`) | Set `NEXT_PUBLIC_EVV_MOCK_GPS=off` (or flip `USE_BOOKINGS_MOCK`) → the real `navigator.geolocation` provider is selected. Real **address-match math** stays server-side (backend geocoding seam), not here — this seam only *captures* the position | 🟡 |
| `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 | | `PaymentApi` | `client/src/services/payment/apis/mockApi.ts` | **The f9 checkout money path** — plays the PSP + webhook roles the client can't reach: `getCheckoutSummary` serves the unserved C6 breakdown (REQ-016; commission-net/VAT/service split via **integer parts-per-10000 BigInt math**, 12% fee / 10% VAT, reconciles to the rial); `initiatePayment` enforces b10 idempotency (same `Idempotency-Key` → same attempt; repeat after capture / lapsed window → **`409`**) and returns a `redirectUrl` into the local mock-gateway harness; `confirmGatewayReturn` on success is the **webhook-confirm stand-in and the missing f7↔f8 bridge** — flips the request `converted` (+ client-augmented `bookingId`, via `mockMarkBookingRequestConverted` in the f7 mock), inserts a **confirmed** booking into the f8 store (`mockInsertConvertedBooking`), and auto-issues the b11-shaped invoice (`moadianStatus: pending`, `pdfUrl: null` so the print path exercises); replayed returns converge idempotently; `getInvoice` 404s until issued | `USE_PAYMENT_MOCK` (`services/payment/constants.ts`, default `true`) | b10 initiate + b11 invoice are live and `paymentClientApi` maps them 1:1 (`Idempotency-Key` header, `GET invoices/{bookingId}`); deliver **REQ-016** (checkout summary — the real client already targets the proposed `booking_requests/checkout_summary/{id}` slug) + **REQ-017** (transaction status / `bookingId`; until then the real outcome poll maps `booking_requests/get` statuses and can't distinguish declined from slow) + **REQ-018** (invoice reachable post-capture), make the upstream `bookingRequests` flow real, then set flag `false`. No hook/component change | 🟡 |
| Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🟡 | | Mock-gateway page (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the PSP's hosted payment page so the initiate → redirect → return round-trip is exercisable without a gateway: the mock `redirectUrl` points here, and its success/failure buttons drive both branches of the return surface (`?outcome=success\|failure`). Clearly labelled «درگاه پرداخت آزمایشی», dashed border | _none — only reachable via the mock's `redirectUrl`_ | On the real path b10's `redirectUrl` is the PSP's **absolute** URL (the checkout does a full `window.location.assign` for `http(s)` URLs), so this page is simply never linked; delete it when `USE_PAYMENT_MOCK` retires. The PSP's return deep-link into `/bookings/checkout/return` is backend/PSP config | 🟡 |
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |