frontend phase 12
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
EarningsListParams,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutStatus,
|
||||
PayoutsApi,
|
||||
} from '../types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
|
||||
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
|
||||
|
||||
/**
|
||||
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
|
||||
* **no** `failureReason` — that field lives on the admin-only `PayoutDto`, so it maps to `null` until
|
||||
* REQ-025 adds it to the nurse read (a `failed` payout still renders; its reason is just absent).
|
||||
*/
|
||||
interface NursePayoutHistoryWire {
|
||||
id: number;
|
||||
batchId: number;
|
||||
status: PayoutStatus;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
maskedIban: string;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
}
|
||||
|
||||
function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
|
||||
return {
|
||||
id: wire.id,
|
||||
batchId: wire.batchId,
|
||||
status: wire.status,
|
||||
grossEarningsIrr: wire.grossEarningsIrr,
|
||||
clawbackAppliedIrr: wire.clawbackAppliedIrr,
|
||||
netAmountIrr: wire.netAmountIrr,
|
||||
maskedIban: wire.maskedIban,
|
||||
transferReference: wire.transferReference,
|
||||
paidAt: wire.paidAt,
|
||||
periodStart: wire.periodStart,
|
||||
periodEnd: wire.periodEnd,
|
||||
// REQ-025: the nurse history DTO carries no failure reason yet (it is on the admin PayoutDto).
|
||||
failureReason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Real HTTP implementation of the `PayoutsApi` seam (b13 contract `dev/contracts/domains/payouts.md`,
|
||||
* swagger `dev/contracts/openapi/swagger.v1.json`). Only `getNursePayoutHistory` maps a **published** nurse
|
||||
* route; the other three target contract gaps the frontend filed (**REQ-025**), which is why the domain
|
||||
* stays mock-primary (see `constants.ts`):
|
||||
* - `getNurseEarningsBalance` → the four-bucket summary + signed net balance (no nurse endpoint today).
|
||||
* - `getNurseEarnings` → the per-booking earnings list + money-state (no nurse endpoint today).
|
||||
* - `getNursePayoutDetail` → a nurse-scoped payout detail with batch context + booking links (the b13
|
||||
* `batches/{id}` detail is **admin-only**).
|
||||
*
|
||||
* NOT the primary implementation this phase (`USE_PAYOUTS_MOCK = true`). Routes are `snake_case`; ids come
|
||||
* from the route; `clientFetch` returns the raw envelope so we `unwrap()`.
|
||||
*/
|
||||
export const payoutsClientApi: PayoutsApi = {
|
||||
getNurseEarningsBalance: async () =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NurseEarningsSummary>>(`${NURSE_PAYOUTS}/earnings_balance`)),
|
||||
|
||||
getNurseEarnings: async (params: EarningsListParams): Promise<Paginated<NurseEarningsItem>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (params.state) query.set('state', params.state);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||
return unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<NurseEarningsItem>>>(`${NURSE_PAYOUTS}/earnings?${query.toString()}`),
|
||||
);
|
||||
},
|
||||
|
||||
getNursePayoutHistory: async (params: PageParams): Promise<Paginated<NursePayoutHistoryItem>> => {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<NursePayoutHistoryWire>>>(`${NURSE_PAYOUTS}/history?${query.toString()}`),
|
||||
);
|
||||
return { ...page, items: page.items.map(toHistoryItem) };
|
||||
},
|
||||
|
||||
getNursePayoutDetail: async (payoutId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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<number, NursePayoutDetail> = {
|
||||
9001: {
|
||||
id: 9001,
|
||||
batchId: 7001,
|
||||
status: 'paid',
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: 'PAYA-14050412-9001',
|
||||
paidAt: isoFromNowHours(-72),
|
||||
failureReason: null,
|
||||
batch: {
|
||||
id: 7001,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(7),
|
||||
status: 'completed',
|
||||
totalAmount: '4250000',
|
||||
payoutCount: 1,
|
||||
processedAt: isoFromNowHours(-72),
|
||||
},
|
||||
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||
},
|
||||
9002: {
|
||||
// Σ booking links (2,750,000 + 2,250,000) = 5,000,000 gross − 750,000 clawback = 4,250,000 net/amount
|
||||
id: 9002,
|
||||
batchId: 7000,
|
||||
status: 'paid',
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '750000',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: 'SATNA-14050405-9002',
|
||||
paidAt: isoFromNowHours(-240),
|
||||
failureReason: null,
|
||||
batch: {
|
||||
id: 7000,
|
||||
periodStart: isoDateDaysAgo(21),
|
||||
periodEnd: isoDateDaysAgo(15),
|
||||
processingDate: isoDateDaysAgo(14),
|
||||
status: 'completed',
|
||||
totalAmount: '4250000',
|
||||
payoutCount: 1,
|
||||
processedAt: isoFromNowHours(-240),
|
||||
},
|
||||
bookings: [
|
||||
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
|
||||
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
|
||||
],
|
||||
},
|
||||
9003: {
|
||||
id: 9003,
|
||||
batchId: 7002,
|
||||
status: 'failed',
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
amountIrr: '3400000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: 'invalid_sheba',
|
||||
batch: {
|
||||
id: 7002,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(7),
|
||||
status: 'partially_failed',
|
||||
totalAmount: '3400000',
|
||||
payoutCount: 1,
|
||||
processedAt: isoFromNowHours(-168),
|
||||
},
|
||||
bookings: [{ bookingId: 5005, sessionId: 1, payoutAmountIrr: '3400000' }],
|
||||
},
|
||||
9004: {
|
||||
id: 9004,
|
||||
batchId: 7003,
|
||||
status: 'submitted',
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
amountIrr: '2000000',
|
||||
maskedIban: MASKED_IBAN,
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: null,
|
||||
batch: {
|
||||
id: 7003,
|
||||
periodStart: isoDateDaysAgo(7),
|
||||
periodEnd: isoDateDaysAgo(1),
|
||||
processingDate: isoDateDaysAgo(0),
|
||||
status: 'processing',
|
||||
totalAmount: '2000000',
|
||||
payoutCount: 1,
|
||||
processedAt: null,
|
||||
},
|
||||
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '2000000' }],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ledger-derived summary. `paid` is a **lifetime** total; the **net payable balance** is
|
||||
* `pending + eligible − clawbackOutstanding` (accrued-unpaid earnings minus receivables), computed with
|
||||
* BigInt and **not clamped** — under `clawback_heavy` it goes negative ("owed back"). `paid` never enters
|
||||
* the net balance (it already left the ledger).
|
||||
*/
|
||||
function buildSummary(): NurseEarningsSummary {
|
||||
const pending = BigInt(4_250_000);
|
||||
const eligible = BigInt(3_400_000);
|
||||
const paid = BigInt(8_500_000); // 9001 (4,250,000) + 9002 net (4,250,000)
|
||||
const clawbackOutstanding = MOCK_SCENARIO === 'clawback_heavy' ? BigInt(12_000_000) : BigInt(1_700_000);
|
||||
const net = pending + eligible - clawbackOutstanding;
|
||||
return {
|
||||
pendingTotalIrr: String(pending),
|
||||
eligibleTotalIrr: String(eligible),
|
||||
paidTotalIrr: String(paid),
|
||||
clawbackOutstandingIrr: String(clawbackOutstanding),
|
||||
netPayableBalanceIrr: String(net),
|
||||
};
|
||||
}
|
||||
|
||||
function paginate<T>(all: T[], params: PageParams): Paginated<T> {
|
||||
const page = Math.max(1, params.page ?? 1);
|
||||
const pageSize = Math.max(1, params.pageSize ?? all.length);
|
||||
const start = (page - 1) * pageSize;
|
||||
return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
|
||||
}
|
||||
|
||||
/** Small artificial latency so loading skeletons are observable in dev. */
|
||||
const LATENCY_MS = 250;
|
||||
function delay<T>(value: T): Promise<T> {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(value), LATENCY_MS));
|
||||
}
|
||||
|
||||
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
|
||||
|
||||
export const payoutsMockApi: PayoutsApi = {
|
||||
getNurseEarningsBalance: async () => delay(buildSummary()),
|
||||
|
||||
getNurseEarnings: async (params: EarningsListParams) => {
|
||||
const filtered = params.state ? EARNINGS.filter((e) => e.state === params.state) : [...EARNINGS];
|
||||
filtered.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state] || b.bookingId - a.bookingId);
|
||||
return delay(paginate(filtered, params));
|
||||
},
|
||||
|
||||
getNursePayoutHistory: async (params: PageParams) => delay(paginate([...HISTORY], params)),
|
||||
|
||||
getNursePayoutDetail: async (payoutId: number) => {
|
||||
const detail = DETAILS[payoutId];
|
||||
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
|
||||
return delay(detail);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* When true, the payouts domain is served by the in-memory mock (`apis/mockApi.ts`) behind the
|
||||
* `PayoutsApi` seam.
|
||||
*
|
||||
* **Mock is primary this phase.** b13 shipped the nurse read as a **single** endpoint
|
||||
* (`GET api/v1/nurse_payouts/history` → `NursePayoutHistoryDto`). The **four-bucket earnings summary**,
|
||||
* the **per-booking earnings list + money-state**, and a **nurse-readable payout detail** (batch context +
|
||||
* booking links + `failureReason`) are contract gaps filed as **REQ-025**. So the whole earnings surface is
|
||||
* mocked behind this seam with real-shaped fixtures covering **all four earnings states + a negative net
|
||||
* balance (clawback > earnings) + a `failed` payout** (every UI state exercisable). Flip to `false` once
|
||||
* REQ-025 lands — no hook/component change (only `clientApi.ts`'s three gap methods start returning real data).
|
||||
*/
|
||||
export const USE_PAYOUTS_MOCK = true;
|
||||
|
||||
/**
|
||||
* Which mock ledger picture to serve. `standard` = a healthy positive net balance with one of each earnings
|
||||
* state; `clawback_heavy` = outstanding clawbacks exceed accrued-unpaid earnings so the **net balance goes
|
||||
* negative** ("owed back") — the explicit owed-back UI state (phase §7 step 3). The four earnings rows are
|
||||
* identical across scenarios; only the ledger-derived summary differs (the summary spans the whole ledger,
|
||||
* not just the visible page). Flip to demo the negative-balance treatment.
|
||||
*/
|
||||
export type PayoutsMockScenario = 'standard' | 'clawback_heavy';
|
||||
export const MOCK_SCENARIO: PayoutsMockScenario = 'standard';
|
||||
|
||||
/**
|
||||
* Earnings move on a **weekly cadence**, not per second — a generous `staleTime` means revisiting the
|
||||
* screen or switching a tab never needlessly refetches. There are no mutations this phase, so nothing
|
||||
* invalidates these; the natural staleness is the only refresh trigger.
|
||||
*/
|
||||
export const EARNINGS_SUMMARY_STALE_TIME = 5 * 60 * 1000;
|
||||
export const EARNINGS_LIST_STALE_TIME = 5 * 60 * 1000;
|
||||
export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000;
|
||||
/** A settled payout is immutable — its detail is effectively permanent; keep it warm longer. */
|
||||
export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000;
|
||||
export const PAYOUTS_GC_TIME = 15 * 60 * 1000;
|
||||
|
||||
/** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */
|
||||
export const PAYOUTS_PAGE_SIZE = 10;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { EARNINGS_LIST_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type { EarningsState } from '../types';
|
||||
|
||||
/**
|
||||
* The state-segmented earnings list. The **state filter + page are part of the query key** so each tab and
|
||||
* each page caches independently — switching back to a viewed tab is a cache hit with no network. `state`
|
||||
* omitted = the unfiltered ("all") tab. `keepPreviousData` holds the prior page/tab visible while the next
|
||||
* loads, so paging/tabbing never flashes an empty list.
|
||||
*/
|
||||
export function useNurseEarnings(state: EarningsState | undefined, page: number) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.earningsList(state ?? 'all', page),
|
||||
queryFn: () => payoutsApi.getNurseEarnings({ state, page, pageSize: PAYOUTS_PAGE_SIZE }),
|
||||
staleTime: EARNINGS_LIST_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { EARNINGS_SUMMARY_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's four-bucket earnings roll-up + the **signed** net payable balance. Read-only; a generous
|
||||
* `staleTime` (earnings move weekly, not per second) so revisiting the screen serves from cache. The net
|
||||
* balance may be negative ("owed back") — the header renders the sign; this hook never touches the value.
|
||||
*/
|
||||
export function useNurseEarningsBalance() {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.earningsSummary(),
|
||||
queryFn: () => payoutsApi.getNurseEarningsBalance(),
|
||||
staleTime: EARNINGS_SUMMARY_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { PAYOUT_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* One payout expanded — the nurse's reconciliation view (money decomposition + batch window + the exact
|
||||
* bookings covered). A settled payout is immutable, so a long `staleTime`; disabled until a valid id.
|
||||
*/
|
||||
export function useNursePayoutDetail(payoutId: number | undefined) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.detail(payoutId ?? -1),
|
||||
queryFn: () => payoutsApi.getNursePayoutDetail(payoutId as number),
|
||||
enabled: payoutId != null && payoutId > 0,
|
||||
staleTime: PAYOUT_DETAIL_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { PAYOUT_HISTORY_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
|
||||
/**
|
||||
* The nurse's paginated payout history (`nurse_payouts`, newest first). Each page keys separately;
|
||||
* `keepPreviousData` avoids an empty flash while paging. Read-only, generous `staleTime`.
|
||||
*/
|
||||
export function useNursePayoutHistory(page: number) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.history(page),
|
||||
queryFn: () => payoutsApi.getNursePayoutHistory({ page, pageSize: PAYOUTS_PAGE_SIZE }),
|
||||
staleTime: PAYOUT_HISTORY_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { PageParams, Paginated } from '@/lib/api/types';
|
||||
|
||||
/**
|
||||
* Payouts domain — the **nurse read** side of the b13 weekly-payout engine ("I did the work, where is
|
||||
* my money?"). Shapes are derived from the payouts contract
|
||||
* (`dev/contracts/domains/payouts.md` + `dev/contracts/openapi/swagger.v1.json`).
|
||||
*
|
||||
* **The contract only serves a nurse ONE endpoint** (`GET api/v1/nurse_payouts/history` →
|
||||
* `NursePayoutHistoryDto`). The **four-bucket earnings summary**, the **per-booking earnings list with a
|
||||
* money-state**, and a **nurse-readable payout detail** (batch context + booking links) are contract gaps
|
||||
* filed as **REQ-025** and mocked behind the `PayoutsApi` seam this phase — so the domain is mock-primary
|
||||
* (see `constants.ts`). When REQ-025 lands, only `apis/clientApi.ts` flips; hooks/screens are unchanged.
|
||||
*
|
||||
* Load-bearing money/authority semantics (contract + phase §5):
|
||||
* - Money is **IRR digit-strings**, integer-safe via the money util; **never** `Number()`/float math.
|
||||
* Toman is **display-only**. The three booking amounts satisfy `gross = commission + payout`.
|
||||
* - The nurse **payable balance is derived from the ledger and MAY be negative** ("owed back") — model it
|
||||
* as a **signed** string; **never clamp to zero**. A clawback **nets**, it does not auto-reverse.
|
||||
* - **Eligibility is server truth** (EVV complete AND `dispute_window_ends_at < now`). The client only ever
|
||||
* *renders* a cosmetic countdown off `disputeWindowEndsAt`; it **never computes eligibility** client-side.
|
||||
* - **Read-only:** a nurse never triggers a transfer, retries a payout, or runs a batch (admin actions, f15).
|
||||
* - The **BNPL provider commission is NEVER a nurse deduction** — it does not appear anywhere here; the nurse
|
||||
* amount is payment-method-invariant (`gross − balinyaar_commission`, identical for card vs BNPL).
|
||||
*
|
||||
* Enums cross the wire as stable string codes — mirrored here as string-literal unions; labels are i18n keys.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The four money states a nurse cares about, per completed booking. This is a **client display model**
|
||||
* (there is no single wire enum for it — REQ-025); it is derived server-side from the ledger + dispute
|
||||
* window + payout link:
|
||||
* - `pending` — still in escrow, the dispute window is open (not yet payout-eligible).
|
||||
* - `eligible` — cleared (dispute window closed), awaiting the next weekly batch.
|
||||
* - `paid` — transferred (carries `paidAt` + `transferReference` + the owning `nursePayoutId`).
|
||||
* - `clawback_applied` — a refund-after-payout netted the original earning out of the total.
|
||||
*/
|
||||
export type EarningsState = 'pending' | 'eligible' | 'paid' | 'clawback_applied';
|
||||
|
||||
/** Stable render/filter order for the state-segmented list. */
|
||||
export const EARNINGS_STATES: readonly EarningsState[] = [
|
||||
'pending',
|
||||
'eligible',
|
||||
'paid',
|
||||
'clawback_applied',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `PayoutStatus` (contract enum) — the per-payout lifecycle, **forward-only**. `paid` is an irreversible
|
||||
* transfer with no outgoing edge; `failed` re-submits on an **admin** retry. NB the contract uses
|
||||
* `submitted` (not "processing"): a payout handed to the bank rail, awaiting settlement.
|
||||
*/
|
||||
export type PayoutStatus = 'pending' | 'submitted' | 'paid' | 'failed';
|
||||
|
||||
/** `PayoutBatchStatus` (contract enum) — the batch lifecycle a payout's batch context reports. */
|
||||
export type PayoutBatchStatus = 'draft' | 'processing' | 'partially_failed' | 'completed' | 'failed';
|
||||
|
||||
/** A payout is settled (no further movement the nurse can affect) once `paid` or `failed`. */
|
||||
export function isTerminalPayoutStatus(status: PayoutStatus): boolean {
|
||||
return status === 'paid' || status === 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* The four-bucket earnings roll-up + the derived net payable balance (REQ-025 — the summary shape the
|
||||
* contract does not yet serve). Every amount is an IRR digit-string.
|
||||
*/
|
||||
export interface NurseEarningsSummary {
|
||||
/** Still in escrow, dispute window open. */
|
||||
pendingTotalIrr: string;
|
||||
/** Cleared, awaiting the next weekly batch. */
|
||||
eligibleTotalIrr: string;
|
||||
/** Lifetime transferred. */
|
||||
paidTotalIrr: string;
|
||||
/** Clawback receivable not yet recovered (netted from a future batch). */
|
||||
clawbackOutstandingIrr: string;
|
||||
/**
|
||||
* **Signed** IRR digit-string — what Balinyaar currently owes the nurse (ledger-derived). **MAY be
|
||||
* negative** when outstanding clawbacks exceed accrued-unpaid earnings ("owed back"). Never clamp.
|
||||
*/
|
||||
netPayableBalanceIrr: string;
|
||||
}
|
||||
|
||||
/** One completed booking contributing to earnings (REQ-025). Enough fields to deep-link + explain each state. */
|
||||
export interface NurseEarningsItem {
|
||||
/** The booking this earning is for — deep-links to the f8 nurse booking detail. */
|
||||
bookingId: number;
|
||||
patientName: string;
|
||||
/** ISO date `YYYY-MM-DD`. */
|
||||
scheduledDate: string;
|
||||
/** The three amounts — IRR digit-strings; `grossPriceIrr = balinyaarCommissionIrr + nursePayoutAmount`. */
|
||||
grossPriceIrr: string;
|
||||
balinyaarCommissionIrr: string;
|
||||
nursePayoutAmount: string;
|
||||
state: EarningsState;
|
||||
/** Drives the **display-only** pending countdown; `null` once past. The server owns eligibility. */
|
||||
disputeWindowEndsAt: string | null;
|
||||
/** Server truth: when this amount became payout-eligible. `null` while pending. Never computed here. */
|
||||
payoutEligibleAt: string | null;
|
||||
/** `paid` only: when the transfer landed + its opaque reconciliation reference + the owning payout/batch. */
|
||||
paidAt: string | null;
|
||||
transferReference: string | null;
|
||||
nursePayoutId: number | null;
|
||||
batchId: number | null;
|
||||
/** `clawback_applied` only: the clawed-back amount and the resulting net (`= nursePayoutAmount − clawback`). */
|
||||
clawbackAppliedIrr: string | null;
|
||||
netAmountIrr: string | null;
|
||||
}
|
||||
|
||||
/** One `nurse_payouts` row in the nurse's own history (`NursePayoutHistoryDto` + REQ-025 `failureReason`). */
|
||||
export interface NursePayoutHistoryItem {
|
||||
id: number;
|
||||
batchId: number;
|
||||
status: PayoutStatus;
|
||||
/** `netAmountIrr = grossEarningsIrr − clawbackAppliedIrr`, guaranteed server-side. */
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
/** Masked, **last-4 only** — an encrypted field; never a full IBAN. */
|
||||
maskedIban: string;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
/** The batch window (holiday-shifted server-side), ISO dates `YYYY-MM-DD`. */
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
/** `failed` only (REQ-025 — the nurse history DTO lacks it today). Read-only; the nurse cannot retry. */
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
/** A booking a payout covered (`PayoutBookingLinkDto`). One booking appears in exactly one payout, forever. */
|
||||
export interface NursePayoutBookingLink {
|
||||
bookingId: number;
|
||||
sessionId: number | null;
|
||||
/** This booking's nurse-payout share. Σ over a payout's links = its `grossEarningsIrr`. */
|
||||
payoutAmountIrr: string;
|
||||
}
|
||||
|
||||
/** The `nurse_payout_batches` context a nurse sees for one of their payouts (subset of `PayoutBatchDto`). */
|
||||
export interface NursePayoutBatchContext {
|
||||
id: number;
|
||||
/** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
status: PayoutBatchStatus;
|
||||
totalAmount: string;
|
||||
payoutCount: number;
|
||||
processedAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One payout expanded — the nurse's reconciliation view (REQ-025, nurse-scoped analogue of the admin
|
||||
* `PayoutDto`): the money decomposition, the batch window, and the exact bookings it covered.
|
||||
*/
|
||||
export interface NursePayoutDetail {
|
||||
id: number;
|
||||
batchId: number;
|
||||
status: PayoutStatus;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
/** `= grossEarningsIrr − clawbackAppliedIrr`. */
|
||||
netAmountIrr: string;
|
||||
/** What was actually transferred (`= netAmountIrr` on a clean payout). */
|
||||
amountIrr: string;
|
||||
maskedIban: string;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
failureReason: string | null;
|
||||
batch: NursePayoutBatchContext;
|
||||
bookings: NursePayoutBookingLink[];
|
||||
}
|
||||
|
||||
/** `getNurseEarnings` query params — the state filter is part of the query key so each tab caches separately. */
|
||||
export interface EarningsListParams extends PageParams {
|
||||
state?: EarningsState;
|
||||
}
|
||||
|
||||
/**
|
||||
* The payouts API seam — the real HTTP client and the in-memory mock both implement this interface;
|
||||
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks. **All reads; no
|
||||
* mutations** (a nurse never writes payout state).
|
||||
*/
|
||||
export interface PayoutsApi {
|
||||
getNurseEarningsBalance(): Promise<NurseEarningsSummary>;
|
||||
getNurseEarnings(params: EarningsListParams): Promise<Paginated<NurseEarningsItem>>;
|
||||
getNursePayoutHistory(params: PageParams): Promise<Paginated<NursePayoutHistoryItem>>;
|
||||
getNursePayoutDetail(payoutId: number): Promise<NursePayoutDetail>;
|
||||
}
|
||||
Reference in New Issue
Block a user