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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user