From 6186f54294aa57cf16165d2b7a2e0e2e6a0adab4 Mon Sep 17 00:00:00 2001 From: hamid Date: Fri, 10 Jul 2026 14:48:15 +0330 Subject: [PATCH] frontend phase 12 --- client/CLAUDE.md | 8 +- client/messages/en.json | 85 +++++ client/messages/fa.json | 85 +++++ .../(private-routes)/nurse/earnings/page.tsx | 213 +++++++++++ .../nurse/earnings/payouts/[id]/page.tsx | 220 ++++++++++++ .../nurse/earnings/payouts/page.tsx | 102 ++++++ .../EarningsBalanceHeader.test.tsx | 57 +++ .../EarningsBalanceHeader.tsx | 122 +++++++ .../EarningsBalanceHeader/index.tsx | 2 + .../EarningsRow/EarningsRow.test.tsx | 101 ++++++ .../components/EarningsRow/EarningsRow.tsx | 195 ++++++++++ client/src/components/EarningsRow/index.tsx | 2 + .../PayoutHistoryRow.test.tsx | 73 ++++ .../PayoutHistoryRow/PayoutHistoryRow.tsx | 133 +++++++ .../src/components/PayoutHistoryRow/index.tsx | 2 + .../src/components/common/AppIcon/config.ts | 3 + client/src/components/index.tsx | 9 + client/src/constants/routes.ts | 12 + client/src/layout/NurseLayout.tsx | 1 + client/src/services/payouts/apis/clientApi.ts | 93 +++++ client/src/services/payouts/apis/index.ts | 10 + client/src/services/payouts/apis/mockApi.ts | 339 ++++++++++++++++++ client/src/services/payouts/constants.ts | 38 ++ .../payouts/hooks/useNurseEarnings.ts | 21 ++ .../payouts/hooks/useNurseEarningsBalance.ts | 18 + .../payouts/hooks/useNursePayoutDetail.ts | 18 + .../payouts/hooks/useNursePayoutHistory.ts | 18 + client/src/services/payouts/index.ts | 8 + client/src/services/payouts/keys.ts | 25 ++ client/src/services/payouts/types.ts | 186 ++++++++++ dev/shared-working-context/frontend/STATUS.md | 25 ++ .../frontend/requests/for-backend.md | 34 ++ .../reports/frontend-phase-12-report.md | 105 ++++++ .../reports/mocks-registry.md | 1 + 34 files changed, 2363 insertions(+), 1 deletion(-) create mode 100644 client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx create mode 100644 client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx create mode 100644 client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx create mode 100644 client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx create mode 100644 client/src/components/EarningsBalanceHeader/index.tsx create mode 100644 client/src/components/EarningsRow/EarningsRow.test.tsx create mode 100644 client/src/components/EarningsRow/EarningsRow.tsx create mode 100644 client/src/components/EarningsRow/index.tsx create mode 100644 client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx create mode 100644 client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx create mode 100644 client/src/components/PayoutHistoryRow/index.tsx create mode 100644 client/src/services/payouts/apis/clientApi.ts create mode 100644 client/src/services/payouts/apis/index.ts create mode 100644 client/src/services/payouts/apis/mockApi.ts create mode 100644 client/src/services/payouts/constants.ts create mode 100644 client/src/services/payouts/hooks/useNurseEarnings.ts create mode 100644 client/src/services/payouts/hooks/useNurseEarningsBalance.ts create mode 100644 client/src/services/payouts/hooks/useNursePayoutDetail.ts create mode 100644 client/src/services/payouts/hooks/useNursePayoutHistory.ts create mode 100644 client/src/services/payouts/index.ts create mode 100644 client/src/services/payouts/keys.ts create mode 100644 client/src/services/payouts/types.ts create mode 100644 dev/shared-working-context/reports/frontend-phase-12-report.md diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 54286e2..725416c 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 ~7–10 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=gross−clawback; Σ 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` 7–10-business-day window / `psp_card` / `manual`), and the failed/contact-support copy; consumed by the cancel + refund-status pages and `CancellationPolicyDisclosure`/`RefundStatusCard`/`RefundEtaBanner` - `'bnpl'` — the f11 BNPL installment checkout (D1–D5): 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 D1–D4 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 diff --git a/client/messages/en.json b/client/messages/en.json index e6cee83..18ef60f 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -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." } } diff --git a/client/messages/fa.json b/client/messages/fa.json index ed36890..fa5dcd1 100644 --- a/client/messages/fa.json +++ b/client/messages/fa.json @@ -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": "این واریز بابت این ویزیت‌ها پرداخت شده است." } } diff --git a/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx new file mode 100644 index 0000000..1d6e771 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/earnings/page.tsx @@ -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('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 ( + + + + {t('title')} + + + {t('subtitle')} + + + + {balance.isLoading ? ( + + ) : balance.isError ? ( + balance.refetch()} retryLabel={t('retry')} /> + ) : balance.data ? ( + + ) : null} + + setExplainerOpen((v) => !v)} /> + + + + {TABS.map((value) => ( + + ))} + + + {earnings.isLoading ? ( + + {[0, 1].map((k) => ( + + ))} + + ) : earnings.isError ? ( + earnings.refetch()} retryLabel={t('retry')} /> + ) : items.length === 0 ? ( + + ) : ( + + {items.map((item) => ( + + ))} + + )} + + setPage((p) => Math.max(1, p - 1))} + onNext={() => setPage((p) => Math.min(pageCount, p + 1))} + /> + + + ); +} + +/** 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 ( + + + + + + {t('explainer_title')} + + + + + + + {points.map((key) => ( + + {t(key)} + + ))} + + + + ); +} + +function EmptyPanel({ title, body }: { title: string; body: string }) { + return ( + + + + {title} + + + {body} + + + ); +} + +function ErrorPanel({ message, onRetry, retryLabel }: { message: string; onRetry: () => void; retryLabel: string }) { + return ( + + + {message} + + + {retryLabel} + + + ); +} + +/** 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 ( + + + {t('page_prev')} + + + {t('page_indicator', { page: fmt(page), total: fmt(pageCount) })} + + = pageCount} sx={{ m: 0 }}> + {t('page_next')} + + + ); +} diff --git a/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx new file mode 100644 index 0000000..70c6526 --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/[id]/page.tsx @@ -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 = { + pending: 'pending', + submitted: 'info', + paid: 'verified', + failed: 'rejected', +}; + +const BATCH_STATUS_KIND: Record = { + 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 ( + + + router.push(`/${locale}${ROUTES.NURSE_EARNINGS_PAYOUTS}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('back_to_history')} + + + {t('detail_title')} + + + + {isLoading ? ( + + + + + ) : isError || !data ? ( + + + {t('detail_not_found')} + + + ) : ( + <> + + + + + {t('detail_batch')} + + + + + + + + {formatShamsiDate(data.batch.periodStart, locale)} – {formatShamsiDate(data.batch.periodEnd, locale)} + + {data.batch.processedAt ? ( + {formatShamsiDate(data.batch.processedAt, locale)} + ) : null} + + + {data.maskedIban} + + + {data.transferReference ? ( + + + {data.transferReference} + + + ) : null} + {data.paidAt ? ( + {formatShamsiDate(data.paidAt, locale)} + ) : null} + + + + {data.status === 'failed' ? ( + + + + + {t('failure_title')} + + + {data.failureReason ? ( + + {t('failure_reason_label')}: {data.failureReason} + + ) : null} + + {t('failure_hint')} + + + ) : null} + + + + {t('detail_money_title')} + + 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} + /> + + + {t('amount_transferred_label')} + + + {formatIrrToToman(data.amountIrr, locale)} + + + + + + + {t('detail_bookings_title')} + + + {t('detail_bookings_hint')} + + + }> + {data.bookings.map((link) => ( + + + + {t('booking_ref', { id: link.bookingId })} + + + {formatIrrToToman(link.payoutAmountIrr, locale)} {tc('currency_toman')} + + + router.push(`/${locale}${nurseBookingDetailPath(link.bookingId)}`)} + sx={{ m: 0 }} + > + {t('view_booking')} + + + ))} + + + + + )} + + ); +} + +const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => ( + + + {label} + + + {children} + + +); diff --git a/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx new file mode 100644 index 0000000..08512fc --- /dev/null +++ b/client/src/app/[locale]/(private-routes)/nurse/earnings/payouts/page.tsx @@ -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 ( + + + router.push(`/${locale}${ROUTES.NURSE_EARNINGS}`)} + sx={{ m: 0, alignSelf: 'flex-start' }} + > + {t('back_to_earnings')} + + + {t('history_title')} + + + {t('history_subtitle')} + + + + {history.isLoading ? ( + + {[0, 1].map((k) => ( + + ))} + + ) : history.isError ? ( + + + {t('history_error')} + + history.refetch()} sx={{ m: 0 }}> + {t('retry')} + + + ) : items.length === 0 ? ( + + + + {t('history_empty_title')} + + + {t('history_empty_body')} + + + ) : ( + + {items.map((item) => ( + router.push(`/${locale}${nursePayoutDetailPath(id)}`)} + /> + ))} + + )} + + {pageCount > 1 ? ( + + setPage((p) => Math.max(1, p - 1))} disabled={page <= 1} sx={{ m: 0 }}> + {t('page_prev')} + + + {t('page_indicator', { page: fmt(page), total: fmt(pageCount) })} + + setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount} sx={{ m: 0 }}> + {t('page_next')} + + + ) : null} + + ); +} diff --git a/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx new file mode 100644 index 0000000..44857ba --- /dev/null +++ b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.test.tsx @@ -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( + + + , + ); +} + +describe(' 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 + }); +}); diff --git a/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx new file mode 100644 index 0000000..d0b6aed --- /dev/null +++ b/client/src/components/EarningsBalanceHeader/EarningsBalanceHeader.tsx @@ -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 = ({ 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 ( + + + + + + + {isOwed ? t('balance_owed_label') : t('balance_net_label')} + + + + + {formatIrrToToman(magnitude, locale)} + + + {tc('currency_toman')} + + + + {isOwed ? t('balance_owed_hint') : t('balance_net_hint')} + + + + + + {BUCKETS.map((bucket) => ( + + + + + + {t(`bucket_${bucket.key}`)} + + + + {formatIrrToToman(summary[bucket.amountKey], locale)}{' '} + + {tc('currency_toman')} + + + + + ))} + + + ); +}; + +export default EarningsBalanceHeader; diff --git a/client/src/components/EarningsBalanceHeader/index.tsx b/client/src/components/EarningsBalanceHeader/index.tsx new file mode 100644 index 0000000..a4788f5 --- /dev/null +++ b/client/src/components/EarningsBalanceHeader/index.tsx @@ -0,0 +1,2 @@ +export { default } from './EarningsBalanceHeader'; +export type { EarningsBalanceHeaderProps } from './EarningsBalanceHeader'; diff --git a/client/src/components/EarningsRow/EarningsRow.test.tsx b/client/src/components/EarningsRow/EarningsRow.test.tsx new file mode 100644 index 0000000..09ac8f4 --- /dev/null +++ b/client/src/components/EarningsRow/EarningsRow.test.tsx @@ -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 { + 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( + + + , + ); + 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(' 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); + }); +}); diff --git a/client/src/components/EarningsRow/EarningsRow.tsx b/client/src/components/EarningsRow/EarningsRow.tsx new file mode 100644 index 0000000..cfc1427 --- /dev/null +++ b/client/src/components/EarningsRow/EarningsRow.tsx @@ -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 = { + 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 = ({ 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 ( + + + + + + {t('booking_ref', { id: item.bookingId })} + + + {item.patientName} · {formatShamsiDate(item.scheduledDate, locale)} + + + + + + + + {item.state === 'clawback_applied' && item.clawbackAppliedIrr != null && item.netAmountIrr != null ? ( + + + {t('clawback_explainer')} + + + + ) : null} + + + + + onViewBooking(item.bookingId)} + sx={{ m: 0 }} + > + {t('view_booking')} + + + + + ); +}; + +/** 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 ( + + + + + {t('pending_affordance')} + + + {item.disputeWindowEndsAt ? ( + + ) : null} + + ); + } + + if (item.state === 'eligible') { + return ( + + + + {t('eligible_affordance')} + + + ); + } + + if (item.state === 'paid') { + return ( + + + + + {item.paidAt ? t('paid_on', { date: formatShamsiDate(item.paidAt, locale) }) : t('estate_paid')} + + + {item.transferReference ? ( + + {t('transfer_reference_label')}: {item.transferReference} + + ) : null} + {item.nursePayoutId != null ? ( + + onViewPayout(item.nursePayoutId as number)} + sx={{ m: 0 }} + > + {t('view_payout')} + + + ) : null} + + ); + } + + // clawback_applied — the net explanation block above already carries the "why"; nothing more here. + return null; +}; + +export default EarningsRow; diff --git a/client/src/components/EarningsRow/index.tsx b/client/src/components/EarningsRow/index.tsx new file mode 100644 index 0000000..c642467 --- /dev/null +++ b/client/src/components/EarningsRow/index.tsx @@ -0,0 +1,2 @@ +export { default } from './EarningsRow'; +export type { EarningsRowProps } from './EarningsRow'; diff --git a/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx new file mode 100644 index 0000000..eaef34a --- /dev/null +++ b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.test.tsx @@ -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 { + 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( + + + , + ); + 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(' 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); + }); +}); diff --git a/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx new file mode 100644 index 0000000..2b33924 --- /dev/null +++ b/client/src/components/PayoutHistoryRow/PayoutHistoryRow.tsx @@ -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 = { + 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 = ({ item, onOpen }) => { + const t = useTranslations('payouts'); + const tc = useTranslations('common'); + const locale = useLocale(); + const isFailed = item.status === 'failed'; + + return ( + + + + + + {t('payout_net_amount')} + + + {formatIrrToToman(item.netAmountIrr, locale)}{' '} + + {tc('currency_toman')} + + + + + + + + + {formatShamsiDate(item.periodStart, locale)} – {formatShamsiDate(item.periodEnd, locale)} + + {item.paidAt ? ( + {formatShamsiDate(item.paidAt, locale)} + ) : null} + + + {item.maskedIban} + + + {item.transferReference ? ( + + + {item.transferReference} + + + ) : null} + + + {isFailed ? ( + + + + + {t('failure_title')} + + + {item.failureReason ? ( + + {t('failure_reason_label')}: {item.failureReason} + + ) : null} + + {t('failure_hint')} + + + ) : null} + + + onOpen(item.id)} sx={{ m: 0 }}> + {t('view_payout_detail')} + + + + + ); +}; + +const MetaLine: FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => ( + + + {label} + + + {children} + + +); + +export default PayoutHistoryRow; diff --git a/client/src/components/PayoutHistoryRow/index.tsx b/client/src/components/PayoutHistoryRow/index.tsx new file mode 100644 index 0000000..369fa42 --- /dev/null +++ b/client/src/components/PayoutHistoryRow/index.tsx @@ -0,0 +1,2 @@ +export { default } from './PayoutHistoryRow'; +export type { PayoutHistoryRowProps } from './PayoutHistoryRow'; diff --git a/client/src/components/common/AppIcon/config.ts b/client/src/components/common/AppIcon/config.ts index 58a0510..2fa9cd8 100644 --- a/client/src/components/common/AppIcon/config.ts +++ b/client/src/components/common/AppIcon/config.ts @@ -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, }; diff --git a/client/src/components/index.tsx b/client/src/components/index.tsx index 5574e36..2c95b35 100644 --- a/client/src/components/index.tsx +++ b/client/src/components/index.tsx @@ -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'; diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index ef151b7..69a0e47 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -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]; diff --git a/client/src/layout/NurseLayout.tsx b/client/src/layout/NurseLayout.tsx index edf8964..22ac51d 100644 --- a/client/src/layout/NurseLayout.tsx +++ b/client/src/layout/NurseLayout.tsx @@ -25,6 +25,7 @@ const NurseLayout: FunctionComponent = ({ 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] ); diff --git a/client/src/services/payouts/apis/clientApi.ts b/client/src/services/payouts/apis/clientApi.ts new file mode 100644 index 0000000..006af85 --- /dev/null +++ b/client/src/services/payouts/apis/clientApi.ts @@ -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>(`${NURSE_PAYOUTS}/earnings_balance`)), + + getNurseEarnings: async (params: EarningsListParams): Promise> => { + 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>>(`${NURSE_PAYOUTS}/earnings?${query.toString()}`), + ); + }, + + getNursePayoutHistory: async (params: PageParams): Promise> => { + 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>>(`${NURSE_PAYOUTS}/history?${query.toString()}`), + ); + return { ...page, items: page.items.map(toHistoryItem) }; + }, + + getNursePayoutDetail: async (payoutId: number) => + unwrap(await clientFetch>(`${NURSE_PAYOUTS}/${payoutId}`)), +}; diff --git a/client/src/services/payouts/apis/index.ts b/client/src/services/payouts/apis/index.ts new file mode 100644 index 0000000..d957947 --- /dev/null +++ b/client/src/services/payouts/apis/index.ts @@ -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; diff --git a/client/src/services/payouts/apis/mockApi.ts b/client/src/services/payouts/apis/mockApi.ts new file mode 100644 index 0000000..633090c --- /dev/null +++ b/client/src/services/payouts/apis/mockApi.ts @@ -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 (5001–5004) 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 = { + 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(all: T[], params: PageParams): Paginated { + 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(value: T): Promise { + return new Promise((resolve) => setTimeout(() => resolve(value), LATENCY_MS)); +} + +const STATE_ORDER: Record = { 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); + }, +}; diff --git a/client/src/services/payouts/constants.ts b/client/src/services/payouts/constants.ts new file mode 100644 index 0000000..5919bb9 --- /dev/null +++ b/client/src/services/payouts/constants.ts @@ -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; diff --git a/client/src/services/payouts/hooks/useNurseEarnings.ts b/client/src/services/payouts/hooks/useNurseEarnings.ts new file mode 100644 index 0000000..bb9959f --- /dev/null +++ b/client/src/services/payouts/hooks/useNurseEarnings.ts @@ -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, + }); +} diff --git a/client/src/services/payouts/hooks/useNurseEarningsBalance.ts b/client/src/services/payouts/hooks/useNurseEarningsBalance.ts new file mode 100644 index 0000000..a9a0998 --- /dev/null +++ b/client/src/services/payouts/hooks/useNurseEarningsBalance.ts @@ -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, + }); +} diff --git a/client/src/services/payouts/hooks/useNursePayoutDetail.ts b/client/src/services/payouts/hooks/useNursePayoutDetail.ts new file mode 100644 index 0000000..b961864 --- /dev/null +++ b/client/src/services/payouts/hooks/useNursePayoutDetail.ts @@ -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, + }); +} diff --git a/client/src/services/payouts/hooks/useNursePayoutHistory.ts b/client/src/services/payouts/hooks/useNursePayoutHistory.ts new file mode 100644 index 0000000..90b041e --- /dev/null +++ b/client/src/services/payouts/hooks/useNursePayoutHistory.ts @@ -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, + }); +} diff --git a/client/src/services/payouts/index.ts b/client/src/services/payouts/index.ts new file mode 100644 index 0000000..87dab43 --- /dev/null +++ b/client/src/services/payouts/index.ts @@ -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'; diff --git a/client/src/services/payouts/keys.ts b/client/src/services/payouts/keys.ts new file mode 100644 index 0000000..559b915 --- /dev/null +++ b/client/src/services/payouts/keys.ts @@ -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, +}; diff --git a/client/src/services/payouts/types.ts b/client/src/services/payouts/types.ts new file mode 100644 index 0000000..9774981 --- /dev/null +++ b/client/src/services/payouts/types.ts @@ -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; + getNurseEarnings(params: EarningsListParams): Promise>; + getNursePayoutHistory(params: PageParams): Promise>; + getNursePayoutDetail(payoutId: number): Promise; +} diff --git a/dev/shared-working-context/frontend/STATUS.md b/dev/shared-working-context/frontend/STATUS.md index 4eb0e89..0a9d59b 100644 --- a/dev/shared-working-context/frontend/STATUS.md +++ b/dev/shared-working-context/frontend/STATUS.md @@ -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 (D1–D5) — 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 diff --git a/dev/shared-working-context/frontend/requests/for-backend.md b/dev/shared-working-context/frontend/requests/for-backend.md index 943ab62..178852d 100644 --- a/dev/shared-working-context/frontend/requests/for-backend.md +++ b/dev/shared-working-context/frontend/requests/for-backend.md @@ -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` 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 diff --git a/dev/shared-working-context/reports/frontend-phase-12-report.md b/dev/shared-working-context/reports/frontend-phase-12-report.md new file mode 100644 index 0000000..8446f6f --- /dev/null +++ b/dev/shared-working-context/reports/frontend-phase-12-report.md @@ -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 5001–5004 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). diff --git a/dev/shared-working-context/reports/mocks-registry.md b/dev/shared-working-context/reports/mocks-registry.md index 7651413..5328b5a 100644 --- a/dev/shared-working-context/reports/mocks-registry.md +++ b/dev/shared-working-context/reports/mocks-registry.md @@ -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 ~7–10-day banner renders. Enforces the outside-policy **`409`** (already-cancelled / nothing-refundable / non-refundable session). Seeds a **`failed`** refund on the cancelled booking 5004 so the contact-support state demos; booking 5002 is pinned to the BNPL channel; booking 5003 (new, mid-engagement) demos the mixed refundable/locked breakdown. Also adds bookings-store seeds 5003/5004 + the two non-seam exports | `USE_REFUNDS_MOCK` (`services/refunds/constants.ts`, default `true`) | Deliver **REQ-019** (customer cancel command — the real `refundsClientApi.cancelBooking` already targets `POST bookings/{id}/cancel`) + **REQ-020** (cancellation-policy preview → `GET bookings/{id}/cancellation_policy`, incl. the canonical `cancellation_policy_code` set) + **REQ-021** (`GET refunds/by_booking/{id}` + the decomposition fields on the customer `refunds/{id}/status`), then set flag `false` — the real client maps the published `refunds/{id}/status` 1:1 and targets the proposed slugs for the rest. No hook/component change | 🟡 | | `BnplApi` | `client/src/services/bnpl/apis/mockApi.ts` | **The f11 BNPL installment checkout (D1–D5)** 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 5001–5004 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 | 🟡 |