frontend phase 12

This commit is contained in:
hamid
2026-07-10 14:48:15 +03:30
parent 67c028562e
commit 6186f54294
34 changed files with 2363 additions and 1 deletions
+7 -1
View File
@@ -165,7 +165,8 @@ client/
│ │ │ │ ├── review/page.tsx # B6 — under-review (same status query, condensed mini-checklist)
│ │ │ │ ├── VerificationChecklist.tsx # B3 body: meter + step rows (co-located, page-only)
│ │ │ │ └── verificationSteps.ts # step→label/chip/route helpers + synthetic mobile step (keeps rendering data-driven)
│ │ │ ── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
│ │ │ ── visits/ # /nurse/visits — f8 EVV: page.tsx = ویزیت امروز today-sessions feed (per-session check-in/out via useEvvController + advisory EvvStatusBanner) ↔ visits/[id]/page.tsx nurse booking detail (BookingDetailView viewerRole="nurse": EVV controls + gated care card)
│ │ │ └── earnings/ # /nurse/earnings — f12 nurse earnings (read-only): page.tsx = EarningsBalanceHeader (net payable balance + 4 buckets, negative "owed back") + cadence/dispute-window explainer + state-segmented EarningsRow list (deep-links to /nurse/visits/[id]) ↔ payouts/page.tsx (PayoutHistoryRow list) → payouts/[id]/page.tsx (payout/batch reconciliation detail: money decomposition + masked IBAN + booking links)
│ │ └── admin/ # Admin/backoffice (/admin/…) — desktop sidebar shell
│ │ ├── layout.tsx # 'use client' — wraps AdminLayout
│ │ ├── page.tsx # /admin (overview)
@@ -203,6 +204,9 @@ client/
│ ├── RefundEtaBanner/ # f10 per-channel refund ETA — bnpl_revert surfaces the ~710 business-day window honestly (never instant), psp_card/manual wording; one branch on refund_channel (tested)
│ ├── BnplPlanCard/ # f11 D2 installment-plan option card (terracotta): term/installments + interest-free/fee sub-label + served monthly amount + down-payment indicator; single-select (tested)
│ ├── InstallmentScheduleRow/ # f11 repayment row: down-payment(«امروز»)/installment + Shamsi due date + served amount + optional provider-reported status chip; reused by D4 schedule + D5 wallet due list (tested)
│ ├── EarningsBalanceHeader/ # f12 nurse net payable balance + 4-bucket breakdown (pending/eligible/paid/clawback off --bal-{warning,info,success,error}); renders a negative net as an explicit "owed back" state (magnitude only, never a bare minus) (tested)
│ ├── EarningsRow/ # f12 one earnings item: three-amount «gross commission = your payout» breakdown via PriceBreakdown + one of four visually-distinct state chips + state affordance (pending→display-only dispute-window CountdownTimer, eligible→awaiting-batch, paid→paid_at+ref+payout link, clawback_applied→net explanation); deep-links to /nurse/visits/[id] (tested)
│ ├── PayoutHistoryRow/ # f12 one nurse_payouts row: net transferred + payout-status chip (pending/submitted/paid/failed) + period + masked IBAN (last-4, dir=ltr) + transfer ref + read-only failure banner (no nurse retry) (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
@@ -260,6 +264,7 @@ client/
│ ├── 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
│ ├── bnpl/ # F11 BNPL installment checkout (b12) — the alternate branch off C6. useBnplOptions/useCheckEligibility/useBnplSchedule/useIssueBnplToken/useAcceptBnplSchedule(invalidates booking+checkout+wallet)/useBnplOrder(bounded backoff poll)/useWalletInstallments; invalidations.ts reuses f9 invalidateAfterPaymentSuccess + the wallet key; seam+mock(PRIMARY)+client. Mock = the settle bridge: reuses the f9 conversion (mockInsertConvertedBooking + mockMarkBookingRequestConverted) — a settled BNPL order is a card payment net-of-fee — and seeds a provider-reported Wallet plan (D5). Contract serves only eligibility/initiate/status; options/schedule/wallet-installments/D3-KYC/customer-bookingId are REQ-022/023/024 gaps mocked behind the seam. Money = served IRR digit-strings (the mock computes plan/schedule with BigInt; components only format). D5 is provider-reported status, NOT a Balinyaar ledger; early-pay hands off to the provider
│ ├── payouts/ # F12 nurse earnings & payout history (b13) — read-only, no mutations. useNurseEarningsBalance/useNurseEarnings(state,page)/useNursePayoutHistory(page)/useNursePayoutDetail(id); the state-filter + page are part of the query key (tabs/pages cache separately, keepPreviousData); seam+mock(PRIMARY)+client. b13 serves only GET nurse_payouts/history; the four-bucket earnings summary, per-booking earnings list + money-state, and nurse-readable payout detail (batch context + booking links + failureReason) are REQ-025 gaps mocked behind the seam. EarningsState (pending|eligible|paid|clawback_applied) is a client display model derived server-side; PayoutStatus is the contract's pending|submitted|paid|failed. Money = IRR digit-strings (gross=commission+payout; net=grossclawback; Σ booking-links=grossEarnings); the net payable balance is SIGNED (may be negative "owed back", never clamped); eligibility/dates/amounts are server truth (never computed client-side); the BNPL provider commission never appears (payment-method-invariant). MOCK_SCENARIO toggles the negative-balance demo
│ └── {domain}/
│ ├── types.ts # Request/response types + the domain's Api interface (the seam)
│ ├── keys.ts # React Query key factory (hierarchical)
@@ -358,6 +363,7 @@ async function MyServerComponent() {
- `'payment'` — the f9 checkout & invoice surface: C6 labels (breakdown rows هزینه خدمت/کارمزد بالین‌یار/مالیات/مبلغ کل, the **verbatim escrow copy** `escrow_notice`, «ادامه پرداخت ←», the BNPL seam), the card-flow states (initiating/redirecting/pending/failed/expired/already-paid), the confirmation + invoice screens (VAT-on-commission line, مودیان `moadian_*` states), `pstatus_*` transaction-status labels, and the dev mock-gateway harness copy; consumed by the checkout pages, the invoice page, `EscrowNotice`, and `PaymentStatusBadge`
- `'refunds'` — the f10 customer cancellation + refund-status surface: policy-tier labels keyed off `cancellation_policy_code` (`policy_*`), the lead-time + refund %/fee % disclosure, the refund-vs-fee breakdown rows, the multi-session refundable/locked reasons (`reason_*`), the admin-approval explainer, the three refund-status step + chip labels (`step_*`/`rstatus_*`), the per-channel ETA copy (`eta_*``bnpl_revert` 710-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner`
- `'bnpl'` — the f11 BNPL installment checkout (D1D5): the ownership-truth copy (`ownership_note`/`contract_note`/`provider_owned_note`/`paid_via_installments` — the agreement is customer↔provider, provider-financed, Balinyaar paid in full), provider names/taglines keyed off `provider_{code}`, the method/plan/eligibility/schedule labels, ICU-`number` plan params (`plan_term_months`/`plan_installments`/`plan_fee`/`down_payment_percent`/`installment_n` — Persian digits on `fa`), the declined/error copy + card fall-back, the D5 wallet outstanding-balance/due-list/`status_*` labels, and the handoff/settle states; consumed by the D1D4 wizard + gateway/return pages, `WalletInstallments`, the reused confirmation, and `BnplPlanCard`/`InstallmentScheduleRow`
- `'payouts'` — the f12 nurse earnings & payout-history surface: the balance header (`balance_net_*`/`balance_owed_*` — the negative "owed back" state + hint) + four buckets (`bucket_*`), the cadence/dispute-window explainer (`explainer_*` — weekly batches, EVV+72h gate, method-invariant), the state tabs + earnings-state chip labels (`tab_*`/`estate_*` for pending/eligible/paid/clawback_applied), the nurse-framed three-amount breakdown (`amount_gross`/`amount_commission`/`amount_your_payout`) + clawback net explanation (`clawback_*`), the per-state affordances (`pending_affordance`/`dispute_window_*`/`eligible_affordance`/`paid_on`), the payout-status labels (`pstatus_*` for pending/submitted/paid/failed) + batch-status labels (`bstatus_*`), the read-only failure banner (`failure_*`), and the detail money decomposition + booking-links copy (`detail_*`/`gross_earnings_label`/`net_amount_label`); consumed by the `/nurse/earnings` pages and `EarningsBalanceHeader`/`EarningsRow`/`PayoutHistoryRow`
- `'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
+85
View File
@@ -12,6 +12,7 @@
"requests": "Requests",
"verification": "Verification",
"visits": "Visits",
"earnings": "Earnings",
"admin": "Admin",
"overview": "Overview",
"users": "Users",
@@ -928,5 +929,89 @@
"step_plan": "Plan",
"step_eligibility": "Eligibility",
"step_schedule": "Schedule"
},
"payouts": {
"title": "Earnings",
"subtitle": "What you've earned, and when it arrives.",
"balance_net_label": "Payable balance",
"balance_net_hint": "Amounts you've earned that are on their way to your account.",
"balance_owed_label": "Owed back to Balinyaar",
"balance_owed_hint": "Past clawbacks exceed your current earnings. This is deducted from your future payouts — nothing is charged to you directly.",
"balance_error": "Couldn't load your balance.",
"bucket_pending": "Pending",
"bucket_eligible": "Eligible",
"bucket_paid": "Paid (lifetime)",
"bucket_clawback": "Clawback outstanding",
"explainer_title": "How payouts work",
"explainer_point_1": "You're paid in weekly batches to your verified primary account.",
"explainer_point_2": "An amount becomes eligible only after the visit is verified (check-out) and the 72-hour dispute window closes.",
"explainer_point_3": "You receive the same amount whether the family paid by card or in installments — the installment provider's fee is never deducted from you.",
"tab_all": "All",
"tab_pending": "Pending",
"tab_eligible": "Eligible",
"tab_paid": "Paid",
"tab_clawback_applied": "Clawed back",
"estate_pending": "Pending",
"estate_eligible": "Eligible",
"estate_paid": "Paid",
"estate_clawback_applied": "Clawed back",
"booking_ref": "Booking #{id}",
"view_booking": "View booking",
"view_payout": "View payout",
"view_payout_detail": "View details",
"amount_gross": "Service price",
"amount_commission": "Balinyaar commission",
"amount_your_payout": "Your payout",
"clawback_explainer": "This booking was refunded after you were paid, so the amount was netted out:",
"clawback_original": "Originally earned",
"clawback_amount": "Clawed back",
"clawback_net": "Net",
"pending_affordance": "In escrow · dispute window open",
"dispute_window_label": "Time to eligible",
"dispute_window_elapsed": "Dispute window closed",
"eligible_affordance": "Cleared · awaiting the next weekly batch",
"paid_on": "Paid on {date}",
"transfer_reference_label": "Transfer reference",
"list_error": "Couldn't load your earnings.",
"list_empty_title": "No earnings yet",
"list_empty_body": "Completed and verified visits will appear here.",
"retry": "Retry",
"page_prev": "Previous",
"page_next": "Next",
"page_indicator": "Page {page} of {total}",
"back_to_earnings": "Earnings",
"history_title": "Payout history",
"history_subtitle": "Every transfer to your account.",
"history_error": "Couldn't load your payout history.",
"history_empty_title": "No payouts yet",
"history_empty_body": "Your weekly transfers will appear here once the first batch runs.",
"payout_net_amount": "Amount transferred",
"period_label": "Period",
"paid_at_label": "Paid on",
"masked_iban_label": "Account",
"pstatus_pending": "Pending",
"pstatus_submitted": "In transfer",
"pstatus_paid": "Paid",
"pstatus_failed": "Failed",
"failure_title": "This transfer failed",
"failure_reason_label": "Reason",
"failure_hint": "Our team will retry it — you don't need to do anything. Check your bank details are correct in your bank settings.",
"back_to_history": "Payout history",
"detail_title": "Payout detail",
"detail_batch": "Weekly batch",
"detail_not_found": "Payout not found.",
"bstatus_draft": "Draft",
"bstatus_processing": "Processing",
"bstatus_partially_failed": "Partially failed",
"bstatus_completed": "Completed",
"bstatus_failed": "Failed",
"processed_on_label": "Processed on",
"detail_money_title": "Amount breakdown",
"gross_earnings_label": "Gross earnings",
"clawback_applied_label": "Clawback applied",
"net_amount_label": "Net amount",
"amount_transferred_label": "Transferred",
"detail_bookings_title": "Bookings covered",
"detail_bookings_hint": "This transfer paid for these visits."
}
}
+85
View File
@@ -12,6 +12,7 @@
"requests": "درخواست‌ها",
"verification": "احراز هویت",
"visits": "ویزیت‌ها",
"earnings": "درآمدها",
"admin": "مدیریت",
"overview": "نمای کلی",
"users": "کاربران",
@@ -928,5 +929,89 @@
"step_plan": "طرح",
"step_eligibility": "اعتبارسنجی",
"step_schedule": "جدول"
},
"payouts": {
"title": "درآمدها",
"subtitle": "آنچه به‌دست آورده‌اید و زمان واریز آن.",
"balance_net_label": "موجودی قابل پرداخت",
"balance_net_hint": "مبالغی که به‌دست آورده‌اید و در مسیر واریز به حساب شماست.",
"balance_owed_label": "بدهی به بالین‌یار",
"balance_owed_hint": "بازپس‌گیری‌های گذشته از درآمد فعلی شما بیشتر است. این مبلغ از پرداخت‌های آینده کسر می‌شود و چیزی مستقیماً از شما دریافت نمی‌شود.",
"balance_error": "بارگذاری موجودی ممکن نشد.",
"bucket_pending": "در انتظار",
"bucket_eligible": "آماده پرداخت",
"bucket_paid": "پرداخت‌شده (کل)",
"bucket_clawback": "بازپس‌گیری معوق",
"explainer_title": "پرداخت‌ها چگونه کار می‌کنند",
"explainer_point_1": "پرداخت‌ها به‌صورت هفتگی و به حساب تاییدشده اصلی شما واریز می‌شود.",
"explainer_point_2": "هر مبلغ تنها پس از تایید ویزیت (خروج) و بسته‌شدن پنجره ۷۲ ساعته اعتراض، آماده پرداخت می‌شود.",
"explainer_point_3": "چه خانواده با کارت پرداخت کند چه اقساطی، مبلغ شما یکسان است — کارمزد ارائه‌دهنده اقساط هرگز از شما کسر نمی‌شود.",
"tab_all": "همه",
"tab_pending": "در انتظار",
"tab_eligible": "آماده",
"tab_paid": "پرداخت‌شده",
"tab_clawback_applied": "بازپس‌گرفته",
"estate_pending": "در انتظار",
"estate_eligible": "آماده پرداخت",
"estate_paid": "پرداخت‌شده",
"estate_clawback_applied": "بازپس‌گرفته",
"booking_ref": "رزرو #{id}",
"view_booking": "مشاهده رزرو",
"view_payout": "مشاهده پرداخت",
"view_payout_detail": "مشاهده جزئیات",
"amount_gross": "مبلغ خدمت",
"amount_commission": "کارمزد بالین‌یار",
"amount_your_payout": "سهم شما",
"clawback_explainer": "این رزرو پس از پرداخت به شما، مسترد شد؛ بنابراین مبلغ آن کسر شد:",
"clawback_original": "درآمد اولیه",
"clawback_amount": "بازپس‌گرفته‌شده",
"clawback_net": "خالص",
"pending_affordance": "در حساب امانی · پنجره اعتراض باز است",
"dispute_window_label": "تا آماده‌شدن",
"dispute_window_elapsed": "پنجره اعتراض بسته شد",
"eligible_affordance": "تسویه‌شده · در انتظار دسته پرداخت هفتگی",
"paid_on": "پرداخت در {date}",
"transfer_reference_label": "کد پیگیری واریز",
"list_error": "بارگذاری درآمدها ممکن نشد.",
"list_empty_title": "هنوز درآمدی ندارید",
"list_empty_body": "ویزیت‌های تکمیل و تاییدشده اینجا نمایش داده می‌شوند.",
"retry": "تلاش دوباره",
"page_prev": "قبلی",
"page_next": "بعدی",
"page_indicator": "صفحه {page} از {total}",
"back_to_earnings": "درآمدها",
"history_title": "تاریخچه پرداخت‌ها",
"history_subtitle": "همه واریزها به حساب شما.",
"history_error": "بارگذاری تاریخچه پرداخت‌ها ممکن نشد.",
"history_empty_title": "هنوز پرداختی ندارید",
"history_empty_body": "واریزهای هفتگی شما پس از اجرای اولین دسته اینجا نمایش داده می‌شوند.",
"payout_net_amount": "مبلغ واریزشده",
"period_label": "دوره",
"paid_at_label": "تاریخ واریز",
"masked_iban_label": "حساب",
"pstatus_pending": "در انتظار",
"pstatus_submitted": "در حال انتقال",
"pstatus_paid": "پرداخت‌شده",
"pstatus_failed": "ناموفق",
"failure_title": "این واریز ناموفق بود",
"failure_reason_label": "علت",
"failure_hint": "تیم ما دوباره تلاش می‌کند و نیازی به اقدام شما نیست. از درست‌بودن اطلاعات بانکی‌تان در تنظیمات حساب مطمئن شوید.",
"back_to_history": "تاریخچه پرداخت‌ها",
"detail_title": "جزئیات پرداخت",
"detail_batch": "دسته هفتگی",
"detail_not_found": "پرداخت یافت نشد.",
"bstatus_draft": "پیش‌نویس",
"bstatus_processing": "در حال پردازش",
"bstatus_partially_failed": "پرداخت ناقص",
"bstatus_completed": "تکمیل‌شده",
"bstatus_failed": "ناموفق",
"processed_on_label": "تاریخ پردازش",
"detail_money_title": "ریز مبلغ",
"gross_earnings_label": "درآمد ناخالص",
"clawback_applied_label": "بازپس‌گیری اعمال‌شده",
"net_amount_label": "مبلغ خالص",
"amount_transferred_label": "واریزشده",
"detail_bookings_title": "رزروهای دربرگرفته",
"detail_bookings_hint": "این واریز بابت این ویزیت‌ها پرداخت شده است."
}
}
@@ -0,0 +1,213 @@
'use client';
import { useMemo, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Collapse, Paper, Skeleton, Stack, Tab, Tabs, Typography } from '@mui/material';
import { AppButton, AppIcon, EarningsBalanceHeader, EarningsRow } from '@/components';
import { nurseBookingDetailPath, nursePayoutDetailPath } from '@/constants';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { EARNINGS_STATES, type EarningsState } from '@/services/payouts/types';
import { useNurseEarnings, useNurseEarningsBalance } from '@/services/payouts';
type EarningsTab = 'all' | EarningsState;
const TABS: readonly EarningsTab[] = ['all', ...EARNINGS_STATES];
/**
* Nurse earnings home (f12) the read-only "where is my money?" surface. Shows the ledger-derived net
* payable balance + four-bucket breakdown (`EarningsBalanceHeader`), a plain-Persian explainer of the
* weekly cadence + dispute-window gate, and a state-segmented earnings list. Each row deep-links to the f8
* booking detail (never rebuilt here) and, when paid, to the payout detail. The server owns eligibility and
* amounts; this screen only renders them.
*/
export default function NurseEarningsPage() {
const t = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const [tab, setTab] = useState<EarningsTab>('all');
const [page, setPage] = useState(1);
const [explainerOpen, setExplainerOpen] = useState(false);
const stateFilter = tab === 'all' ? undefined : tab;
const balance = useNurseEarningsBalance();
const earnings = useNurseEarnings(stateFilter, page);
const items = earnings.data?.items ?? [];
const total = earnings.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const openBooking = (bookingId: number) => router.push(`/${locale}${nurseBookingDetailPath(bookingId)}`);
const openPayout = (payoutId: number) => router.push(`/${locale}${nursePayoutDetailPath(payoutId)}`);
const onTabChange = (_: React.SyntheticEvent, next: EarningsTab) => {
setTab(next);
setPage(1);
};
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box>
<Typography variant="h5" component="h1">
{t('title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('subtitle')}
</Typography>
</Box>
{balance.isLoading ? (
<Skeleton variant="rounded" height={200} />
) : balance.isError ? (
<ErrorPanel message={t('balance_error')} onRetry={() => balance.refetch()} retryLabel={t('retry')} />
) : balance.data ? (
<EarningsBalanceHeader summary={balance.data} />
) : null}
<ExplainerCard open={explainerOpen} onToggle={() => setExplainerOpen((v) => !v)} />
<Stack sx={{ gap: 2 }}>
<Tabs
value={tab}
onChange={onTabChange}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
>
{TABS.map((value) => (
<Tab key={value} value={value} label={t(`tab_${value}`)} sx={{ textTransform: 'none' }} />
))}
</Tabs>
{earnings.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={200} />
))}
</Stack>
) : earnings.isError ? (
<ErrorPanel message={t('list_error')} onRetry={() => earnings.refetch()} retryLabel={t('retry')} />
) : items.length === 0 ? (
<EmptyPanel title={t('list_empty_title')} body={t('list_empty_body')} />
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<EarningsRow key={item.bookingId} item={item} onViewBooking={openBooking} onViewPayout={openPayout} />
))}
</Stack>
)}
<Pager
page={page}
pageCount={pageCount}
onPrev={() => setPage((p) => Math.max(1, p - 1))}
onNext={() => setPage((p) => Math.min(pageCount, p + 1))}
/>
</Stack>
</Box>
);
}
/** Collapsible "how payouts work" — the cadence + dispute-window + method-invariant copy (both locales). */
function ExplainerCard({ open, onToggle }: { open: boolean; onToggle: () => void }) {
const t = useTranslations('payouts');
const points = useMemo(() => ['explainer_point_1', 'explainer_point_2', 'explainer_point_3'] as const, []);
return (
<Paper
elevation={0}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-info)',
}}
>
<Stack
direction="row"
sx={{ gap: 1, alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={onToggle}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="info" size={18} color="var(--bal-info)" />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
{t('explainer_title')}
</Typography>
</Stack>
<AppIcon icon={open ? 'visibilityoff' : 'visibilityon'} size={18} color="var(--bal-text-secondary)" />
</Stack>
<Collapse in={open}>
<Stack component="ul" sx={{ gap: 0.75, mt: 1.5, mb: 0, pl: 2.5 }}>
{points.map((key) => (
<Typography key={key} component="li" variant="body2" sx={{ color: 'text.secondary' }}>
{t(key)}
</Typography>
))}
</Stack>
</Collapse>
</Paper>
);
}
function EmptyPanel({ title, body }: { title: string; body: string }) {
return (
<Paper
elevation={0}
sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}
>
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{title}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{body}
</Typography>
</Paper>
);
}
function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) {
return (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{message}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={onRetry} sx={{ m: 0 }}>
{retryLabel}
</AppButton>
</Paper>
);
}
/** Prev/next pager — rendered only when there is more than one page. */
function Pager({
page,
pageCount,
onPrev,
onNext,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
}) {
const t = useTranslations('payouts');
const locale = useLocale();
if (pageCount <= 1) return null;
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
return (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={onPrev} disabled={page <= 1} sx={{ m: 0 }}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={onNext} disabled={page >= pageCount} sx={{ m: 0 }}>
{t('page_next')}
</AppButton>
</Stack>
);
}
@@ -0,0 +1,220 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Divider, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PriceBreakdown, StatusChip } from '@/components';
import type { StatusKind } from '@/components';
import { nurseBookingDetailPath, ROUTES } from '@/constants';
import { formatIrrToToman, formatShamsiDate, parseIrr } from '@/utils';
import { useNursePayoutDetail } from '@/services/payouts';
import type { PayoutBatchStatus, PayoutStatus } from '@/services/payouts/types';
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
const BATCH_STATUS_KIND: Record<PayoutBatchStatus, StatusKind> = {
draft: 'neutral',
processing: 'info',
partially_failed: 'pending',
completed: 'verified',
failed: 'rejected',
};
/**
* Nurse payout/batch reconciliation detail (f12) one payout expanded: the batch window (holiday-shifted
* server-side), the money decomposition (`gross_earnings clawback_applied = net_amount`, plus the amount
* actually transferred), the masked IBAN + transfer reference, a failed reason (read-only), and the exact
* bookings the payout covered (each deep-linking to its f8 booking detail "this transfer paid for these
* visits"). Money is display-only; the client never recomputes eligibility, dates, or amounts.
*/
export default function NursePayoutDetailPage() {
const t = useTranslations('payouts');
const tc = useTranslations('common');
const locale = useLocale();
const router = useRouter();
const params = useParams<{ id: string }>();
const payoutId = Number(params?.id);
const { data, isLoading, isError } = useNursePayoutDetail(Number.isFinite(payoutId) ? payoutId : undefined);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
startIcon="wallet"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_history')}
</AppButton>
<Typography variant="h5" component="h1">
{t('detail_title')}
</Typography>
</Stack>
{isLoading ? (
<Stack sx={{ gap: 2 }}>
<Skeleton variant="rounded" height={160} />
<Skeleton variant="rounded" height={200} />
</Stack>
) : isError || !data ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_not_found')}
</Typography>
</Paper>
) : (
<>
<Paper elevation={0} sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Stack sx={{ gap: 1.5 }}>
<Stack direction="row" sx={{ justifyContent: 'space-between', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_batch')}
</Typography>
<Stack direction="row" sx={{ gap: 0.75, flexWrap: 'wrap' }}>
<StatusChip status={BATCH_STATUS_KIND[data.batch.status]} label={t(`bstatus_${data.batch.status}`)} />
<StatusChip status={PAYOUT_STATUS_KIND[data.status]} label={t(`pstatus_${data.status}`)} />
</Stack>
</Stack>
<MetaLine label={t('period_label')}>
{formatShamsiDate(data.batch.periodStart, locale)} {formatShamsiDate(data.batch.periodEnd, locale)}
</MetaLine>
{data.batch.processedAt ? (
<MetaLine label={t('processed_on_label')}>{formatShamsiDate(data.batch.processedAt, locale)}</MetaLine>
) : null}
<MetaLine label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{data.maskedIban}
</Box>
</MetaLine>
{data.transferReference ? (
<MetaLine label={t('transfer_reference_label')}>
<Box component="span" dir="ltr">
{data.transferReference}
</Box>
</MetaLine>
) : null}
{data.paidAt ? (
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(data.paidAt, locale)}</MetaLine>
) : null}
</Stack>
</Paper>
{data.status === 'failed' ? (
<Box
sx={{
p: 1.75,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-error)',
}}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('failure_title')}
</Typography>
</Stack>
{data.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {data.failureReason}
</Typography>
) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('failure_hint')}
</Typography>
</Box>
) : null}
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_money_title')}
</Typography>
<PriceBreakdown
rows={
parseIrr(data.clawbackAppliedIrr) > BigInt(0)
? [
{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr },
{
key: 'clawback',
label: t('clawback_applied_label'),
amountIrr: String(-parseIrr(data.clawbackAppliedIrr)),
},
]
: [{ key: 'gross_earnings', label: t('gross_earnings_label'), amountIrr: data.grossEarningsIrr }]
}
totalLabel={t('net_amount_label')}
totalAmountIrr={data.netAmountIrr}
/>
<Stack direction="row" sx={{ justifyContent: 'space-between', px: 0.5 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('amount_transferred_label')}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{formatIrrToToman(data.amountIrr, locale)}
</Typography>
</Stack>
</Stack>
<Stack sx={{ gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('detail_bookings_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('detail_bookings_hint')}
</Typography>
<Paper elevation={0} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
<Stack divider={<Divider />}>
{data.bookings.map((link) => (
<Stack
key={link.bookingId}
direction="row"
sx={{ p: 1.75, gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('booking_ref', { id: link.bookingId })}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')}
</Typography>
</Stack>
<AppButton
variant="text"
color="primary"
endIcon="visits"
onClick={() => router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
))}
</Stack>
</Paper>
</Stack>
</>
)}
</Box>
);
}
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{children}
</Typography>
</Stack>
);
@@ -0,0 +1,102 @@
'use client';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import { Box, Paper, Skeleton, Stack, Typography } from '@mui/material';
import { AppButton, AppIcon, PayoutHistoryRow } from '@/components';
import { nursePayoutDetailPath, ROUTES } from '@/constants';
import { PAYOUTS_PAGE_SIZE } from '@/services/payouts/constants';
import { useNursePayoutHistory } from '@/services/payouts';
/**
* Nurse payout history (f12) a paginated, read-only list of the nurse's `nurse_payouts`, newest first.
* Each row (`PayoutHistoryRow`) shows the net amount transferred, the status chip, the batch window, the
* masked IBAN + transfer reference, and when failed the reason (no retry; that's an admin action). A row
* opens the payout/batch reconciliation detail.
*/
export default function NursePayoutHistoryPage() {
const t = useTranslations('payouts');
const locale = useLocale();
const router = useRouter();
const [page, setPage] = useState(1);
const history = useNursePayoutHistory(page);
const items = history.data?.items ?? [];
const total = history.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAYOUTS_PAGE_SIZE));
const fmt = (n: number) => new Intl.NumberFormat(locale === 'fa' ? 'fa-IR' : 'en-US').format(n);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Stack sx={{ gap: 0.5 }}>
<AppButton
variant="text"
color="primary"
startIcon="earnings"
onClick={() => router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)}
sx={{ m: 0, alignSelf: 'flex-start' }}
>
{t('back_to_earnings')}
</AppButton>
<Typography variant="h5" component="h1">
{t('history_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('history_subtitle')}
</Typography>
</Stack>
{history.isLoading ? (
<Stack sx={{ gap: 2 }}>
{[0, 1].map((k) => (
<Skeleton key={k} variant="rounded" height={180} />
))}
</Stack>
) : history.isError ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>
{t('history_error')}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => history.refetch()} sx={{ m: 0 }}>
{t('retry')}
</AppButton>
</Paper>
) : items.length === 0 ? (
<Paper elevation={0} sx={{ p: 4, textAlign: 'center', border: '1px dashed', borderColor: 'divider', borderRadius: 2 }}>
<AppIcon icon="earnings" size={40} color="var(--bal-text-secondary)" />
<Typography variant="subtitle1" sx={{ fontWeight: 700, mt: 1 }}>
{t('history_empty_title')}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('history_empty_body')}
</Typography>
</Paper>
) : (
<Stack sx={{ gap: 2 }}>
{items.map((item) => (
<PayoutHistoryRow
key={item.id}
item={item}
onOpen={(id) => router.push(`/${locale}${nursePayoutDetailPath(id)}`)}
/>
))}
</Stack>
)}
{pageCount > 1 ? (
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', justifyContent: 'center' }}>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} sx={{ m: 0 }}>
{t('page_prev')}
</AppButton>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('page_indicator', { page: fmt(page), total: fmt(pageCount) })}
</Typography>
<AppButton variant="text" color="primary" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount} sx={{ m: 0 }}>
{t('page_next')}
</AppButton>
</Stack>
) : null}
</Box>
);
}
@@ -0,0 +1,57 @@
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NurseEarningsSummary } from '@/services/payouts/types';
// next-intl mocked to echo keys; locale 'en' so the money util renders ASCII digits for assertions.
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import EarningsBalanceHeader from './EarningsBalanceHeader';
const BASE: NurseEarningsSummary = {
pendingTotalIrr: '4250000',
eligibleTotalIrr: '3400000',
paidTotalIrr: '8500000',
clawbackOutstandingIrr: '1700000',
netPayableBalanceIrr: '5950000',
};
function renderHeader(summary: NurseEarningsSummary) {
return render(
<ThemeProvider>
<EarningsBalanceHeader summary={summary} />
</ThemeProvider>,
);
}
describe('<EarningsBalanceHeader/> component', () => {
it('renders a positive net balance as the payable state with the Toman magnitude', () => {
const { container } = renderHeader(BASE);
expect(container.querySelector('[data-balance-state="payable"]')).toBeInTheDocument();
expect(screen.getByText('balance_net_label')).toBeInTheDocument();
// 5,950,000 IRR ÷ 10 = 595,000 Toman
expect(screen.getByText('595,000')).toBeInTheDocument();
});
it('renders a negative net balance as "owed back" — magnitude only, never a bare minus', () => {
const { container } = renderHeader({ ...BASE, netPayableBalanceIrr: '-4350000' });
expect(container.querySelector('[data-balance-state="owed"]')).toBeInTheDocument();
expect(screen.getByText('balance_owed_label')).toBeInTheDocument();
// The magnitude 435,000 shows; the raw "-435,000" must not.
expect(screen.getByText('435,000')).toBeInTheDocument();
expect(screen.queryByText('-435,000')).not.toBeInTheDocument();
});
it('renders all four buckets with their formatted Toman amounts', () => {
const { container } = renderHeader(BASE);
for (const bucket of ['pending', 'eligible', 'paid', 'clawback']) {
expect(container.querySelector(`[data-bucket="${bucket}"]`)).toBeInTheDocument();
}
expect(screen.getByText('425,000')).toBeInTheDocument(); // pending 4,250,000
expect(screen.getByText('340,000')).toBeInTheDocument(); // eligible 3,400,000
expect(screen.getByText('850,000')).toBeInTheDocument(); // paid lifetime 8,500,000
expect(screen.getByText('170,000')).toBeInTheDocument(); // clawback outstanding 1,700,000
});
});
@@ -0,0 +1,122 @@
'use client';
import { FunctionComponent, useMemo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import AppIcon from '../common/AppIcon';
import { formatIrrToToman, parseIrr } from '@/utils';
import type { NurseEarningsSummary } from '@/services/payouts/types';
export interface EarningsBalanceHeaderProps {
summary: NurseEarningsSummary;
}
/** The four roll-up buckets — each keyed to a semantic token + icon so the states read at a glance. */
const BUCKETS: ReadonlyArray<{
key: 'pending' | 'eligible' | 'paid' | 'clawback';
amountKey: keyof NurseEarningsSummary;
token: string;
icon: string;
}> = [
{ key: 'pending', amountKey: 'pendingTotalIrr', token: 'var(--bal-warning)', icon: 'pending' },
{ key: 'eligible', amountKey: 'eligibleTotalIrr', token: 'var(--bal-info)', icon: 'schedule' },
{ key: 'paid', amountKey: 'paidTotalIrr', token: 'var(--bal-success)', icon: 'verified' },
{ key: 'clawback', amountKey: 'clawbackOutstandingIrr', token: 'var(--bal-error)', icon: 'refresh' },
];
/**
* The nurse's money home header: the **net payable balance** prominently, with the four-bucket breakdown
* (pending / eligible / paid-lifetime / clawback-outstanding) beneath. The net balance is **ledger-derived
* and may be negative** when it is, this renders an explicit **"owed back"** state (a distinct error-toned
* card + the magnitude, never a bare minus sign as if it were a positive amount). Every amount is formatted
* Toman via the money util (integer-safe BigInt); this component only formats, never computes a figure.
* @component EarningsBalanceHeader
*/
const EarningsBalanceHeader: FunctionComponent<EarningsBalanceHeaderProps> = ({ summary }) => {
const t = useTranslations('payouts');
const tc = useTranslations('common');
const locale = useLocale();
const net = useMemo(() => parseIrr(summary.netPayableBalanceIrr), [summary.netPayableBalanceIrr]);
const isOwed = net < BigInt(0);
// Render the magnitude; the "owed back" framing carries the sign in words, never a bare minus.
const magnitude = isOwed ? -net : net;
const accent = isOwed ? 'var(--bal-error)' : 'var(--bal-primary)';
return (
<Stack sx={{ gap: 2 }}>
<Paper
elevation={0}
data-balance-state={isOwed ? 'owed' : 'payable'}
sx={{
p: 3,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '4px solid',
borderInlineStartColor: accent,
}}
>
<Stack sx={{ gap: 0.75 }}>
<Stack direction="row" sx={{ gap: 1, alignItems: 'center' }}>
<AppIcon icon={isOwed ? 'warning' : 'wallet'} size={20} color={accent} />
<Typography variant="subtitle2" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{isOwed ? t('balance_owed_label') : t('balance_net_label')}
</Typography>
</Stack>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'baseline', flexWrap: 'wrap' }}>
<Typography component="span" sx={{ fontWeight: 800, fontSize: '2rem', color: accent }}>
{formatIrrToToman(magnitude, locale)}
</Typography>
<Typography component="span" variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{tc('currency_toman')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{isOwed ? t('balance_owed_hint') : t('balance_net_hint')}
</Typography>
</Stack>
</Paper>
<Box
sx={{
display: 'grid',
gap: 1.5,
gridTemplateColumns: { xs: 'repeat(2, 1fr)', md: 'repeat(4, 1fr)' },
}}
>
{BUCKETS.map((bucket) => (
<Paper
key={bucket.key}
elevation={0}
data-bucket={bucket.key}
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: bucket.token,
}}
>
<Stack sx={{ gap: 0.75 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon={bucket.icon} size={16} color={bucket.token} />
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{t(`bucket_${bucket.key}`)}
</Typography>
</Stack>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{formatIrrToToman(summary[bucket.amountKey], locale)}{' '}
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
{tc('currency_toman')}
</Typography>
</Typography>
</Stack>
</Paper>
))}
</Box>
</Stack>
);
};
export default EarningsBalanceHeader;
@@ -0,0 +1,2 @@
export { default } from './EarningsBalanceHeader';
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
@@ -0,0 +1,101 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import EarningsRow from './EarningsRow';
function makeItem(state: EarningsState, overrides: Partial<NurseEarningsItem> = {}): NurseEarningsItem {
return {
bookingId: 5001,
patientName: 'Test Patient',
scheduledDate: '2026-06-01',
grossPriceIrr: '5000000',
balinyaarCommissionIrr: '750000',
nursePayoutAmount: '4250000',
state,
disputeWindowEndsAt: null,
payoutEligibleAt: null,
paidAt: null,
transferReference: null,
nursePayoutId: null,
batchId: null,
clawbackAppliedIrr: null,
netAmountIrr: null,
...overrides,
};
}
function renderRow(item: NurseEarningsItem, handlers?: { onViewBooking?: jest.Mock; onViewPayout?: jest.Mock }) {
const onViewBooking = handlers?.onViewBooking ?? jest.fn();
const onViewPayout = handlers?.onViewPayout ?? jest.fn();
const utils = render(
<ThemeProvider>
<EarningsRow item={item} onViewBooking={onViewBooking} onViewPayout={onViewPayout} />
</ThemeProvider>,
);
return { ...utils, onViewBooking, onViewPayout };
}
const STATE_KIND: Array<{ state: EarningsState; kind: string }> = [
{ state: 'pending', kind: 'pending' },
{ state: 'eligible', kind: 'info' },
{ state: 'paid', kind: 'verified' },
{ state: 'clawback_applied', kind: 'rejected' },
];
describe('<EarningsRow/> component', () => {
it.each(STATE_KIND)('maps the $state state to the $kind chip kind', ({ state, kind }) => {
const { container } = renderRow(makeItem(state));
expect(container.querySelector(`[data-earnings-state="${state}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
});
it('renders the reconciled payout as the breakdown total (gross commission = payout)', () => {
const { container } = renderRow(makeItem('eligible'));
const total = container.querySelector('[data-row="total"]');
// 4,250,000 IRR ÷ 10 = 425,000 Toman
expect(total?.textContent).toContain('425,000');
});
it('shows the pending dispute-window affordance', () => {
renderRow(makeItem('pending', { disputeWindowEndsAt: '2999-01-01T00:00:00Z' }));
expect(screen.getByText('pending_affordance')).toBeInTheDocument();
});
it('shows the clawback net-explanation block for a clawed-back earning', () => {
// Reconciling fixture: original payout 1,700,000 clawback 1,700,000 = net 0 (PriceBreakdown guard stays silent).
renderRow(
makeItem('clawback_applied', {
grossPriceIrr: '2000000',
balinyaarCommissionIrr: '300000',
nursePayoutAmount: '1700000',
clawbackAppliedIrr: '1700000',
netAmountIrr: '0',
}),
);
expect(screen.getByText('clawback_explainer')).toBeInTheDocument();
expect(screen.getByText('clawback_net')).toBeInTheDocument();
});
it('links a paid earning to its payout detail', () => {
const onViewPayout = jest.fn();
renderRow(
makeItem('paid', { paidAt: '2026-06-10T00:00:00Z', transferReference: 'PAYA-1', nursePayoutId: 9001 }),
{ onViewPayout },
);
fireEvent.click(screen.getByText('view_payout'));
expect(onViewPayout).toHaveBeenCalledWith(9001);
});
it('deep-links every row to its booking detail', () => {
const onViewBooking = jest.fn();
renderRow(makeItem('eligible', { bookingId: 5002 }), { onViewBooking });
fireEvent.click(screen.getByText('view_booking'));
expect(onViewBooking).toHaveBeenCalledWith(5002);
});
});
@@ -0,0 +1,195 @@
'use client';
import { FunctionComponent, useMemo } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Paper, Stack, Typography } from '@mui/material';
import StatusChip, { StatusKind } from '@/components/StatusChip';
import PriceBreakdown from '@/components/PriceBreakdown';
import CountdownTimer from '@/components/CountdownTimer';
import AppButton from '@/components/common/AppButton';
import AppIcon from '@/components/common/AppIcon';
import { formatShamsiDate, parseIrr } from '@/utils';
import type { EarningsState, NurseEarningsItem } from '@/services/payouts/types';
export interface EarningsRowProps {
item: NurseEarningsItem;
/** Deep-link to the f8 nurse booking detail (`/nurse/visits/{bookingId}`) — never rebuilt here. */
onViewBooking: (bookingId: number) => void;
/** Deep-link to this earning's payout detail (paid rows only). */
onViewPayout: (payoutId: number) => void;
}
/** Each earnings state maps to a distinct semantic chip so the four states read instantly (§5). */
const EARNINGS_STATE_KIND: Record<EarningsState, StatusKind> = {
pending: 'pending',
eligible: 'info',
paid: 'verified',
clawback_applied: 'rejected',
};
/**
* One earnings row a completed booking's contribution to the nurse's pay. Shows the booking reference,
* the **three-amount breakdown** framed for the nurse (`gross commission = your payout`, reconciled by
* `PriceBreakdown`), the **earnings-state chip**, and the state-specific affordance:
* - `pending` "in escrow · dispute window open" + a **display-only** countdown off `disputeWindowEndsAt`
* (the server owns eligibility this never gates anything, only renders time remaining);
* - `eligible` "cleared · awaiting the next weekly batch";
* - `paid` `paidAt` (Shamsi) + `transferReference`, links to the payout detail;
* - `clawback_applied` the net explanation (`original clawback = net`) so a lower paid total is explained.
*
* Money is display-only IRR digit-strings through the money util (no float, no client computation). Every
* row deep-links to the booking detail. Read-only: no transfer/retry/eligibility action lives here.
* @component EarningsRow
*/
const EarningsRow: FunctionComponent<EarningsRowProps> = ({ item, onViewBooking, onViewPayout }) => {
const t = useTranslations('payouts');
const locale = useLocale();
// gross + (commission) = payout — the invariant PriceBreakdown reconciles against.
const negativeCommissionIrr = useMemo(
() => String(-parseIrr(item.balinyaarCommissionIrr)),
[item.balinyaarCommissionIrr],
);
return (
<Paper
elevation={0}
data-earnings-state={item.state}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.75 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{t('booking_ref', { id: item.bookingId })}
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.patientName} · {formatShamsiDate(item.scheduledDate, locale)}
</Typography>
</Stack>
<StatusChip status={EARNINGS_STATE_KIND[item.state]} label={t(`estate_${item.state}`)} />
</Stack>
<PriceBreakdown
rows={[
{ key: 'gross', label: t('amount_gross'), amountIrr: item.grossPriceIrr },
{ key: 'commission', label: t('amount_commission'), amountIrr: negativeCommissionIrr },
]}
totalLabel={t('amount_your_payout')}
totalAmountIrr={item.nursePayoutAmount}
/>
{item.state === 'clawback_applied' && item.clawbackAppliedIrr != null && item.netAmountIrr != null ? (
<Stack sx={{ gap: 0.75 }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('clawback_explainer')}
</Typography>
<PriceBreakdown
rows={[
{ key: 'original', label: t('clawback_original'), amountIrr: item.nursePayoutAmount },
{ key: 'clawback', label: t('clawback_amount'), amountIrr: String(-parseIrr(item.clawbackAppliedIrr)) },
]}
totalLabel={t('clawback_net')}
totalAmountIrr={item.netAmountIrr}
/>
</Stack>
) : null}
<StateAffordance item={item} onViewPayout={onViewPayout} />
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
<AppButton
variant="text"
color="primary"
endIcon="visits"
onClick={() => onViewBooking(item.bookingId)}
sx={{ m: 0 }}
>
{t('view_booking')}
</AppButton>
</Stack>
</Stack>
</Paper>
);
};
/** The state-specific explanation block below the money breakdown. */
const StateAffordance: FunctionComponent<{
item: NurseEarningsItem;
onViewPayout: (payoutId: number) => void;
}> = ({ item, onViewPayout }) => {
const t = useTranslations('payouts');
const locale = useLocale();
if (item.state === 'pending') {
return (
<Stack
direction="row"
sx={{ gap: 1.5, alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="lock" size={18} color="var(--bal-warning)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('pending_affordance')}
</Typography>
</Stack>
{item.disputeWindowEndsAt ? (
<CountdownTimer
deadlineIso={item.disputeWindowEndsAt}
label={t('dispute_window_label')}
elapsedText={t('dispute_window_elapsed')}
/>
) : null}
</Stack>
);
}
if (item.state === 'eligible') {
return (
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center' }}>
<AppIcon icon="schedule" size={18} color="var(--bal-info)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t('eligible_affordance')}
</Typography>
</Stack>
);
}
if (item.state === 'paid') {
return (
<Stack sx={{ gap: 0.75 }}>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', flexWrap: 'wrap' }}>
<AppIcon icon="verified" size={18} color="var(--bal-success)" />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{item.paidAt ? t('paid_on', { date: formatShamsiDate(item.paidAt, locale) }) : t('estate_paid')}
</Typography>
</Stack>
{item.transferReference ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('transfer_reference_label')}: {item.transferReference}
</Typography>
) : null}
{item.nursePayoutId != null ? (
<Stack direction="row" sx={{ justifyContent: 'flex-start' }}>
<AppButton
variant="text"
color="primary"
endIcon="wallet"
onClick={() => onViewPayout(item.nursePayoutId as number)}
sx={{ m: 0 }}
>
{t('view_payout')}
</AppButton>
</Stack>
) : null}
</Stack>
);
}
// clawback_applied — the net explanation block above already carries the "why"; nothing more here.
return null;
};
export default EarningsRow;
@@ -0,0 +1,2 @@
export { default } from './EarningsRow';
export type { EarningsRowProps } from './EarningsRow';
@@ -0,0 +1,73 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '../../theme';
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
jest.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
import PayoutHistoryRow from './PayoutHistoryRow';
function makeItem(status: PayoutStatus, overrides: Partial<NursePayoutHistoryItem> = {}): NursePayoutHistoryItem {
return {
id: 9001,
batchId: 7001,
status,
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
maskedIban: 'IR••••4821',
transferReference: status === 'paid' ? 'PAYA-1' : null,
paidAt: status === 'paid' ? '2026-06-10T00:00:00Z' : null,
periodStart: '2026-06-01',
periodEnd: '2026-06-07',
failureReason: status === 'failed' ? 'invalid_sheba' : null,
...overrides,
};
}
function renderRow(item: NursePayoutHistoryItem, onOpen = jest.fn()) {
const utils = render(
<ThemeProvider>
<PayoutHistoryRow item={item} onOpen={onOpen} />
</ThemeProvider>,
);
return { ...utils, onOpen };
}
const STATUS_KIND: Array<{ status: PayoutStatus; kind: string }> = [
{ status: 'pending', kind: 'pending' },
{ status: 'submitted', kind: 'info' },
{ status: 'paid', kind: 'verified' },
{ status: 'failed', kind: 'rejected' },
];
describe('<PayoutHistoryRow/> component', () => {
it.each(STATUS_KIND)('maps the $status payout status to the $kind chip kind', ({ status, kind }) => {
const { container } = renderRow(makeItem(status));
expect(container.querySelector(`[data-payout-status="${status}"]`)).toBeInTheDocument();
expect(container.querySelector(`[data-status="${kind}"]`)).toBeInTheDocument();
});
it('renders the net amount transferred in Toman and the masked IBAN', () => {
renderRow(makeItem('paid'));
expect(screen.getByText('425,000')).toBeInTheDocument(); // 4,250,000 ÷ 10
expect(screen.getByText('IR••••4821')).toBeInTheDocument();
});
it('surfaces a failed payout reason as a read-only banner with no retry control', () => {
renderRow(makeItem('failed'));
expect(screen.getByText('failure_title')).toBeInTheDocument();
expect(screen.getByText(/invalid_sheba/)).toBeInTheDocument();
// No retry affordance for the nurse — only the "view detail" link exists.
expect(screen.queryByText('retry')).not.toBeInTheDocument();
});
it('opens the payout detail on click', () => {
const onOpen = jest.fn();
renderRow(makeItem('paid', { id: 9002 }), onOpen);
fireEvent.click(screen.getByText('view_payout_detail'));
expect(onOpen).toHaveBeenCalledWith(9002);
});
});
@@ -0,0 +1,133 @@
'use client';
import { FunctionComponent, ReactNode } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { Box, Paper, Stack, Typography } from '@mui/material';
import StatusChip, { StatusKind } from '@/components/StatusChip';
import AppButton from '@/components/common/AppButton';
import AppIcon from '@/components/common/AppIcon';
import { formatIrrToToman, formatShamsiDate } from '@/utils';
import type { NursePayoutHistoryItem, PayoutStatus } from '@/services/payouts/types';
export interface PayoutHistoryRowProps {
item: NursePayoutHistoryItem;
/** Open this payout's reconciliation detail. */
onOpen: (payoutId: number) => void;
}
/** Each payout status maps to a distinct semantic chip. `submitted` = handed to the bank rail (in transit). */
const PAYOUT_STATUS_KIND: Record<PayoutStatus, StatusKind> = {
pending: 'pending',
submitted: 'info',
paid: 'verified',
failed: 'rejected',
};
/**
* One payout-history row: the **net amount transferred**, the payout-status chip, the batch window (Shamsi),
* `paidAt` when settled, the **masked IBAN** (last-4, LTR), and the `transferReference` for reconciliation.
* A `failed` payout surfaces its `failureReason` as a **read-only** banner the nurse cannot retry (retry
* is an admin action). Money is display-only through the money util. Deep-links to the payout detail.
* @component PayoutHistoryRow
*/
const PayoutHistoryRow: FunctionComponent<PayoutHistoryRowProps> = ({ item, onOpen }) => {
const t = useTranslations('payouts');
const tc = useTranslations('common');
const locale = useLocale();
const isFailed = item.status === 'failed';
return (
<Paper
elevation={0}
data-payout-status={item.status}
sx={{ p: 2.5, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}
>
<Stack sx={{ gap: 1.5 }}>
<Stack
direction="row"
sx={{ justifyContent: 'space-between', alignItems: 'flex-start', gap: 1.5, flexWrap: 'wrap' }}
>
<Stack sx={{ gap: 0.25, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600 }}>
{t('payout_net_amount')}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>
{formatIrrToToman(item.netAmountIrr, locale)}{' '}
<Typography component="span" variant="caption" sx={{ color: 'text.secondary' }}>
{tc('currency_toman')}
</Typography>
</Typography>
</Stack>
<StatusChip status={PAYOUT_STATUS_KIND[item.status]} label={t(`pstatus_${item.status}`)} />
</Stack>
<Stack sx={{ gap: 0.5 }}>
<MetaLine label={t('period_label')}>
{formatShamsiDate(item.periodStart, locale)} {formatShamsiDate(item.periodEnd, locale)}
</MetaLine>
{item.paidAt ? (
<MetaLine label={t('paid_at_label')}>{formatShamsiDate(item.paidAt, locale)}</MetaLine>
) : null}
<MetaLine label={t('masked_iban_label')}>
<Box component="span" dir="ltr">
{item.maskedIban}
</Box>
</MetaLine>
{item.transferReference ? (
<MetaLine label={t('transfer_reference_label')}>
<Box component="span" dir="ltr">
{item.transferReference}
</Box>
</MetaLine>
) : null}
</Stack>
{isFailed ? (
<Box
sx={{
p: 1.5,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
borderInlineStart: '3px solid',
borderInlineStartColor: 'var(--bal-error)',
}}
>
<Stack direction="row" sx={{ gap: 0.75, alignItems: 'center', mb: 0.5 }}>
<AppIcon icon="warning" size={18} color="var(--bal-error)" />
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{t('failure_title')}
</Typography>
</Stack>
{item.failureReason ? (
<Typography variant="caption" sx={{ color: 'text.secondary' }} dir="ltr">
{t('failure_reason_label')}: {item.failureReason}
</Typography>
) : null}
<Typography variant="body2" sx={{ color: 'text.secondary', mt: 0.5 }}>
{t('failure_hint')}
</Typography>
</Box>
) : null}
<Stack direction="row" sx={{ justifyContent: 'flex-end' }}>
<AppButton variant="text" color="primary" endIcon="wallet" onClick={() => onOpen(item.id)} sx={{ m: 0 }}>
{t('view_payout_detail')}
</AppButton>
</Stack>
</Stack>
</Paper>
);
};
const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => (
<Stack direction="row" sx={{ gap: 1, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap' }}>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{label}
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{children}
</Typography>
</Stack>
);
export default PayoutHistoryRow;
@@ -0,0 +1,2 @@
export { default } from './PayoutHistoryRow';
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
@@ -72,6 +72,8 @@ import EmergencyIcon from '@mui/icons-material/LocalPhoneOutlined';
import LockIcon from '@mui/icons-material/LockOutlined';
// BNPL — the installment-checkout surface (f11/b12): installment plans + repayment schedule
import InstallmentsIcon from '@mui/icons-material/PaymentsOutlined';
// Payouts — the nurse earnings & payout-history surface (f12/b13)
import EarningsIcon from '@mui/icons-material/PaidOutlined';
/**
* List of all available Icon names
@@ -153,4 +155,5 @@ export const ICONS /* Note: Setting type disables property autocomplete :( was -
emergency: EmergencyIcon,
lock: LockIcon,
installments: InstallmentsIcon,
earnings: EarningsIcon,
};
+9
View File
@@ -26,6 +26,9 @@ import EscrowNotice from './EscrowNotice';
import PaymentStatusBadge from './PaymentStatusBadge';
import BnplPlanCard from './BnplPlanCard';
import InstallmentScheduleRow from './InstallmentScheduleRow';
import EarningsBalanceHeader from './EarningsBalanceHeader';
import EarningsRow from './EarningsRow';
import PayoutHistoryRow from './PayoutHistoryRow';
export {
UserInfo,
@@ -54,6 +57,9 @@ export {
PaymentStatusBadge,
BnplPlanCard,
InstallmentScheduleRow,
EarningsBalanceHeader,
EarningsRow,
PayoutHistoryRow,
};
export type { PlaceholderScreenProps } from './PlaceholderScreen';
export type { OtpInputProps } from './OtpInput';
@@ -79,3 +85,6 @@ export type { PriceBreakdownProps, PriceBreakdownRow } from './PriceBreakdown';
export type { PaymentStatusBadgeProps } from './PaymentStatusBadge';
export type { BnplPlanCardProps } from './BnplPlanCard';
export type { InstallmentScheduleRowProps } from './InstallmentScheduleRow';
export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader';
export type { EarningsRowProps } from './EarningsRow';
export type { PayoutHistoryRowProps } from './PayoutHistoryRow';
+12
View File
@@ -56,6 +56,10 @@ export const ROUTES = {
NURSE_VERIFICATION_CREDENTIALS: '/nurse/verification/credentials',
NURSE_VERIFICATION_REVIEW: '/nurse/verification/review',
NURSE_VISITS: '/nurse/visits',
// Earnings & payout history (f12) — the nurse's money home; append `/payouts` for the history list.
NURSE_EARNINGS: '/nurse/earnings',
// Payout history list base — append `/{payoutId}` for the payout/batch reconciliation detail.
NURSE_EARNINGS_PAYOUTS: '/nurse/earnings/payouts',
// Admin / backoffice console
ADMIN: '/admin',
@@ -75,5 +79,13 @@ export const bookingCancelPath = (bookingId: number | string): string =>
export const bookingRefundStatusPath = (bookingId: number | string): string =>
`${ROUTES.BOOKINGS}/${bookingId}/refund_status`;
/** The nurse's payout/batch reconciliation detail (f12) — keyed by the payout id. */
export const nursePayoutDetailPath = (payoutId: number | string): string =>
`${ROUTES.NURSE_EARNINGS_PAYOUTS}/${payoutId}`;
/** The nurse's booking-detail view (f8) — earnings rows + payout booking-links deep-link here. */
export const nurseBookingDetailPath = (bookingId: number | string): string =>
`${ROUTES.NURSE_VISITS}/${bookingId}`;
/** Paths (without locale prefix) that bypass auth in middleware. */
export const PUBLIC_PATHS: string[] = [ROUTES.LOGIN];
+1
View File
@@ -25,6 +25,7 @@ const NurseLayout: FunctionComponent<PropsWithChildren> = ({ children }) => {
{ title: t('bank'), path: ROUTES.NURSE_BANK, icon: 'bank' },
{ title: t('verification'), path: ROUTES.NURSE_VERIFICATION, icon: 'verification' },
{ title: t('visits'), path: ROUTES.NURSE_VISITS, icon: 'visits' },
{ title: t('earnings'), path: ROUTES.NURSE_EARNINGS, icon: 'earnings' },
],
[t]
);
@@ -0,0 +1,93 @@
import { clientFetch } from '@/lib/api/client';
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
import { PAYOUTS_PAGE_SIZE } from '../constants';
import type {
EarningsListParams,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutStatus,
PayoutsApi,
} from '../types';
import type { PageParams } from '@/lib/api/types';
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
/**
* The b13 nurse history payload (`GET nurse_payouts/history` `NursePayoutHistoryDto`). Note it carries
* **no** `failureReason` that field lives on the admin-only `PayoutDto`, so it maps to `null` until
* REQ-025 adds it to the nurse read (a `failed` payout still renders; its reason is just absent).
*/
interface NursePayoutHistoryWire {
id: number;
batchId: number;
status: PayoutStatus;
grossEarningsIrr: string;
clawbackAppliedIrr: string;
netAmountIrr: string;
maskedIban: string;
transferReference: string | null;
paidAt: string | null;
periodStart: string;
periodEnd: string;
}
function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
return {
id: wire.id,
batchId: wire.batchId,
status: wire.status,
grossEarningsIrr: wire.grossEarningsIrr,
clawbackAppliedIrr: wire.clawbackAppliedIrr,
netAmountIrr: wire.netAmountIrr,
maskedIban: wire.maskedIban,
transferReference: wire.transferReference,
paidAt: wire.paidAt,
periodStart: wire.periodStart,
periodEnd: wire.periodEnd,
// REQ-025: the nurse history DTO carries no failure reason yet (it is on the admin PayoutDto).
failureReason: null,
};
}
/**
* Real HTTP implementation of the `PayoutsApi` seam (b13 contract `dev/contracts/domains/payouts.md`,
* swagger `dev/contracts/openapi/swagger.v1.json`). Only `getNursePayoutHistory` maps a **published** nurse
* route; the other three target contract gaps the frontend filed (**REQ-025**), which is why the domain
* stays mock-primary (see `constants.ts`):
* - `getNurseEarningsBalance` the four-bucket summary + signed net balance (no nurse endpoint today).
* - `getNurseEarnings` the per-booking earnings list + money-state (no nurse endpoint today).
* - `getNursePayoutDetail` a nurse-scoped payout detail with batch context + booking links (the b13
* `batches/{id}` detail is **admin-only**).
*
* NOT the primary implementation this phase (`USE_PAYOUTS_MOCK = true`). Routes are `snake_case`; ids come
* from the route; `clientFetch` returns the raw envelope so we `unwrap()`.
*/
export const payoutsClientApi: PayoutsApi = {
getNurseEarningsBalance: async () =>
unwrap(await clientFetch<ApiEnvelope<NurseEarningsSummary>>(`${NURSE_PAYOUTS}/earnings_balance`)),
getNurseEarnings: async (params: EarningsListParams): Promise<Paginated<NurseEarningsItem>> => {
const query = new URLSearchParams();
if (params.state) query.set('state', params.state);
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
return unwrap(
await clientFetch<ApiEnvelope<Paginated<NurseEarningsItem>>>(`${NURSE_PAYOUTS}/earnings?${query.toString()}`),
);
},
getNursePayoutHistory: async (params: PageParams): Promise<Paginated<NursePayoutHistoryItem>> => {
const query = new URLSearchParams();
query.set('page', String(params.page ?? 1));
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
const page = unwrap(
await clientFetch<ApiEnvelope<Paginated<NursePayoutHistoryWire>>>(`${NURSE_PAYOUTS}/history?${query.toString()}`),
);
return { ...page, items: page.items.map(toHistoryItem) };
},
getNursePayoutDetail: async (payoutId: number) =>
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
};
+10
View File
@@ -0,0 +1,10 @@
import { USE_PAYOUTS_MOCK } from '../constants';
import type { PayoutsApi } from '../types';
import { payoutsClientApi } from './clientApi';
import { payoutsMockApi } from './mockApi';
/**
* The selected `PayoutsApi` implementation the single seam the hooks import. Selection is by config
* (`USE_PAYOUTS_MOCK`), never by scattered `if (mock)` checks. Mock-primary this phase (REQ-025 gaps).
*/
export const payoutsApi: PayoutsApi = USE_PAYOUTS_MOCK ? payoutsMockApi : payoutsClientApi;
+339
View File
@@ -0,0 +1,339 @@
import type { Paginated } from '@/lib/api/types';
import type { PageParams } from '@/lib/api/types';
import { MOCK_SCENARIO } from '../constants';
import type {
EarningsListParams,
EarningsState,
NurseEarningsItem,
NurseEarningsSummary,
NursePayoutDetail,
NursePayoutHistoryItem,
PayoutsApi,
} from '../types';
/**
* In-memory `PayoutsApi` **the primary implementation this phase** (b13 serves only the nurse history
* endpoint; the summary, earnings list, and nurse payout detail are REQ-025 gaps see `constants.ts`).
*
* The fixtures are engineered to exercise **every** UI state and to be **money-correct**:
* - all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`);
* - `gross = commission + payout` on every earnings row (the sacred three-amount invariant);
* - a **negative net balance** ("owed back") under the `clawback_heavy` scenario;
* - a `failed` payout (with a `failureReason`) plus `paid` and `submitted` payouts;
* - payout-detail booking links whose `payoutAmountIrr` sum to the payout's `grossEarningsIrr`, and
* `gross clawback = net = amount` the reconciliation the nurse checks.
*
* Booking ids align with the f8 bookings mock seeds (50015004) so an earnings row's "view booking"
* deep-link lands on a real mock detail screen. Timestamps are computed **relative to now** so the pending
* dispute-window countdown always ticks; all money stays an IRR digit-string end-to-end.
*/
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * HOUR_MS;
/** An ISO instant `hours` in the future (+) / past () from now — for dispute-window / paid-at fixtures. */
function isoFromNowHours(hours: number): string {
return new Date(Date.now() + hours * HOUR_MS).toISOString();
}
/** An ISO `YYYY-MM-DD` date `days` ago (batch window / scheduled-date fixtures). */
function isoDateDaysAgo(days: number): string {
return new Date(Date.now() - days * DAY_MS).toISOString().slice(0, 10);
}
const MASKED_IBAN = 'IR••••••••••••••••••4821';
// ── Earnings items (one per state; ids match the f8 bookings mock seeds) ──────────────────────────────
const EARNINGS: NurseEarningsItem[] = [
{
// pending — in escrow, dispute window still open (~40h left, always ticking)
bookingId: 5001,
patientName: 'حاج‌آقا موسوی',
scheduledDate: isoDateDaysAgo(1),
grossPriceIrr: '5000000',
balinyaarCommissionIrr: '750000',
nursePayoutAmount: '4250000',
state: 'pending',
disputeWindowEndsAt: isoFromNowHours(40),
payoutEligibleAt: null,
paidAt: null,
transferReference: null,
nursePayoutId: null,
batchId: null,
clawbackAppliedIrr: null,
netAmountIrr: null,
},
{
// eligible — cleared (dispute window closed), awaiting the next weekly batch
bookingId: 5002,
patientName: 'خانم احمدی',
scheduledDate: isoDateDaysAgo(4),
grossPriceIrr: '4000000',
balinyaarCommissionIrr: '600000',
nursePayoutAmount: '3400000',
state: 'eligible',
disputeWindowEndsAt: null,
payoutEligibleAt: isoFromNowHours(-12),
paidAt: null,
transferReference: null,
nursePayoutId: null,
batchId: null,
clawbackAppliedIrr: null,
netAmountIrr: null,
},
{
// paid — transferred, links to its payout detail
bookingId: 5003,
patientName: 'آقای کریمی',
scheduledDate: isoDateDaysAgo(9),
grossPriceIrr: '5000000',
balinyaarCommissionIrr: '750000',
nursePayoutAmount: '4250000',
state: 'paid',
disputeWindowEndsAt: null,
payoutEligibleAt: isoFromNowHours(-96),
paidAt: isoFromNowHours(-72),
transferReference: 'PAYA-14050412-9001',
nursePayoutId: 9001,
batchId: 7001,
clawbackAppliedIrr: null,
netAmountIrr: null,
},
{
// clawback_applied — booking refunded after payout: original 1,700,000 clawback 1,700,000 = net 0
bookingId: 5004,
patientName: 'خانم صادقی',
scheduledDate: isoDateDaysAgo(14),
grossPriceIrr: '2000000',
balinyaarCommissionIrr: '300000',
nursePayoutAmount: '1700000',
state: 'clawback_applied',
disputeWindowEndsAt: null,
payoutEligibleAt: isoFromNowHours(-240),
paidAt: isoFromNowHours(-216),
transferReference: 'SATNA-14050405-9002',
nursePayoutId: 9002,
batchId: 7000,
clawbackAppliedIrr: '1700000',
netAmountIrr: '0',
},
];
// ── Payout history (newest first; exercises all four PayoutStatus values) ─────────────────────────────
const HISTORY: NursePayoutHistoryItem[] = [
{
id: 9004,
batchId: 7003,
status: 'submitted',
grossEarningsIrr: '2000000',
clawbackAppliedIrr: '0',
netAmountIrr: '2000000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
periodStart: isoDateDaysAgo(7),
periodEnd: isoDateDaysAgo(1),
failureReason: null,
},
{
id: 9003,
batchId: 7002,
status: 'failed',
grossEarningsIrr: '3400000',
clawbackAppliedIrr: '0',
netAmountIrr: '3400000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
failureReason: 'invalid_sheba',
},
{
id: 9001,
batchId: 7001,
status: 'paid',
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'PAYA-14050412-9001',
paidAt: isoFromNowHours(-72),
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
failureReason: null,
},
{
// demonstrates clawback netting in a paid payout: 5,000,000 gross 750,000 clawback = 4,250,000 net
id: 9002,
batchId: 7000,
status: 'paid',
grossEarningsIrr: '5000000',
clawbackAppliedIrr: '750000',
netAmountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'SATNA-14050405-9002',
paidAt: isoFromNowHours(-240),
periodStart: isoDateDaysAgo(21),
periodEnd: isoDateDaysAgo(15),
failureReason: null,
},
];
// ── Payout details (batch context + booking links; every one reconciles) ──────────────────────────────
const DETAILS: Record<number, NursePayoutDetail> = {
9001: {
id: 9001,
batchId: 7001,
status: 'paid',
grossEarningsIrr: '4250000',
clawbackAppliedIrr: '0',
netAmountIrr: '4250000',
amountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'PAYA-14050412-9001',
paidAt: isoFromNowHours(-72),
failureReason: null,
batch: {
id: 7001,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
processingDate: isoDateDaysAgo(7),
status: 'completed',
totalAmount: '4250000',
payoutCount: 1,
processedAt: isoFromNowHours(-72),
},
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
},
9002: {
// Σ booking links (2,750,000 + 2,250,000) = 5,000,000 gross 750,000 clawback = 4,250,000 net/amount
id: 9002,
batchId: 7000,
status: 'paid',
grossEarningsIrr: '5000000',
clawbackAppliedIrr: '750000',
netAmountIrr: '4250000',
amountIrr: '4250000',
maskedIban: MASKED_IBAN,
transferReference: 'SATNA-14050405-9002',
paidAt: isoFromNowHours(-240),
failureReason: null,
batch: {
id: 7000,
periodStart: isoDateDaysAgo(21),
periodEnd: isoDateDaysAgo(15),
processingDate: isoDateDaysAgo(14),
status: 'completed',
totalAmount: '4250000',
payoutCount: 1,
processedAt: isoFromNowHours(-240),
},
bookings: [
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
],
},
9003: {
id: 9003,
batchId: 7002,
status: 'failed',
grossEarningsIrr: '3400000',
clawbackAppliedIrr: '0',
netAmountIrr: '3400000',
amountIrr: '3400000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
failureReason: 'invalid_sheba',
batch: {
id: 7002,
periodStart: isoDateDaysAgo(14),
periodEnd: isoDateDaysAgo(8),
processingDate: isoDateDaysAgo(7),
status: 'partially_failed',
totalAmount: '3400000',
payoutCount: 1,
processedAt: isoFromNowHours(-168),
},
bookings: [{ bookingId: 5005, sessionId: 1, payoutAmountIrr: '3400000' }],
},
9004: {
id: 9004,
batchId: 7003,
status: 'submitted',
grossEarningsIrr: '2000000',
clawbackAppliedIrr: '0',
netAmountIrr: '2000000',
amountIrr: '2000000',
maskedIban: MASKED_IBAN,
transferReference: null,
paidAt: null,
failureReason: null,
batch: {
id: 7003,
periodStart: isoDateDaysAgo(7),
periodEnd: isoDateDaysAgo(1),
processingDate: isoDateDaysAgo(0),
status: 'processing',
totalAmount: '2000000',
payoutCount: 1,
processedAt: null,
},
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '2000000' }],
},
};
/**
* The ledger-derived summary. `paid` is a **lifetime** total; the **net payable balance** is
* `pending + eligible clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with
* BigInt and **not clamped** under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
* the net balance (it already left the ledger).
*/
function buildSummary(): NurseEarningsSummary {
const pending = BigInt(4_250_000);
const eligible = BigInt(3_400_000);
const paid = BigInt(8_500_000); // 9001 (4,250,000) + 9002 net (4,250,000)
const clawbackOutstanding = MOCK_SCENARIO === 'clawback_heavy' ? BigInt(12_000_000) : BigInt(1_700_000);
const net = pending + eligible - clawbackOutstanding;
return {
pendingTotalIrr: String(pending),
eligibleTotalIrr: String(eligible),
paidTotalIrr: String(paid),
clawbackOutstandingIrr: String(clawbackOutstanding),
netPayableBalanceIrr: String(net),
};
}
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.max(1, params.pageSize ?? all.length);
const start = (page - 1) * pageSize;
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
}
/** Small artificial latency so loading skeletons are observable in dev. */
const LATENCY_MS = 250;
function delay<T>(value: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(value), LATENCY_MS));
}
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
export const payoutsMockApi: PayoutsApi = {
getNurseEarningsBalance: async () => delay(buildSummary()),
getNurseEarnings: async (params: EarningsListParams) => {
const filtered = params.state ? EARNINGS.filter((e) => e.state === params.state) : [...EARNINGS];
filtered.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || b.bookingId - a.bookingId);
return delay(paginate(filtered, params));
},
getNursePayoutHistory: async (params: PageParams) => delay(paginate([...HISTORY], params)),
getNursePayoutDetail: async (payoutId: number) => {
const detail = DETAILS[payoutId];
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
return delay(detail);
},
};
+38
View File
@@ -0,0 +1,38 @@
/**
* When true, the payouts domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
* `PayoutsApi` seam.
*
* **Mock is primary this phase.** b13 shipped the nurse read as a **single** endpoint
* (`GET api/v1/nurse_payouts/history` `NursePayoutHistoryDto`). The **four-bucket earnings summary**,
* the **per-booking earnings list + money-state**, and a **nurse-readable payout detail** (batch context +
* booking links + `failureReason`) are contract gaps filed as **REQ-025**. So the whole earnings surface is
* mocked behind this seam with real-shaped fixtures covering **all four earnings states + a negative net
* balance (clawback > earnings) + a `failed` payout** (every UI state exercisable). Flip to `false` once
* REQ-025 lands no hook/component change (only `clientApi.ts`'s three gap methods start returning real data).
*/
export const USE_PAYOUTS_MOCK = true;
/**
* Which mock ledger picture to serve. `standard` = a healthy positive net balance with one of each earnings
* state; `clawback_heavy` = outstanding clawbacks exceed accrued-unpaid earnings so the **net balance goes
* negative** ("owed back") the explicit owed-back UI state (phase §7 step 3). The four earnings rows are
* identical across scenarios; only the ledger-derived summary differs (the summary spans the whole ledger,
* not just the visible page). Flip to demo the negative-balance treatment.
*/
export type PayoutsMockScenario = 'standard' | 'clawback_heavy';
export const MOCK_SCENARIO: PayoutsMockScenario = 'standard';
/**
* Earnings move on a **weekly cadence**, not per second a generous `staleTime` means revisiting the
* screen or switching a tab never needlessly refetches. There are no mutations this phase, so nothing
* invalidates these; the natural staleness is the only refresh trigger.
*/
export const EARNINGS_SUMMARY_STALE_TIME = 5 * 60 * 1000;
export const EARNINGS_LIST_STALE_TIME = 5 * 60 * 1000;
export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000;
/** A settled payout is immutable — its detail is effectively permanent; keep it warm longer. */
export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000;
export const PAYOUTS_GC_TIME = 15 * 60 * 1000;
/** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */
export const PAYOUTS_PAGE_SIZE = 10;
@@ -0,0 +1,21 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import { EARNINGS_LIST_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
import type { EarningsState } from '../types';
/**
* The state-segmented earnings list. The **state filter + page are part of the query key** so each tab and
* each page caches independently switching back to a viewed tab is a cache hit with no network. `state`
* omitted = the unfiltered ("all") tab. `keepPreviousData` holds the prior page/tab visible while the next
* loads, so paging/tabbing never flashes an empty list.
*/
export function useNurseEarnings(state: EarningsState | undefined, page: number) {
return useQuery({
queryKey: payoutKeys.earningsList(state ?? 'all', page),
queryFn: () => payoutsApi.getNurseEarnings({ state, page, pageSize: PAYOUTS_PAGE_SIZE }),
staleTime: EARNINGS_LIST_STALE_TIME,
gcTime: PAYOUTS_GC_TIME,
placeholderData: keepPreviousData,
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import { EARNINGS_SUMMARY_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
/**
* The nurse's four-bucket earnings roll-up + the **signed** net payable balance. Read-only; a generous
* `staleTime` (earnings move weekly, not per second) so revisiting the screen serves from cache. The net
* balance may be negative ("owed back") the header renders the sign; this hook never touches the value.
*/
export function useNurseEarningsBalance() {
return useQuery({
queryKey: payoutKeys.earningsSummary(),
queryFn: () => payoutsApi.getNurseEarningsBalance(),
staleTime: EARNINGS_SUMMARY_STALE_TIME,
gcTime: PAYOUTS_GC_TIME,
});
}
@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import { PAYOUT_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
/**
* One payout expanded the nurse's reconciliation view (money decomposition + batch window + the exact
* bookings covered). A settled payout is immutable, so a long `staleTime`; disabled until a valid id.
*/
export function useNursePayoutDetail(payoutId: number | undefined) {
return useQuery({
queryKey: payoutKeys.detail(payoutId ?? -1),
queryFn: () => payoutsApi.getNursePayoutDetail(payoutId as number),
enabled: payoutId != null && payoutId > 0,
staleTime: PAYOUT_DETAIL_STALE_TIME,
gcTime: PAYOUTS_GC_TIME,
});
}
@@ -0,0 +1,18 @@
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { payoutsApi } from '../apis';
import { payoutKeys } from '../keys';
import { PAYOUT_HISTORY_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
/**
* The nurse's paginated payout history (`nurse_payouts`, newest first). Each page keys separately;
* `keepPreviousData` avoids an empty flash while paging. Read-only, generous `staleTime`.
*/
export function useNursePayoutHistory(page: number) {
return useQuery({
queryKey: payoutKeys.history(page),
queryFn: () => payoutsApi.getNursePayoutHistory({ page, pageSize: PAYOUTS_PAGE_SIZE }),
staleTime: PAYOUT_HISTORY_STALE_TIME,
gcTime: PAYOUTS_GC_TIME,
placeholderData: keepPreviousData,
});
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Payouts domain barrel re-exports **hooks only** (per the `services/{domain}` convention).
* Import types/keys/apis directly from their files when needed.
*/
export { useNurseEarningsBalance } from './hooks/useNurseEarningsBalance';
export { useNurseEarnings } from './hooks/useNurseEarnings';
export { useNursePayoutHistory } from './hooks/useNursePayoutHistory';
export { useNursePayoutDetail } from './hooks/useNursePayoutDetail';
+25
View File
@@ -0,0 +1,25 @@
import type { EarningsState } from './types';
/**
* React Query key factory for the payouts domain (hierarchical, per the `services/{domain}` pattern).
*
* The **state filter and page are part of the key** so switching the earnings tab or paging never refetches
* data already in cache (reverting to a viewed tab is a cache hit); the summary, each earnings tab, each
* history page, and each payout detail all key independently.
*/
export const payoutKeys = {
all: ['payouts'] as const,
earningsSummary: () => [...payoutKeys.all, 'earnings_summary'] as const,
earningsLists: () => [...payoutKeys.all, 'earnings'] as const,
/** `state` is `'all'` for the unfiltered tab, else the `EarningsState`; `page` keeps pages separate. */
earningsList: (state: EarningsState | 'all', page: number) =>
[...payoutKeys.earningsLists(), state, page] as const,
historyLists: () => [...payoutKeys.all, 'history'] as const,
history: (page: number) => [...payoutKeys.historyLists(), page] as const,
details: () => [...payoutKeys.all, 'detail'] as const,
detail: (payoutId: number) => [...payoutKeys.details(), payoutId] as const,
};
+186
View File
@@ -0,0 +1,186 @@
import type { PageParams, Paginated } from '@/lib/api/types';
/**
* Payouts domain the **nurse read** side of the b13 weekly-payout engine ("I did the work, where is
* my money?"). Shapes are derived from the payouts contract
* (`dev/contracts/domains/payouts.md` + `dev/contracts/openapi/swagger.v1.json`).
*
* **The contract only serves a nurse ONE endpoint** (`GET api/v1/nurse_payouts/history`
* `NursePayoutHistoryDto`). The **four-bucket earnings summary**, the **per-booking earnings list with a
* money-state**, and a **nurse-readable payout detail** (batch context + booking links) are contract gaps
* filed as **REQ-025** and mocked behind the `PayoutsApi` seam this phase so the domain is mock-primary
* (see `constants.ts`). When REQ-025 lands, only `apis/clientApi.ts` flips; hooks/screens are unchanged.
*
* Load-bearing money/authority semantics (contract + phase §5):
* - Money is **IRR digit-strings**, integer-safe via the money util; **never** `Number()`/float math.
* Toman is **display-only**. The three booking amounts satisfy `gross = commission + payout`.
* - The nurse **payable balance is derived from the ledger and MAY be negative** ("owed back") model it
* as a **signed** string; **never clamp to zero**. A clawback **nets**, it does not auto-reverse.
* - **Eligibility is server truth** (EVV complete AND `dispute_window_ends_at < now`). The client only ever
* *renders* a cosmetic countdown off `disputeWindowEndsAt`; it **never computes eligibility** client-side.
* - **Read-only:** a nurse never triggers a transfer, retries a payout, or runs a batch (admin actions, f15).
* - The **BNPL provider commission is NEVER a nurse deduction** it does not appear anywhere here; the nurse
* amount is payment-method-invariant (`gross balinyaar_commission`, identical for card vs BNPL).
*
* Enums cross the wire as stable string codes mirrored here as string-literal unions; labels are i18n keys.
*/
/**
* The four money states a nurse cares about, per completed booking. This is a **client display model**
* (there is no single wire enum for it REQ-025); it is derived server-side from the ledger + dispute
* window + payout link:
* - `pending` still in escrow, the dispute window is open (not yet payout-eligible).
* - `eligible` cleared (dispute window closed), awaiting the next weekly batch.
* - `paid` transferred (carries `paidAt` + `transferReference` + the owning `nursePayoutId`).
* - `clawback_applied` a refund-after-payout netted the original earning out of the total.
*/
export type EarningsState = 'pending' | 'eligible' | 'paid' | 'clawback_applied';
/** Stable render/filter order for the state-segmented list. */
export const EARNINGS_STATES: readonly EarningsState[] = [
'pending',
'eligible',
'paid',
'clawback_applied',
] as const;
/**
* `PayoutStatus` (contract enum) the per-payout lifecycle, **forward-only**. `paid` is an irreversible
* transfer with no outgoing edge; `failed` re-submits on an **admin** retry. NB the contract uses
* `submitted` (not "processing"): a payout handed to the bank rail, awaiting settlement.
*/
export type PayoutStatus = 'pending' | 'submitted' | 'paid' | 'failed';
/** `PayoutBatchStatus` (contract enum) — the batch lifecycle a payout's batch context reports. */
export type PayoutBatchStatus = 'draft' | 'processing' | 'partially_failed' | 'completed' | 'failed';
/** A payout is settled (no further movement the nurse can affect) once `paid` or `failed`. */
export function isTerminalPayoutStatus(status: PayoutStatus): boolean {
return status === 'paid' || status === 'failed';
}
/**
* The four-bucket earnings roll-up + the derived net payable balance (REQ-025 the summary shape the
* contract does not yet serve). Every amount is an IRR digit-string.
*/
export interface NurseEarningsSummary {
/** Still in escrow, dispute window open. */
pendingTotalIrr: string;
/** Cleared, awaiting the next weekly batch. */
eligibleTotalIrr: string;
/** Lifetime transferred. */
paidTotalIrr: string;
/** Clawback receivable not yet recovered (netted from a future batch). */
clawbackOutstandingIrr: string;
/**
* **Signed** IRR digit-string what Balinyaar currently owes the nurse (ledger-derived). **MAY be
* negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp.
*/
netPayableBalanceIrr: string;
}
/** One completed booking contributing to earnings (REQ-025). Enough fields to deep-link + explain each state. */
export interface NurseEarningsItem {
/** The booking this earning is for — deep-links to the f8 nurse booking detail. */
bookingId: number;
patientName: string;
/** ISO date `YYYY-MM-DD`. */
scheduledDate: string;
/** The three amounts — IRR digit-strings; `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. */
grossPriceIrr: string;
balinyaarCommissionIrr: string;
nursePayoutAmount: string;
state: EarningsState;
/** Drives the **display-only** pending countdown; `null` once past. The server owns eligibility. */
disputeWindowEndsAt: string | null;
/** Server truth: when this amount became payout-eligible. `null` while pending. Never computed here. */
payoutEligibleAt: string | null;
/** `paid` only: when the transfer landed + its opaque reconciliation reference + the owning payout/batch. */
paidAt: string | null;
transferReference: string | null;
nursePayoutId: number | null;
batchId: number | null;
/** `clawback_applied` only: the clawed-back amount and the resulting net (`= nursePayoutAmount clawback`). */
clawbackAppliedIrr: string | null;
netAmountIrr: string | null;
}
/** One `nurse_payouts` row in the nurse's own history (`NursePayoutHistoryDto` + REQ-025 `failureReason`). */
export interface NursePayoutHistoryItem {
id: number;
batchId: number;
status: PayoutStatus;
/** `netAmountIrr = grossEarningsIrr clawbackAppliedIrr`, guaranteed server-side. */
grossEarningsIrr: string;
clawbackAppliedIrr: string;
netAmountIrr: string;
/** Masked, **last-4 only** — an encrypted field; never a full IBAN. */
maskedIban: string;
transferReference: string | null;
paidAt: string | null;
/** The batch window (holiday-shifted server-side), ISO dates `YYYY-MM-DD`. */
periodStart: string;
periodEnd: string;
/** `failed` only (REQ-025 — the nurse history DTO lacks it today). Read-only; the nurse cannot retry. */
failureReason: string | null;
}
/** A booking a payout covered (`PayoutBookingLinkDto`). One booking appears in exactly one payout, forever. */
export interface NursePayoutBookingLink {
bookingId: number;
sessionId: number | null;
/** This booking's nurse-payout share. Σ over a payout's links = its `grossEarningsIrr`. */
payoutAmountIrr: string;
}
/** The `nurse_payout_batches` context a nurse sees for one of their payouts (subset of `PayoutBatchDto`). */
export interface NursePayoutBatchContext {
id: number;
/** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */
periodStart: string;
periodEnd: string;
processingDate: string;
status: PayoutBatchStatus;
totalAmount: string;
payoutCount: number;
processedAt: string | null;
}
/**
* One payout expanded the nurse's reconciliation view (REQ-025, nurse-scoped analogue of the admin
* `PayoutDto`): the money decomposition, the batch window, and the exact bookings it covered.
*/
export interface NursePayoutDetail {
id: number;
batchId: number;
status: PayoutStatus;
grossEarningsIrr: string;
clawbackAppliedIrr: string;
/** `= grossEarningsIrr clawbackAppliedIrr`. */
netAmountIrr: string;
/** What was actually transferred (`= netAmountIrr` on a clean payout). */
amountIrr: string;
maskedIban: string;
transferReference: string | null;
paidAt: string | null;
failureReason: string | null;
batch: NursePayoutBatchContext;
bookings: NursePayoutBookingLink[];
}
/** `getNurseEarnings` query params — the state filter is part of the query key so each tab caches separately. */
export interface EarningsListParams extends PageParams {
state?: EarningsState;
}
/**
* The payouts API seam the real HTTP client and the in-memory mock both implement this interface;
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks. **All reads; no
* mutations** (a nurse never writes payout state).
*/
export interface PayoutsApi {
getNurseEarningsBalance(): Promise<NurseEarningsSummary>;
getNurseEarnings(params: EarningsListParams): Promise<Paginated<NurseEarningsItem>>;
getNursePayoutHistory(params: PageParams): Promise<Paginated<NursePayoutHistoryItem>>;
getNursePayoutDetail(payoutId: number): Promise<NursePayoutDetail>;
}
@@ -12,6 +12,31 @@ for awareness.
- **Requests filed:** frontend/requests/for-backend.md (yes/no)
-->
## frontend-phase-12-b13 — Nurse earnings & payout history — 2026-07-10
- **Shipped:** the **last money-path frontend phase** — the read-only **nurse earnings** surface. A **new
`services/payouts` domain** (types/keys/constants/apis[client+mock]/4 read-only hooks + hooks-only barrel)
and three nurse screens under the nurse shell: **`/nurse/earnings`** (net payable balance + four-bucket
breakdown + a plain-Persian cadence/dispute-window explainer + a state-segmented earnings list), **`/nurse/
earnings/payouts`** (payout history list), and **`/nurse/earnings/payouts/[id]`** (payout/batch reconciliation
detail — money decomposition + masked IBAN + transfer ref + the exact bookings covered). Three shared tested
composites: **`EarningsBalanceHeader`** (net balance with an explicit **negative "owed back"** state — never a
bare minus), **`EarningsRow`** (three-amount `gross commission = your payout` breakdown via `PriceBreakdown`
+ one of four visually-distinct state chips + state affordance: pending shows a **display-only** dispute-window
countdown reusing `CountdownTimer`), **`PayoutHistoryRow`** (net transferred + status chip + masked IBAN +
failure banner). Deep-links to the f8 booking detail (`/nurse/visits/{id}`); the `earnings` nav item +
`PaidOutlined` icon + a `payouts` i18n namespace (82 keys, both locales in sync) added. **Strictly read-only**
— no transfer/retry/batch/mutation; a failed payout shows its reason with **no retry** (admin action). Money is
IRR digit-strings via the f0 money util (`gross = commission + payout`; the BNPL provider commission never
appears); eligibility/dates/amounts are server truth (never computed client-side).
- **Consumes:** dev/contracts/domains/payouts.md (backend phase b13) — only `GET api/v1/nurse_payouts/history`
maps a live nurse route; the summary/earnings-list/nurse-payout-detail are contract gaps (REQ-025).
- **Mocked client-side:** `services/payouts` via `payoutsMockApi` (`USE_PAYOUTS_MOCK=true`) — self-contained,
money-correct fixtures covering all four earnings states + all four payout statuses + a `failed` payout + a
**negative net balance** (`MOCK_SCENARIO='clawback_heavy'`). Swap is one flip once REQ-025 lands.
- **Gate:** npm run check green · npm run test:ci green (242 tests, +3 suites) · npm run build green with
NEXT_PUBLIC_API_URL set.
- **Requests filed:** frontend/requests/for-backend.md — yes (REQ-025).
## frontend-phase-11-b12 — BNPL installment checkout (D1D5) — 2026-07-10
- **Shipped:** the **alternate checkout branch** off C6 — a **new `services/bnpl` domain** (types/keys/
constants/invalidations/apis[client+mock]/7 hooks + barrel) and the five wireframe screens under the
@@ -379,3 +379,37 @@ delivers fixes in its own change. **Frontend never edits backend code to "fix" a
behind the `services/bnpl` seam (seeded plan + a plan pushed on each settled checkout). When it lands the
swap is one config flip.
- **Status:** open
## REQ-025 — Nurse-read earnings surface: four-bucket balance + per-booking earnings list + nurse payout detail — filed by frontend-phase-12-b13 — 2026-07-10
- **Need:** b13 serves the nurse exactly one endpoint (`GET api/v1/nurse_payouts/history``NursePayoutHistoryDto`).
The f12 earnings screen needs three more **nurse-scoped, read-only** reads (all tenancy-scoped to the caller):
1. **Earnings balance (four buckets + signed net)** — proposed `GET api/v1/nurse_payouts/earnings_balance`
`{ pendingTotalIrr, eligibleTotalIrr, paidTotalIrr, clawbackOutstandingIrr, netPayableBalanceIrr }`.
`netPayableBalanceIrr` is the **ledger-derived, SIGNED** payable balance (may be **negative** = "owed back";
never clamp). `paidTotalIrr` is a lifetime total and does **not** enter the net balance.
2. **Per-booking earnings list + money-state** — proposed
`GET api/v1/nurse_payouts/earnings?state=&page=&pageSize=``PagedResult<NurseEarningsItem>` where
`NurseEarningsItem = { bookingId, patientName, scheduledDate, grossPriceIrr, balinyaarCommissionIrr,
nursePayoutAmount, state: 'pending'|'eligible'|'paid'|'clawback_applied', disputeWindowEndsAt?,
payoutEligibleAt?, paidAt?, transferReference?, nursePayoutId?, batchId?, clawbackAppliedIrr?, netAmountIrr? }`.
`state` is derived **server-side** from `bookings.status` + `dispute_window_ends_at < now` + the payout link +
any clawback (the client must never compute eligibility). Filterable by `state`.
3. **Nurse-readable payout detail (batch context + booking links)** — proposed
`GET api/v1/nurse_payouts/{id}` → a nurse-scoped analogue of the admin `PayoutBatchDetailDto`/`PayoutDto`:
`{ id, batchId, status, grossEarningsIrr, clawbackAppliedIrr, netAmountIrr, amountIrr, maskedIban,
transferReference?, paidAt?, failureReason?, batch: { id, periodStart, periodEnd, processingDate, status,
totalAmount, payoutCount, processedAt? }, bookings: PayoutBookingLinkDto[] }`. The admin `batches/{id}` detail
is admin-only; a nurse needs to reach **their own** payout's batch window + covered bookings for reconciliation.
4. **`failureReason` on the nurse history DTO** — `NursePayoutHistoryDto` has no `failureReason` (it's on the
admin `PayoutDto`). A `failed` payout in the nurse history should carry its reason so the read-only failure
banner can show it (the nurse cannot retry — retry stays an admin action).
- **Why:** the f12 nurse earnings screen (`services/payouts`) renders the net payable balance + four-bucket
breakdown, the state-segmented earnings list (pending/eligible/paid/clawback_applied), and the payout/batch
reconciliation detail. Only `getNursePayoutHistory` maps a live route; the other three are mocked behind the
`PayoutsApi` seam (real-shaped fixtures covering all four states + a negative net balance + a failed payout).
`payoutsClientApi` already targets the proposed slugs — when these land the swap is a single
`USE_PAYOUTS_MOCK=false` flip; no hook/component change.
- **Note (money invariants the server owns):** `gross_price_irr = balinyaar_commission_irr + nurse_payout_amount`;
`net_amount = gross_earnings clawback_applied`; a payout's booking-link `payout_amount_irr` sum = its
`gross_earnings_irr`; the nurse amount is **payment-method-invariant** (BNPL provider commission never deducted).
- **Status:** open
@@ -0,0 +1,105 @@
# Frontend Phase 12 — Nurse earnings & payout history — report
**Date:** 2026-07-10 · **Track:** frontend · **Depends on:** f8 (nurse booking detail) + the b13 payouts
contract · **Status:** complete (mock-primary) · **Gate:** `npm run check` green · `npm run test:ci` green
(242 tests, +3 suites) · `npm run build` green with `NEXT_PUBLIC_API_URL` set.
This is the **last money-path frontend phase** — it closes the loop on the nurse side ("I did the work,
where is my money?") as a strictly **read-only** surface.
## What was built
### `services/payouts` domain (nurse read; the `auth`-service shape)
- **`types.ts`** — string-literal unions + shapes derived from the b13 contract:
- `EarningsState` = `pending | eligible | paid | clawback_applied` — a **client display model** (there is no
single wire enum; it is derived server-side from `bookings.status` + `dispute_window_ends_at` + the payout
link + clawbacks). `PayoutStatus` = the **contract's** `pending | submitted | paid | failed` (NB `submitted`,
not "processing"). `PayoutBatchStatus` = `draft | processing | partially_failed | completed | failed`.
- `NurseEarningsSummary` (four buckets + a **signed** `netPayableBalanceIrr` that may be negative),
`NurseEarningsItem` (per-booking, all four states), `NursePayoutHistoryItem`, `NursePayoutBookingLink`,
`NursePayoutBatchContext`, `NursePayoutDetail`, `EarningsListParams`, and the `PayoutsApi` seam (**all reads,
no mutations**).
- **`keys.ts`** — `payoutKeys` with the **state filter + page baked into the key** (`earningsList(state, page)`,
`history(page)`, `detail(id)`, `earningsSummary()`) so each tab/page caches independently.
- **`constants.ts`** — `USE_PAYOUTS_MOCK = true` (mock-primary), a `MOCK_SCENARIO` toggle
(`standard` | `clawback_heavy`) for the negative-balance demo, generous per-read `staleTime`s (earnings move
weekly), `PAYOUTS_PAGE_SIZE`.
- **`apis/clientApi.ts`** — real HTTP impl: `getNursePayoutHistory` → the **live** `GET api/v1/nurse_payouts/
history`; `getNurseEarningsBalance` / `getNurseEarnings` / `getNursePayoutDetail` target the proposed
REQ-025 slugs. **`apis/mockApi.ts`** — the primary impl (self-contained, money-correct fixtures — see below).
**`apis/index.ts`** — one-line seam selector.
- **`hooks/`** — `useNurseEarningsBalance` / `useNurseEarnings(state, page)` / `useNursePayoutHistory(page)` /
`useNursePayoutDetail(id)` — all read-only `useQuery`, `keepPreviousData` on the paged lists, detail disabled
on an invalid id. **`index.ts`** re-exports hooks only.
### Three shared, tested composites (`src/components/`)
- **`EarningsBalanceHeader`** — the net payable balance + four buckets (pending/eligible/paid-lifetime/clawback
off `--bal-{warning,info,success,error}`). A **negative** net renders an explicit **"owed back"** state
(error-toned card, the magnitude only — never a bare minus).
- **`EarningsRow`** — the three-amount breakdown framed for the nurse (`gross commission = your payout`,
reconciled by `PriceBreakdown`), one of four **visually-distinct** state chips, and the state affordance:
pending → a **display-only** dispute-window countdown (reuses `CountdownTimer`); eligible → awaiting-batch;
paid → `paid_at` + `transferReference` + a payout-detail link; clawback_applied → the `original clawback =
net` explanation. Every row deep-links to the f8 booking detail.
- **`PayoutHistoryRow`** — net transferred + payout-status chip + period + masked IBAN (last-4, `dir="ltr"`) +
transfer ref; a `failed` payout shows its reason as a **read-only** banner (no nurse retry).
### Three nurse screens (nurse shell)
- **`/nurse/earnings`** — balance header + collapsible explainer + a `Tabs` state segment (All + the four states)
→ the earnings list (skeleton / empty / error-with-retry) + a prev/next pager.
- **`/nurse/earnings/payouts`** — the payout history list (same state treatments) → payout detail.
- **`/nurse/earnings/payouts/[id]`** — the reconciliation detail: batch window (Shamsi) + status chips, the money
decomposition (`gross_earnings clawback_applied = net_amount`, plus the amount transferred), masked IBAN +
transfer ref, a failure banner, and the list of covered bookings (each deep-linking to `/nurse/visits/[id]`).
### Wiring
- `earnings` nav item + a `PaidOutlined` icon; `NURSE_EARNINGS` / `NURSE_EARNINGS_PAYOUTS` routes +
`nursePayoutDetailPath` / `nurseBookingDetailPath` helpers; the three components in the `@/components` barrel;
a `payouts` i18n namespace (82 keys, **`en.json` + `fa.json` in sync**) + `nav.earnings`.
## What is now testable and exactly how (phase §7)
Prereq: `npm run dev` (mock is primary, `USE_PAYOUTS_MOCK=true`), sign in as a nurse, open **درآمدها**.
1. **Pending** — booking 5001 shows under **pending / "in escrow · dispute window open"** with a live countdown
off `disputeWindowEndsAt`; it counts into the **pending** bucket, never as paid.
2. **Eligible → paid** — booking 5002 shows **eligible / "awaiting the weekly batch"**; booking 5003 shows
**paid** with `paid_at` (Shamsi) + a `transferReference`, links to the payout detail, and appears in payout
history; the detail lists the exact booking(s) covered.
3. **Clawback nets the total** — booking 5004 shows **clawback_applied** with the `original clawback = net`
explanation (1,700,000 1,700,000 = 0). To see the **negative net balance** ("owed back"), set
`MOCK_SCENARIO = 'clawback_heavy'` in `services/payouts/constants.ts` → the header renders the explicit
owed-back state (magnitude only).
4. **Failed payout** — payout 9003 (in history + at `/nurse/earnings/payouts/9003`) shows `failure_reason`
`invalid_sheba` as a read-only banner; **no retry control** exists for the nurse.
5. **Money correctness** — every row satisfies `gross commission = your payout`; Toman = IRR ÷ 10; no BNPL
provider commission appears; the amount is identical for a card- vs BNPL-funded booking of the same gross.
6. **i18n / RTL / caching**`fa``en` translates + mirrors; switching state tabs / paging shows separate
cache entries (React Query Devtools) and **no refetch** of already-loaded data.
7. **Gate**`npm run check` + `npm run test:ci` pass.
## What is mocked, and how it swaps
`services/payouts` is **mock-primary** (`payoutsMockApi`) because b13 serves the nurse **only**
`GET api/v1/nurse_payouts/history`. The four-bucket **earnings summary**, the per-booking **earnings list +
money-state**, the **nurse-readable payout detail** (batch context + booking links), and `failureReason` on the
history DTO are contract gaps filed as **REQ-025**. `payoutsClientApi` already maps the live history route 1:1
and targets the proposed slugs for the rest — when REQ-025 lands, the swap is a single `USE_PAYOUTS_MOCK=false`
flip with **no hook/component change**. Recorded in `reports/mocks-registry.md` (`PayoutsApi` row).
The mock fixtures are engineered to be **money-correct** (`gross = commission + payout`; `net = gross clawback`;
Σ booking-link amounts = `grossEarnings`; the signed net balance computed with BigInt, not clamped) and to
exercise **every** UI state (all four earnings states, all four payout statuses incl. a `failed` one, a negative
net balance). Booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land on
real mock detail screens.
## Contracts consumed
- `dev/contracts/domains/payouts.md` (b13) — `GET api/v1/nurse_payouts/history` (live) + the enum codes
(`PayoutStatus`, `PayoutBatchStatus`) and the `PayoutDto`/`PayoutBookingLinkDto`/`PayoutBatchDto` shapes the
nurse-read analogues mirror. `api-conventions.md` (`page`/`pageSize`, envelope) + `money-and-types.md` (IRR
integer digit-strings, Toman display-only, UTC → Shamsi).
## Follow-ups (deferred, not built here)
- **REQ-025** (this phase's filing) — the three nurse-read endpoints + `failureReason` on the history DTO.
- **Admin payout console** (create/process/retry batch, eligible-earnings preview, clawback write-off queue) →
DEFERRED to f15.
- **Nurse bank-account add/verify (استعلام شبا) UI** → already the nurse onboarding/profile phase (f2/`/nurse/
bank`). This phase only *displays* the masked `iban_snapshot`; it never edits bank accounts.
- **On-demand / instant withdrawal**, per-nurse payout-frequency settings → DEFERRED product-side (MVP is weekly).
@@ -76,3 +76,4 @@ the frontend can build before the backend phase merges, and swap to the real HTT
| `RefundsApi` | `client/src/services/refunds/apis/mockApi.ts` | **The f10 customer cancel + refund surface** b11 doesn't serve (refunds are admin-only; no customer cancel command, no policy preview, no refund-by-booking, no fee-leg decomposition on the customer status → REQ-019/020/021). Reads the shared **f8 bookings store** (`mockGetBookingForRefund`) to resolve the tier by lead time (`free_24h` >24h / `partial_under_24h` <24h / `customer_no_show` started — client-invented codes → i18n keys) and the per-session refundable(un-started)/locked(completed-and-verified) breakdown, decomposing the refund across the two fee legs via **integer parts-per-10000 BigInt math** (`refundAmount + fee = refundableGross` to the rial). `cancelBooking` flips the booking → `cancelled` (`mockMarkBookingCancelled` stamps the b9 snapshot + cancels only un-started sessions) and creates a refund: **card → `succeeded`** immediately (no ETA); **BNPL → `approved`→`processing`→`succeeded`** over status polls with a `expected_customer_refund_eta` ~10 business days out (Fridays skipped) so the ~710-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 |
| `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1D5)** b12 doesn't serve client-side (b12 is order-centric — eligibility/initiate/status/webhook — and **explicitly does not model the repayment schedule**; no provider/plan options, no wallet installment status → REQ-022/023/024). Reads the frozen request gross from the shared **f7 store** and plays the provider: `getBnplOptions` builds the provider set as **data** (دیجی‌پی 3/6/12 · اسنپ‌پی ۴ · اقساط بالین‌یار; per-plan monthly/down-payment/total via **integer parts-per-10000 BigInt math**, never a hardcoded fee in the UI); `checkEligibility` returns `eligible` unless the national-id last digit is `0` (→`not_eligible`) or the order exceeds `MOCK_CREDIT_CEILING_IRR` (→`ceiling_exceeded`) so both declined paths demo; `getBnplSchedule` serves the down-payment + N-installment rows (last absorbs the remainder → rows sum to total); `issueBnplToken` enforces b12 idempotency (same key → same token; repeat after settle / lapsed window → **`409`**) + a `redirectUrl` into the local provider-handoff harness; `acceptBnplSchedule` on success is the **settle stand-in and reuses the f9 conversion bridge** — flips the request `converted` (`mockMarkBookingRequestConverted`), inserts a **confirmed** booking (`mockInsertConvertedBooking`; a settled BNPL order = a card payment net-of-fee, payout invariant to method), and **seeds a provider-reported Wallet plan**; `getWalletInstallments` serves D5 (seeded active دیجی‌پی ۶-ماهه with paid/due-soon/upcoming rows + each settled checkout's plan). Money = served IRR digit-strings end-to-end (components only format) | `USE_BNPL_MOCK` (`services/bnpl/constants.ts`, default `true`) | Deliver **REQ-022** (options + schedule — real `bnplClientApi` targets `checkout_bnpl/options/{id}` + `checkout_bnpl/schedule/{id}`), **REQ-023** (eligibility accepts the D3 national-id/mobile/consent), **REQ-024** (`checkout_bnpl/wallet_installments` provider-reported status + a customer `bookingId` on the settled order), and make the upstream `bookingRequests` flow real, then set flag `false``checkEligibility`/`issueBnplToken`(`Idempotency-Key`)/`getBnplOrder` already map the live b12 routes 1:1; the settle-on-return reads the order (the real settle is the provider webhook). No hook/component change | 🟡 |
| BNPL provider-handoff harness (test harness) | `client/src/app/[locale]/(private-routes)/(customer)/bookings/checkout/bnpl/gateway/page.tsx` | **Not a product feature** — a dev stand-in for the provider's hosted BNPL page so the initiate → redirect → return round-trip is exercisable without a provider: the mock `redirectUrl` points here, and its pay/cancel 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 b12's `redirectUrl` is the provider's **absolute** URL (the wizard does a full `window.location.assign` for `http(s)`), so this page is never linked; delete it when `USE_BNPL_MOCK` retires. The provider's return deep-link into `/bookings/checkout/bnpl/return` is backend/provider config | 🟡 |
| `PayoutsApi` | `client/src/services/payouts/apis/mockApi.ts` | **The f12 nurse earnings surface** b13 doesn't serve read-side for a nurse (b13's only nurse route is `GET nurse_payouts/history`; the four-bucket **earnings summary**, the per-booking **earnings list + money-state**, and a **nurse-readable payout detail** with batch context + booking links are gaps → **REQ-025**). Self-contained, money-correct fixtures exercising **every** UI state: all four earnings states (`pending`/`eligible`/`paid`/`clawback_applied`; booking ids 50015004 align with the f8 bookings-store seeds so "view booking" deep-links land), all four `PayoutStatus` values in history (`pending`/`submitted`/`paid`/`failed`, incl. a `failed` payout with `failureReason: 'invalid_sheba'` for the read-only failure banner), payout **details that reconcile** (`gross clawback = net = amount`, Σ booking-link amounts = `grossEarnings`), and a **signed net balance** computed with BigInt via a `MOCK_SCENARIO` toggle (`standard` = positive; **`clawback_heavy` = negative "owed back"** for phase §7 step 3). Timestamps are relative to `now` so the pending dispute-window countdown always ticks; money stays IRR digit-strings end-to-end (components only format). `getNurseEarnings` filters by `state` + paginates | `USE_PAYOUTS_MOCK` (`services/payouts/constants.ts`, default `true`) + `MOCK_SCENARIO` in `constants.ts` | Deliver **REQ-025** (earnings_balance + earnings list + nurse `nurse_payouts/{id}` detail + `failureReason` on the history DTO), then set flag `false``payoutsClientApi` already maps the live `GET nurse_payouts/history` 1:1 and targets the proposed slugs for the other three. No hook/component change | 🟡 |