From ccfa27aff63bfa7b537f20e4b63e1380a14a89e4 Mon Sep 17 00:00:00 2001 From: hamid Date: Fri, 10 Jul 2026 12:51:53 +0330 Subject: [PATCH] frontend phase 10 --- client/CLAUDE.md | 9 +- client/messages/en.json | 73 +++++ client/messages/fa.json | 73 +++++ .../(customer)/bookings/[id]/cancel/page.tsx | 213 ++++++++++++++ .../(customer)/bookings/[id]/page.tsx | 77 +++++- .../bookings/[id]/refund_status/page.tsx | 75 +++++ .../CancellationPolicyDisclosure.test.tsx | 80 ++++++ .../CancellationPolicyDisclosure.tsx | 113 ++++++++ .../CancellationPolicyDisclosure/index.tsx | 2 + .../RefundEtaBanner/RefundEtaBanner.test.tsx | 39 +++ .../RefundEtaBanner/RefundEtaBanner.tsx | 65 +++++ .../src/components/RefundEtaBanner/index.tsx | 2 + .../RefundStatusCard.test.tsx | 83 ++++++ .../RefundStatusCard/RefundStatusCard.tsx | 135 +++++++++ .../src/components/RefundStatusCard/index.tsx | 2 + client/src/constants/routes.ts | 8 + client/src/services/bookings/apis/mockApi.ts | 152 ++++++++++ client/src/services/refunds/apis/clientApi.ts | 96 +++++++ client/src/services/refunds/apis/index.ts | 10 + client/src/services/refunds/apis/mockApi.ts | 261 ++++++++++++++++++ client/src/services/refunds/constants.ts | 58 ++++ .../refunds/hooks/useCancelBooking.ts | 19 ++ .../hooks/useCancellationPolicyPreview.ts | 19 ++ .../services/refunds/hooks/useRefundStatus.ts | 31 +++ client/src/services/refunds/index.ts | 7 + client/src/services/refunds/invalidations.ts | 22 ++ client/src/services/refunds/keys.ts | 18 ++ client/src/services/refunds/types.ts | 195 +++++++++++++ dev/shared-working-context/frontend/STATUS.md | 39 +++ .../frontend/requests/for-backend.md | 60 ++++ .../reports/frontend-phase-10-report.md | 117 ++++++++ .../reports/mocks-registry.md | 1 + 32 files changed, 2151 insertions(+), 3 deletions(-) create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx create mode 100644 client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx create mode 100644 client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx create mode 100644 client/src/components/CancellationPolicyDisclosure/index.tsx create mode 100644 client/src/components/RefundEtaBanner/RefundEtaBanner.test.tsx create mode 100644 client/src/components/RefundEtaBanner/RefundEtaBanner.tsx create mode 100644 client/src/components/RefundEtaBanner/index.tsx create mode 100644 client/src/components/RefundStatusCard/RefundStatusCard.test.tsx create mode 100644 client/src/components/RefundStatusCard/RefundStatusCard.tsx create mode 100644 client/src/components/RefundStatusCard/index.tsx create mode 100644 client/src/services/refunds/apis/clientApi.ts create mode 100644 client/src/services/refunds/apis/index.ts create mode 100644 client/src/services/refunds/apis/mockApi.ts create mode 100644 client/src/services/refunds/constants.ts create mode 100644 client/src/services/refunds/hooks/useCancelBooking.ts create mode 100644 client/src/services/refunds/hooks/useCancellationPolicyPreview.ts create mode 100644 client/src/services/refunds/hooks/useRefundStatus.ts create mode 100644 client/src/services/refunds/index.ts create mode 100644 client/src/services/refunds/invalidations.ts create mode 100644 client/src/services/refunds/keys.ts create mode 100644 client/src/services/refunds/types.ts create mode 100644 dev/shared-working-context/reports/frontend-phase-10-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index bc84723..6da8ef4 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -127,10 +127,12 @@ client/ │ │ │ ├── onboarding/page.tsx # /onboarding — A3→A4 wizard (relation → first patient) │ │ │ ├── bookings/ │ │ │ │ ├── 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/[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]/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 ~7–10-day ETA, failed=contact-support; polls only while non-terminal │ │ │ │ └── checkout/ # f9 checkout flow (C5 accept CTA lands on page.tsx with ?request_id=) │ │ │ │ ├── page.tsx # C6 خلاصه و پرداخت — acceptance badge, served reconciling breakdown (PriceBreakdown), EscrowNotice, payment-window countdown, «ادامه پرداخت ←» (idempotency-key-per-attempt) + disabled BNPL seam (f11) │ │ │ │ ├── gateway/page.tsx # dev mock-gateway page — TEST HARNESS standing in for the PSP redirect (mock redirectUrl points here; success/failure buttons drive both return branches) @@ -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) │ ├── 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) + │ ├── 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 ~7–10 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) │ ├── 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 @@ -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 │ ├── 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 + │ ├── 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}/ │ ├── types.ts # Request/response types + the domain's Api interface (the seam) │ ├── 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 - `'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` +- `'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` 7–10-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) **Namespace conventions for the phases to come** (seed each when its feature lands, in both locale diff --git a/client/messages/en.json b/client/messages/en.json index 4a585e1..d93d796 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -766,5 +766,78 @@ "upload_retry": "Try again", "upload_rejected": "This document was rejected", "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 7–10 business days", + "eta_expected_label": "estimated by {date}", + "cancel_booking_cta": "Cancel booking", + "refund_section_title": "Refund" } } diff --git a/client/messages/fa.json b/client/messages/fa.json index 13b6030..76aa772 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -766,5 +766,78 @@ "upload_retry": "تلاش مجدد", "upload_rejected": "این مدرک رد شد", "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": "بازپرداخت" } } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx new file mode 100644 index 0000000..acbdbdf --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/cancel/page.tsx @@ -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('changed_mind'); + const [reasonNotes, setReasonNotes] = useState(''); + + const bookingHref = `/${locale}${ROUTES.BOOKINGS}/${bookingId}`; + + if (isLoading) return ; + + if (isError || !preview || bookingId == null) { + return ( + + + + + {t('error_title')} + + {t('error_body')} + + + + ); + } + + if (!preview.cancellable) { + return ( + + + + + {t('not_cancellable_title')} + + {t('not_cancellable_body')} + + + + router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)} + sx={{ m: 0 }} + > + {t('view_refund_status')} + + router.push(bookingHref)} sx={{ m: 0 }}> + {t('back_to_booking')} + + + + ); + } + + 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 ( + + + {t('cancel_title')} + + + + {step === 0 ? ( + <> + + + setReasonCategory(event.target.value as CancelReasonCategory)} + fullWidth + > + {REASON_CATEGORIES.map((category) => ( + + {t(`reason_cat_${category}`)} + + ))} + + setReasonNotes(event.target.value)} + multiline + minRows={2} + fullWidth + /> + setAcknowledged(event.target.checked)} />} + label={t('acknowledge_label')} + /> + + + router.push(bookingHref)} sx={{ m: 0 }}> + {t('back_to_booking')} + + setStep(1)} + sx={{ m: 0 }} + > + {t('continue_cta')} + + + + ) : ( + <> + + + {t('confirm_title')} + + {t('confirm_restate', { refund: refundToman, fee: feeToman })} + + + {cancel.isError && ( + + {t(cancelErrorKey(cancel.error))} + + )} + + + setStep(0)} + disabled={cancel.isPending} + sx={{ m: 0 }} + > + {tc('back')} + + + {cancel.isPending ? t('submitting') : t('confirm_cta')} + + + + )} + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx index de37cb6..e9ac0d3 100644 --- a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/page.tsx @@ -1,14 +1,87 @@ '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 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: * 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). + * + * 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() { const params = useParams<{ id: string }>(); const id = Number(params.id); - return 0 ? id : -1} viewerRole="customer" />; + const bookingId = Number.isInteger(id) && id > 0 ? id : -1; + + return ( + + + {bookingId > 0 && } + + ); +} + +/** + * 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 ( + + router.push(`/${locale}${bookingCancelPath(bookingId)}`)} + sx={{ m: 0 }} + > + {t('cancel_booking_cta')} + + + ); + } + + if (isCancelled && refund) { + return ( + + + {t('refund_section_title')} + + + router.push(`/${locale}${bookingRefundStatusPath(bookingId)}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('view_refund_status')} + + + ); + } + + return null; } diff --git a/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx new file mode 100644 index 0000000..b6261d4 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/(customer)/bookings/[id]/refund_status/page.tsx @@ -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 ~7–10-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 ( + + + {t('status_title')} + + + {isLoading ? ( + + ) : isError ? ( + + + + {t('error_title')} + + {t('error_body')} + + + ) : refund ? ( + <> + + {t('status_subtitle')} + + + + ) : ( + + + + {t('no_refund_title')} + + {t('no_refund_body')} + + + )} + + router.push(`/${locale}${ROUTES.BOOKINGS}/${bookingId}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('back_to_booking')} + + + ); +} diff --git a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx new file mode 100644 index 0000000..f945f8a --- /dev/null +++ b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.test.tsx @@ -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 = (props) => ( + + + +); + +// 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(' component', () => { + it('renders the tier label off the policy code (never the raw code) and the fee %', () => { + const { container } = render(); + 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(); + 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(); + 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(); + expect(screen.getByTestId('admin-approval-explainer')).toBeInTheDocument(); + expect(screen.getByTestId('refund-eta-banner')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx new file mode 100644 index 0000000..59531d5 --- /dev/null +++ b/client/src/components/CancellationPolicyDisclosure/CancellationPolicyDisclosure.tsx @@ -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 0–1 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 ~7–10-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 = ({ 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 ( + + + + + + {t(`policy_${preview.cancellationPolicyCode}`)} + + 0 ? 'active' : 'rejected'} + label={t('refund_percent', { percent: refundPercent })} + /> + + + {t(`lead_${preview.leadTimeLabel}`)} + + + {t('fee_percent', { percent: feePercent })} + + + + + + + {isMultiSession && ( + + + + {t('sessions_title')} + + + {preview.sessions.map((session) => ( + + + {`${session.sessionIndex}. ${formatShamsiDate(session.scheduledDate, locale)}`} + + + + ))} + + + )} + + + + } + data-testid="admin-approval-explainer" + sx={{ marginY: 0 }} + > + {t('admin_approval_explainer')} + + + ); +}; + +export default CancellationPolicyDisclosure; diff --git a/client/src/components/CancellationPolicyDisclosure/index.tsx b/client/src/components/CancellationPolicyDisclosure/index.tsx new file mode 100644 index 0000000..4bb4f36 --- /dev/null +++ b/client/src/components/CancellationPolicyDisclosure/index.tsx @@ -0,0 +1,2 @@ +export { default } from './CancellationPolicyDisclosure'; +export type { CancellationPolicyDisclosureProps } from './CancellationPolicyDisclosure'; diff --git a/client/src/components/RefundEtaBanner/RefundEtaBanner.test.tsx b/client/src/components/RefundEtaBanner/RefundEtaBanner.test.tsx new file mode 100644 index 0000000..a2f110e --- /dev/null +++ b/client/src/components/RefundEtaBanner/RefundEtaBanner.test.tsx @@ -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 = (props) => ( + + + +); + +describe(' component', () => { + it('renders the BNPL wording + the ~7–10 business-day window and the ETA', () => { + const { container } = render(); + 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(); + 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(); + expect(screen.getByText('eta_manual_title')).toBeInTheDocument(); + expect(screen.queryByTestId('refund-eta-window')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/RefundEtaBanner/RefundEtaBanner.tsx b/client/src/components/RefundEtaBanner/RefundEtaBanner.tsx new file mode 100644 index 0000000..dd13878 --- /dev/null +++ b/client/src/components/RefundEtaBanner/RefundEtaBanner.tsx @@ -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 ~7–10 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 = { + 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 ~7–10 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 = ({ channel, eta }) => { + const t = useTranslations('refunds'); + const locale = useLocale(); + const copy = CHANNEL_COPY[channel]; + + return ( + } + data-testid="refund-eta-banner" + data-channel={channel} + sx={{ + marginY: 0, + borderColor: 'var(--bal-primary)', + color: 'var(--bal-primary)', + backgroundColor: 'var(--bal-primary-soft)', + }} + > + + + {t(copy.titleKey)} + + {t(copy.bodyKey)} + {channel === 'bnpl_revert' && ( + + {t('eta_business_days')} + {eta ? ` · ${t('eta_expected_label', { date: formatShamsiDate(eta, locale) })}` : ''} + + )} + + + ); +}; + +export default RefundEtaBanner; diff --git a/client/src/components/RefundEtaBanner/index.tsx b/client/src/components/RefundEtaBanner/index.tsx new file mode 100644 index 0000000..7f99cca --- /dev/null +++ b/client/src/components/RefundEtaBanner/index.tsx @@ -0,0 +1,2 @@ +export { default } from './RefundEtaBanner'; +export type { RefundEtaBannerProps } from './RefundEtaBanner'; diff --git a/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx b/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx new file mode 100644 index 0000000..d4a6c71 --- /dev/null +++ b/client/src/components/RefundStatusCard/RefundStatusCard.test.tsx @@ -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 = (props) => ( + + + +); + +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(' 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(); + 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( + , + ); + 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(); + 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(); + }); +}); diff --git a/client/src/components/RefundStatusCard/RefundStatusCard.tsx b/client/src/components/RefundStatusCard/RefundStatusCard.tsx new file mode 100644 index 0000000..0bba06c --- /dev/null +++ b/client/src/components/RefundStatusCard/RefundStatusCard.tsx @@ -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 = { + 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 ~7–10-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 = ({ 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 ( + + {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. + + + + {t('failed_title')} + + {t('failed_body')} + {refund.externalRevertReference && ( + + {t('reference_label')}: {refund.externalRevertReference} + + )} + + + ) : ( + <> + + + + + + + + + + + + {t('total_refunded_label')} + + + {formatIrrToToman(refund.totalRefundedIrr, locale)} {tc('currency_toman')} + + + {refund.externalRevertReference && ( + <> + + + + {t('reference_label')} + + + {refund.externalRevertReference} + + + + )} + + + + {hasDecomposition && ( + + )} + + + + )} + + ); +}; + +export default RefundStatusCard; diff --git a/client/src/components/RefundStatusCard/index.tsx b/client/src/components/RefundStatusCard/index.tsx new file mode 100644 index 0000000..7efa05a --- /dev/null +++ b/client/src/components/RefundStatusCard/index.tsx @@ -0,0 +1,2 @@ +export { default } from './RefundStatusCard'; +export type { RefundStatusCardProps } from './RefundStatusCard'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 35a6b0e..043f4aa 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -59,5 +59,13 @@ export const ROUTES = { export const bookingInvoicePath = (bookingId: number | string): string => `${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. */ export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN]; diff --git a/client/src/services/bookings/apis/mockApi.ts b/client/src/services/bookings/apis/mockApi.ts index cc30db5..03cb71f 100644 --- a/client/src/services/bookings/apis/mockApi.ts +++ b/client/src/services/bookings/apis/mockApi.ts @@ -160,6 +160,109 @@ function seed(): void { createdAt: new Date().toISOString(), 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] = { @@ -461,3 +564,52 @@ export function mockInsertConvertedBooking(seed: ConvertedBookingSeed): BookingD bookings = [booking, ...bookings]; 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); +} diff --git a/client/src/services/refunds/apis/clientApi.ts b/client/src/services/refunds/apis/clientApi.ts new file mode 100644 index 0000000..4e148a4 --- /dev/null +++ b/client/src/services/refunds/apis/clientApi.ts @@ -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>( + `${BOOKINGS}/${bookingId}/cancellation_policy`, + ), + ), + + cancelBooking: async ({ bookingId, sessionIds, reasonCategory, reasonNotes }: CancelBookingInput) => + toSummary( + unwrap( + await clientFetch>(`${BOOKINGS}/${bookingId}/cancel`, { + method: 'POST', + body: JSON.stringify({ sessionIds, reasonCategory, reasonNotes }), + }), + ), + ), + + getRefundByBooking: async (bookingId: number) => { + try { + return toSummary( + unwrap(await clientFetch>(`${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>(`${REFUNDS}/${refundId}/status`))), +}; diff --git a/client/src/services/refunds/apis/index.ts b/client/src/services/refunds/apis/index.ts new file mode 100644 index 0000000..d629577 --- /dev/null +++ b/client/src/services/refunds/apis/index.ts @@ -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; diff --git a/client/src/services/refunds/apis/mockApi.ts b/client/src/services/refunds/apis/mockApi.ts new file mode 100644 index 0000000..2400d23 --- /dev/null +++ b/client/src/services/refunds/apis/mockApi.ts @@ -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 ~7–10-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 = { + 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: ~7–10 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 = {}; + +// 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 ~7–10 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); + }, +}; diff --git a/client/src/services/refunds/constants.ts b/client/src/services/refunds/constants.ts new file mode 100644 index 0000000..5d2e3e3 --- /dev/null +++ b/client/src/services/refunds/constants.ts @@ -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 0–1. + */ +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 + * ~7–10 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; diff --git a/client/src/services/refunds/hooks/useCancelBooking.ts b/client/src/services/refunds/hooks/useCancelBooking.ts new file mode 100644 index 0000000..f55b84c --- /dev/null +++ b/client/src/services/refunds/hooks/useCancelBooking.ts @@ -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), + }); +} diff --git a/client/src/services/refunds/hooks/useCancellationPolicyPreview.ts b/client/src/services/refunds/hooks/useCancellationPolicyPreview.ts new file mode 100644 index 0000000..4212236 --- /dev/null +++ b/client/src/services/refunds/hooks/useCancellationPolicyPreview.ts @@ -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, + }); +} diff --git a/client/src/services/refunds/hooks/useRefundStatus.ts b/client/src/services/refunds/hooks/useRefundStatus.ts new file mode 100644 index 0000000..09274e7 --- /dev/null +++ b/client/src/services/refunds/hooks/useRefundStatus.ts @@ -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; + }, + }); +} diff --git a/client/src/services/refunds/index.ts b/client/src/services/refunds/index.ts new file mode 100644 index 0000000..2dbf35e --- /dev/null +++ b/client/src/services/refunds/index.ts @@ -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'; diff --git a/client/src/services/refunds/invalidations.ts b/client/src/services/refunds/invalidations.ts new file mode 100644 index 0000000..9b3b4cf --- /dev/null +++ b/client/src/services/refunds/invalidations.ts @@ -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) }); +} diff --git a/client/src/services/refunds/keys.ts b/client/src/services/refunds/keys.ts new file mode 100644 index 0000000..78f39b0 --- /dev/null +++ b/client/src/services/refunds/keys.ts @@ -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, +}; diff --git a/client/src/services/refunds/types.ts b/client/src/services/refunds/types.ts new file mode 100644 index 0000000..f3ffcbb --- /dev/null +++ b/client/src/services/refunds/types.ts @@ -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` (~7–10 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; + /** 0–1 fraction of the refundable amount returned to the customer. */ + refundPercentageApplied: number; + /** 0–1 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 ~7–10 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 ~7–10 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; + cancelBooking(input: CancelBookingInput): Promise; + /** `null` when the booking has no refund (e.g. not cancelled) — a clean empty state, not an error. */ + getRefundByBooking(bookingId: number): Promise; + getRefund(refundId: number): Promise; +} diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index bb5a3f2..987a2a9 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -12,6 +12,45 @@ for awareness. - **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 + **~7–10-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 ~7–10-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 - **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 خلاصه و پرداخت** diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 2c76743..f1a61d3 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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 state. - **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 diff --git a/dev/shared-working-context/reports/frontend-phase-10-report.md b/dev/shared-working-context/reports/frontend-phase-10-report.md new file mode 100644 index 0000000..60670d9 --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-10-report.md @@ -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 ~7–10-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 ~7–10-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 ~7–10-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 + 2–3 **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 ~7–10-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. diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 96e668b..de154fb 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 | 🟡 | | `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 | 🟡 | +| `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 ~7–10-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 | 🟡 |