frontend phase 15
This commit is contained in:
@@ -2,17 +2,27 @@ import { clientFetch } from '@/lib/api/client';
|
||||
import { unwrap, type ApiEnvelope, type Paginated } from '@/lib/api/types';
|
||||
import { PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type {
|
||||
AdminPayoutBatchDetail,
|
||||
AdminPayoutRow,
|
||||
EarningsListParams,
|
||||
EligibleNurseEarnings,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutBatchFilters,
|
||||
PayoutBatchStatus,
|
||||
PayoutBatchSummary,
|
||||
PayoutStatus,
|
||||
PayoutsApi,
|
||||
} from '../types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
|
||||
const NURSE_PAYOUTS = '/api/v1/nurse_payouts';
|
||||
const ADMIN_PAYOUTS = '/api/v1/admin_payouts';
|
||||
|
||||
/** The header b13's process/retry read the per-run idempotency key from (same convention as b10/b12). */
|
||||
const IDEMPOTENCY_KEY_HEADER = 'Idempotency-Key';
|
||||
|
||||
/**
|
||||
* The b13 nurse history payload (`GET nurse_payouts/history` → `NursePayoutHistoryDto`). Note it carries
|
||||
@@ -51,6 +61,91 @@ function toHistoryItem(wire: NursePayoutHistoryWire): NursePayoutHistoryItem {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin batch wire DTOs (`admin_payouts/*`) ─────────────────────────────────────────────────────────
|
||||
|
||||
/** `PayoutBatchDto` — note it has `initiatedByAdminId` but **no** `holidayShifted` flag (REQ-036). */
|
||||
interface PayoutBatchWire {
|
||||
id: number;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
totalAmount: string;
|
||||
payoutCount: number;
|
||||
status: PayoutBatchStatus;
|
||||
processedAt: string | null;
|
||||
failureNotes: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** `PayoutDto` — note the transferred amount is `amount`, mapped to `amountIrr`. */
|
||||
interface PayoutWire {
|
||||
id: number;
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
maskedIban: string;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
amount: string;
|
||||
status: PayoutStatus;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
failureReason: string | null;
|
||||
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
|
||||
}
|
||||
|
||||
interface PayoutBatchDetailWire {
|
||||
batch: PayoutBatchWire;
|
||||
payouts: PayoutWire[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface GeneratePayoutBatchResultWire {
|
||||
batch: PayoutBatchWire;
|
||||
}
|
||||
|
||||
function toBatchSummary(wire: PayoutBatchWire): PayoutBatchSummary {
|
||||
return {
|
||||
id: wire.id,
|
||||
periodStart: wire.periodStart,
|
||||
periodEnd: wire.periodEnd,
|
||||
processingDate: wire.processingDate,
|
||||
totalAmount: wire.totalAmount,
|
||||
payoutCount: wire.payoutCount,
|
||||
status: wire.status,
|
||||
processedAt: wire.processedAt,
|
||||
failureNotes: wire.failureNotes,
|
||||
createdAt: wire.createdAt,
|
||||
// REQ-036: PayoutBatchDto exposes no holidayShifted flag — a single preview endpoint returning
|
||||
// eligible+skipped+processingDate+holidayShifted would carry it. Defaults false until then.
|
||||
holidayShifted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toPayoutRow(wire: PayoutWire): AdminPayoutRow {
|
||||
return {
|
||||
id: wire.id,
|
||||
nurseId: wire.nurseId,
|
||||
nurseName: wire.nurseName,
|
||||
maskedIban: wire.maskedIban,
|
||||
grossEarningsIrr: wire.grossEarningsIrr,
|
||||
clawbackAppliedIrr: wire.clawbackAppliedIrr,
|
||||
netAmountIrr: wire.netAmountIrr,
|
||||
amountIrr: wire.amount,
|
||||
status: wire.status,
|
||||
transferReference: wire.transferReference,
|
||||
paidAt: wire.paidAt,
|
||||
failureReason: wire.failureReason,
|
||||
bookings: wire.bookings.map((b) => ({
|
||||
bookingId: b.bookingId,
|
||||
sessionId: b.sessionId,
|
||||
payoutAmountIrr: b.payoutAmountIrr,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -90,4 +185,87 @@ export const payoutsClientApi: PayoutsApi = {
|
||||
|
||||
getNursePayoutDetail: async (payoutId: number) =>
|
||||
unwrap(await clientFetch<ApiEnvelope<NursePayoutDetail>>(`${NURSE_PAYOUTS}/${payoutId}`)),
|
||||
|
||||
// ── Admin batch actions (`admin_payouts/*`) ─────────────────────────────────────────────────────────
|
||||
|
||||
listPayoutBatches: async (
|
||||
filters: PayoutBatchFilters,
|
||||
params: PageParams,
|
||||
): Promise<Paginated<PayoutBatchSummary>> => {
|
||||
const query = new URLSearchParams();
|
||||
if (filters.status) query.set('status', filters.status);
|
||||
query.set('page', String(params.page ?? 1));
|
||||
query.set('pageSize', String(params.pageSize ?? PAYOUTS_PAGE_SIZE));
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<PayoutBatchWire>>>(`${ADMIN_PAYOUTS}/batches?${query.toString()}`),
|
||||
);
|
||||
return { ...page, items: page.items.map(toBatchSummary) };
|
||||
},
|
||||
|
||||
previewPayoutBatch: async (periodStart: string, periodEnd: string) => {
|
||||
const query = new URLSearchParams({ periodStart, periodEnd });
|
||||
const page = unwrap(
|
||||
await clientFetch<ApiEnvelope<Paginated<EligibleNurseEarnings>>>(
|
||||
`${ADMIN_PAYOUTS}/eligible?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
const eligible = page.items;
|
||||
const totalNet = eligible.reduce((sum, e) => sum + BigInt(e.netAmountIrr), BigInt(0));
|
||||
// REQ-036: a single preview endpoint returning eligible + skipped + processingDate + holidayShifted in
|
||||
// one shot. The b13 `eligible` read is paged and returns only the eligible rows — no skipped list (that
|
||||
// is materialized by the generate call) and no processingDate/holidayShifted — so carry what we can:
|
||||
// processingDate falls back to periodEnd and skipped is empty until the preview route lands.
|
||||
return {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
processingDate: periodEnd,
|
||||
holidayShifted: false,
|
||||
eligible,
|
||||
skipped: [],
|
||||
totalNetIrr: String(totalNet),
|
||||
};
|
||||
},
|
||||
|
||||
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
|
||||
const result = unwrap(
|
||||
await clientFetch<ApiEnvelope<GeneratePayoutBatchResultWire>>(`${ADMIN_PAYOUTS}/batches`, {
|
||||
method: 'POST',
|
||||
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||
body: JSON.stringify({ periodStart, periodEnd }),
|
||||
}),
|
||||
);
|
||||
return toBatchSummary(result.batch);
|
||||
},
|
||||
|
||||
getPayoutBatchDetail: async (batchId: number, page: number): Promise<AdminPayoutBatchDetail> => {
|
||||
const query = new URLSearchParams({ page: String(page) });
|
||||
const detail = unwrap(
|
||||
await clientFetch<ApiEnvelope<PayoutBatchDetailWire>>(
|
||||
`${ADMIN_PAYOUTS}/batches/${batchId}?${query.toString()}`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
batch: toBatchSummary(detail.batch),
|
||||
payouts: detail.payouts.map(toPayoutRow),
|
||||
total: detail.total,
|
||||
page: detail.page,
|
||||
pageSize: detail.pageSize,
|
||||
};
|
||||
},
|
||||
|
||||
retryPayout: async (payoutId: number, idempotencyKey: string) => {
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/retry`, {
|
||||
method: 'POST',
|
||||
headers: { [IDEMPOTENCY_KEY_HEADER]: idempotencyKey },
|
||||
});
|
||||
},
|
||||
|
||||
recordTransferReference: async (payoutId: number, reference: string) => {
|
||||
// REQ-036: b13 has `mark_failed` but no record-transfer-reference route — a manually reconciled bank
|
||||
// transfer reference has nowhere to land. Proposed action-style slug; 404s until the backend adds it.
|
||||
await clientFetch<ApiEnvelope<boolean>>(`${ADMIN_PAYOUTS}/${payoutId}/transfer_reference`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reference }),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { Paginated } from '@/lib/api/types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import { MOCK_SCENARIO } from '../constants';
|
||||
import { ADMIN_BATCH_DETAIL_PAGE_SIZE, MOCK_SCENARIO } from '../constants';
|
||||
import type {
|
||||
AdminPayoutBatchDetail,
|
||||
AdminPayoutRow,
|
||||
EarningsListParams,
|
||||
EarningsState,
|
||||
EligibleNurseEarnings,
|
||||
NurseEarningsItem,
|
||||
NurseEarningsSummary,
|
||||
NursePayoutDetail,
|
||||
NursePayoutHistoryItem,
|
||||
PayoutBatchFilters,
|
||||
PayoutBatchPreview,
|
||||
PayoutBatchSummary,
|
||||
PayoutsApi,
|
||||
SkippedNurse,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
@@ -320,6 +327,262 @@ function delay<T>(value: T): Promise<T> {
|
||||
|
||||
const STATE_ORDER: Record<EarningsState, number> = { pending: 0, eligible: 1, paid: 2, clawback_applied: 3 };
|
||||
|
||||
// ══ Admin batch actions (b13 admin_payouts/*) — mutable in-memory state ════════════════════════════════
|
||||
//
|
||||
// Engineered to exercise every admin UI state and stay money-correct: batches spanning completed /
|
||||
// partially_failed / processing, at least one holiday-shifted; per-payout rows including a `failed` row
|
||||
// (so retry is demonstrable) and `paid` rows with a last-4 masked IBAN + transfer reference. Every row
|
||||
// reconciles `gross − clawback = net = amount`, its booking links sum to its gross, and each batch's
|
||||
// `totalAmount = Σ its payouts' net`. Rows are **mutated in place** by retry / record-reference so state
|
||||
// persists across calls within the session.
|
||||
|
||||
/** ISO `YYYY-MM-DD` shifted `days` off `isoDate` (holiday-shifted processing date). */
|
||||
function isoDateShift(isoDate: string, days: number): string {
|
||||
return new Date(new Date(isoDate).getTime() + days * DAY_MS).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** IRR-string sum (integer-safe BigInt; never a float). */
|
||||
function sumIrr(values: string[]): string {
|
||||
return String(values.reduce((total, v) => total + BigInt(v), BigInt(0)));
|
||||
}
|
||||
|
||||
const ADMIN_MASKED_IBAN_A = 'IR••••••••••••••••••4821';
|
||||
const ADMIN_MASKED_IBAN_B = 'IR••••••••••••••••••7734';
|
||||
const ADMIN_MASKED_IBAN_C = 'IR••••••••••••••••••1092';
|
||||
|
||||
/** Per-nurse payout rows keyed by batch id. Mutated in place by `retryPayout` / `recordTransferReference`. */
|
||||
const BATCH_DETAILS: Record<number, AdminPayoutRow[]> = {
|
||||
// 7101 — partially_failed: one paid + one failed (retryable). Σ net = 4,250,000 + 3,400,000 = 7,650,000.
|
||||
7101: [
|
||||
{
|
||||
id: 9201,
|
||||
nurseId: 301,
|
||||
nurseName: 'زهرا موسوی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
status: 'paid',
|
||||
transferReference: 'PAYA-14050412-9201',
|
||||
paidAt: isoFromNowHours(-20),
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5003, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||
},
|
||||
{
|
||||
id: 9202,
|
||||
nurseId: 302,
|
||||
nurseName: 'مریم احمدی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_B,
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
amountIrr: '3400000',
|
||||
status: 'failed',
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: 'invalid_sheba',
|
||||
bookings: [{ bookingId: 5002, sessionId: 1, payoutAmountIrr: '3400000' }],
|
||||
},
|
||||
],
|
||||
// 7102 — completed: a clawback-netted paid + a clean paid. Σ net = 4,250,000 + 2,000,000 = 6,250,000.
|
||||
7102: [
|
||||
{
|
||||
id: 9203,
|
||||
nurseId: 303,
|
||||
nurseName: 'فاطمه کریمی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_C,
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '750000',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
status: 'paid',
|
||||
transferReference: 'SATNA-14050405-9203',
|
||||
paidAt: isoFromNowHours(-96),
|
||||
failureReason: null,
|
||||
bookings: [
|
||||
{ bookingId: 4990, sessionId: 1, payoutAmountIrr: '2750000' },
|
||||
{ bookingId: 4991, sessionId: 1, payoutAmountIrr: '2250000' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9204,
|
||||
nurseId: 304,
|
||||
nurseName: 'سکینه رضایی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
amountIrr: '2000000',
|
||||
status: 'paid',
|
||||
transferReference: 'PAYA-14050405-9204',
|
||||
paidAt: isoFromNowHours(-100),
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5006, sessionId: 1, payoutAmountIrr: '2000000' }],
|
||||
},
|
||||
],
|
||||
// 7103 — processing: submitted, awaiting settlement. Σ net = 3,000,000.
|
||||
7103: [
|
||||
{
|
||||
id: 9205,
|
||||
nurseId: 305,
|
||||
nurseName: 'اکرم حسینی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_B,
|
||||
grossEarningsIrr: '3000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3000000',
|
||||
amountIrr: '3000000',
|
||||
status: 'submitted',
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5010, sessionId: 1, payoutAmountIrr: '3000000' }],
|
||||
},
|
||||
],
|
||||
// 7104 — completed (holiday-shifted), older. Σ net = 4,250,000.
|
||||
7104: [
|
||||
{
|
||||
id: 9206,
|
||||
nurseId: 301,
|
||||
nurseName: 'زهرا موسوی',
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: '4250000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '4250000',
|
||||
amountIrr: '4250000',
|
||||
status: 'paid',
|
||||
transferReference: 'SATNA-14050328-9206',
|
||||
paidAt: isoFromNowHours(-260),
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 4980, sessionId: 1, payoutAmountIrr: '4250000' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/** Batch headers (newest-first by `createdAt` at read time). `totalAmount = Σ its detail rows' net`. */
|
||||
const BATCHES: PayoutBatchSummary[] = [
|
||||
{
|
||||
id: 7103,
|
||||
periodStart: isoDateDaysAgo(7),
|
||||
periodEnd: isoDateDaysAgo(1),
|
||||
processingDate: isoDateDaysAgo(0),
|
||||
totalAmount: '3000000',
|
||||
payoutCount: 1,
|
||||
status: 'processing',
|
||||
processedAt: null,
|
||||
failureNotes: null,
|
||||
createdAt: isoFromNowHours(-6),
|
||||
holidayShifted: false,
|
||||
},
|
||||
{
|
||||
id: 7101,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(6),
|
||||
totalAmount: '7650000',
|
||||
payoutCount: 2,
|
||||
status: 'partially_failed',
|
||||
processedAt: isoFromNowHours(-20),
|
||||
failureNotes: '۱ انتقال توسط سامانه بانکی رد شد (invalid_sheba)',
|
||||
createdAt: isoFromNowHours(-26),
|
||||
holidayShifted: true,
|
||||
},
|
||||
{
|
||||
id: 7102,
|
||||
periodStart: isoDateDaysAgo(14),
|
||||
periodEnd: isoDateDaysAgo(8),
|
||||
processingDate: isoDateDaysAgo(7),
|
||||
totalAmount: '6250000',
|
||||
payoutCount: 2,
|
||||
status: 'completed',
|
||||
processedAt: isoFromNowHours(-96),
|
||||
failureNotes: null,
|
||||
createdAt: isoFromNowHours(-120),
|
||||
holidayShifted: false,
|
||||
},
|
||||
{
|
||||
id: 7104,
|
||||
periodStart: isoDateDaysAgo(21),
|
||||
periodEnd: isoDateDaysAgo(15),
|
||||
processingDate: isoDateDaysAgo(13),
|
||||
totalAmount: '4250000',
|
||||
payoutCount: 1,
|
||||
status: 'completed',
|
||||
processedAt: isoFromNowHours(-260),
|
||||
failureNotes: null,
|
||||
createdAt: isoFromNowHours(-264),
|
||||
holidayShifted: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Idempotency ledgers: the SAME key returns the SAME result (never a double-run / double-pay). */
|
||||
const RUN_BATCH_IDEMPOTENCY = new Map<string, PayoutBatchSummary>();
|
||||
const RETRY_IDEMPOTENCY = new Set<string>();
|
||||
let nextBatchId = 7200;
|
||||
let nextAdminPayoutId = 9300;
|
||||
|
||||
/**
|
||||
* The eligibility dry-run: ≥3 eligible nurses (one with a netted clawback), one flagged with no verified
|
||||
* IBAN (`hasVerifiedPrimaryIban:false` — shown, not dropped), one skipped (`no_verified_primary_iban`), and
|
||||
* a holiday-shifted processing date. `totalNetIrr = Σ eligible.netAmountIrr`. The server owns eligibility +
|
||||
* the shifted date; the client only renders this.
|
||||
*/
|
||||
function buildPreview(periodStart: string, periodEnd: string): PayoutBatchPreview {
|
||||
const eligible: EligibleNurseEarnings[] = [
|
||||
{
|
||||
nurseId: 301,
|
||||
nurseName: 'زهرا موسوی',
|
||||
bookingCount: 2,
|
||||
grossEarningsIrr: '5000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '5000000',
|
||||
hasVerifiedPrimaryIban: true,
|
||||
},
|
||||
{
|
||||
nurseId: 302,
|
||||
nurseName: 'مریم احمدی',
|
||||
bookingCount: 1,
|
||||
grossEarningsIrr: '3400000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '3400000',
|
||||
hasVerifiedPrimaryIban: true,
|
||||
},
|
||||
{
|
||||
// netted clawback: 6,000,000 gross − 1,500,000 clawback = 4,500,000 net
|
||||
nurseId: 303,
|
||||
nurseName: 'فاطمه کریمی',
|
||||
bookingCount: 3,
|
||||
grossEarningsIrr: '6000000',
|
||||
clawbackAppliedIrr: '1500000',
|
||||
netAmountIrr: '4500000',
|
||||
hasVerifiedPrimaryIban: true,
|
||||
},
|
||||
{
|
||||
// flagged (no verified primary IBAN) — surfaced with the flag, NOT dropped (contract semantics)
|
||||
nurseId: 306,
|
||||
nurseName: 'نرگس علوی',
|
||||
bookingCount: 1,
|
||||
grossEarningsIrr: '2000000',
|
||||
clawbackAppliedIrr: '0',
|
||||
netAmountIrr: '2000000',
|
||||
hasVerifiedPrimaryIban: false,
|
||||
},
|
||||
];
|
||||
const skipped: SkippedNurse[] = [
|
||||
{ nurseId: 307, nurseName: 'طاهره یوسفی', grossEarningsIrr: '1200000', reason: 'no_verified_primary_iban' },
|
||||
];
|
||||
return {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
// holiday-shifted a couple days past periodEnd (the server owns the shift; the client renders it)
|
||||
processingDate: isoDateShift(periodEnd, 2),
|
||||
holidayShifted: true,
|
||||
eligible,
|
||||
skipped,
|
||||
totalNetIrr: sumIrr(eligible.map((e) => e.netAmountIrr)),
|
||||
};
|
||||
}
|
||||
|
||||
export const payoutsMockApi: PayoutsApi = {
|
||||
getNurseEarningsBalance: async () => delay(buildSummary()),
|
||||
|
||||
@@ -336,4 +599,111 @@ export const payoutsMockApi: PayoutsApi = {
|
||||
if (!detail) throw new Error(`Mock payout ${payoutId} not found`);
|
||||
return delay(detail);
|
||||
},
|
||||
|
||||
// ── Admin batch actions ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
listPayoutBatches: async (filters: PayoutBatchFilters, params: PageParams) => {
|
||||
const sorted = [...BATCHES].sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
|
||||
const filtered = filters.status ? sorted.filter((b) => b.status === filters.status) : sorted;
|
||||
return delay(paginate(filtered, params));
|
||||
},
|
||||
|
||||
previewPayoutBatch: async (periodStart: string, periodEnd: string) =>
|
||||
delay(buildPreview(periodStart, periodEnd)),
|
||||
|
||||
runPayoutBatch: async (periodStart: string, periodEnd: string, idempotencyKey: string) => {
|
||||
// Idempotency: the same key returns the same batch — a retried run never opens a second batch.
|
||||
const prior = RUN_BATCH_IDEMPOTENCY.get(idempotencyKey);
|
||||
if (prior) return delay(prior);
|
||||
|
||||
const preview = buildPreview(periodStart, periodEnd);
|
||||
// Only nurses with a verified primary IBAN are materialized into payouts (the flagged ones are skipped).
|
||||
const payable = preview.eligible.filter((e) => e.hasVerifiedPrimaryIban);
|
||||
const id = nextBatchId++;
|
||||
const batch: PayoutBatchSummary = {
|
||||
id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
processingDate: preview.processingDate,
|
||||
totalAmount: sumIrr(payable.map((e) => e.netAmountIrr)),
|
||||
payoutCount: payable.length,
|
||||
status: 'processing',
|
||||
processedAt: null,
|
||||
failureNotes: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
holidayShifted: preview.holidayShifted,
|
||||
};
|
||||
BATCHES.unshift(batch);
|
||||
BATCH_DETAILS[id] = payable.map((e) => ({
|
||||
id: nextAdminPayoutId++,
|
||||
nurseId: e.nurseId,
|
||||
nurseName: e.nurseName,
|
||||
maskedIban: ADMIN_MASKED_IBAN_A,
|
||||
grossEarningsIrr: e.grossEarningsIrr,
|
||||
clawbackAppliedIrr: e.clawbackAppliedIrr,
|
||||
netAmountIrr: e.netAmountIrr,
|
||||
amountIrr: e.netAmountIrr,
|
||||
status: 'submitted',
|
||||
transferReference: null,
|
||||
paidAt: null,
|
||||
failureReason: null,
|
||||
bookings: [{ bookingId: 5000 + e.nurseId, sessionId: 1, payoutAmountIrr: e.grossEarningsIrr }],
|
||||
}));
|
||||
RUN_BATCH_IDEMPOTENCY.set(idempotencyKey, batch);
|
||||
return delay(batch);
|
||||
},
|
||||
|
||||
getPayoutBatchDetail: async (batchId: number, page: number) => {
|
||||
const batch = BATCHES.find((b) => b.id === batchId);
|
||||
if (!batch) throw new Error(`Mock payout batch ${batchId} not found`);
|
||||
const rows = BATCH_DETAILS[batchId] ?? [];
|
||||
const pageSize = ADMIN_BATCH_DETAIL_PAGE_SIZE;
|
||||
const p = Math.max(1, page);
|
||||
const start = (p - 1) * pageSize;
|
||||
const detail: AdminPayoutBatchDetail = {
|
||||
batch,
|
||||
payouts: rows.slice(start, start + pageSize),
|
||||
total: rows.length,
|
||||
page: p,
|
||||
pageSize,
|
||||
};
|
||||
return delay(detail);
|
||||
},
|
||||
|
||||
retryPayout: async (payoutId: number, idempotencyKey: string) => {
|
||||
// Idempotency: a re-fired retry with the same key never re-applies (no double-pay).
|
||||
if (RETRY_IDEMPOTENCY.has(idempotencyKey)) return delay(undefined);
|
||||
RETRY_IDEMPOTENCY.add(idempotencyKey);
|
||||
for (const [key, rows] of Object.entries(BATCH_DETAILS)) {
|
||||
const row = rows.find((r) => r.id === payoutId);
|
||||
if (!row) continue;
|
||||
if (row.status === 'failed') {
|
||||
row.status = 'paid';
|
||||
row.failureReason = null;
|
||||
row.paidAt = new Date().toISOString();
|
||||
row.transferReference = `PAYA-RETRY-${payoutId}`;
|
||||
row.amountIrr = row.netAmountIrr;
|
||||
const batch = BATCHES.find((b) => b.id === Number(key));
|
||||
// if that was the last failure in the batch, it re-settles partially_failed → completed
|
||||
if (batch && batch.status === 'partially_failed' && rows.every((r) => r.status !== 'failed')) {
|
||||
batch.status = 'completed';
|
||||
batch.processedAt = new Date().toISOString();
|
||||
batch.failureNotes = null;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return delay(undefined);
|
||||
},
|
||||
|
||||
recordTransferReference: async (payoutId: number, reference: string) => {
|
||||
for (const rows of Object.values(BATCH_DETAILS)) {
|
||||
const row = rows.find((r) => r.id === payoutId);
|
||||
if (row) {
|
||||
row.transferReference = reference;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return delay(undefined);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,5 +34,15 @@ export const PAYOUT_HISTORY_STALE_TIME = 5 * 60 * 1000;
|
||||
export const PAYOUT_DETAIL_STALE_TIME = 10 * 60 * 1000;
|
||||
export const PAYOUTS_GC_TIME = 15 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The **admin** reconciliation surfaces are more volatile than the nurse read — within one session an admin
|
||||
* opens a draft, runs it, retries a failed payout, records a reference. A short `staleTime`, backed by
|
||||
* explicit invalidation on every mutation (`useRunPayoutBatch`/`useRetryPayout`/`useRecordTransferReference`).
|
||||
*/
|
||||
export const ADMIN_BATCHES_STALE_TIME = 30 * 1000;
|
||||
export const ADMIN_BATCH_DETAIL_STALE_TIME = 30 * 1000;
|
||||
/** Page size for the admin batch-detail payout rows (contract default 50). */
|
||||
export const ADMIN_BATCH_DETAIL_PAGE_SIZE = 50;
|
||||
|
||||
/** Page size for the earnings + payout-history lists (api-conventions `pageSize`). */
|
||||
export const PAYOUTS_PAGE_SIZE = 10;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { ADMIN_BATCH_DETAIL_STALE_TIME, PAYOUTS_GC_TIME } from '../constants';
|
||||
|
||||
/**
|
||||
* One batch expanded — its header + paginated per-payout rows (status, net, masked IBAN, transfer reference,
|
||||
* booking links). Disabled until a batch is selected (`batchId` present); each page keys separately and
|
||||
* `keepPreviousData` holds the prior page while the next loads. Invalidated by retry / record-reference.
|
||||
*/
|
||||
export function usePayoutBatchDetail(batchId: number | null, page: number) {
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.adminBatchDetail(batchId ?? -1, page),
|
||||
queryFn: () => payoutsApi.getPayoutBatchDetail(batchId as number, page),
|
||||
enabled: batchId != null && batchId > 0,
|
||||
staleTime: ADMIN_BATCH_DETAIL_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import { ADMIN_BATCHES_STALE_TIME, PAYOUTS_GC_TIME, PAYOUTS_PAGE_SIZE } from '../constants';
|
||||
import type { PayoutBatchFilters } from '../types';
|
||||
|
||||
/**
|
||||
* The admin reconciliation list of payout batches (newest first). The **filter + page params are part of the
|
||||
* query key** so each status filter / page caches independently; `keepPreviousData` avoids an empty flash
|
||||
* while paging or switching filters. Volatile relative to the nurse read — a short `staleTime`, refreshed by
|
||||
* explicit invalidation from the batch mutations.
|
||||
*/
|
||||
export function usePayoutBatches(filters: PayoutBatchFilters, page: number) {
|
||||
const params = { page, pageSize: PAYOUTS_PAGE_SIZE };
|
||||
return useQuery({
|
||||
queryKey: payoutKeys.adminBatches(filters, params),
|
||||
queryFn: () => payoutsApi.listPayoutBatches(filters, params),
|
||||
staleTime: ADMIN_BATCHES_STALE_TIME,
|
||||
gcTime: PAYOUTS_GC_TIME,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import type { PayoutBatchPreview } from '../types';
|
||||
|
||||
/**
|
||||
* The eligibility dry-run for a window — a **mutation**, not an auto-fetching query: it runs only when the
|
||||
* admin explicitly asks to preview (never on mount), and its result (the eligible/skipped breakdown + the
|
||||
* server's holiday-shifted processing date) is read from the mutation's `data`. The client renders it; it
|
||||
* never computes eligibility or the shifted date.
|
||||
*/
|
||||
export function usePreviewPayoutBatch() {
|
||||
return useMutation<PayoutBatchPreview, unknown, { periodStart: string; periodEnd: string }>({
|
||||
mutationFn: ({ periodStart, periodEnd }) => payoutsApi.previewPayoutBatch(periodStart, periodEnd),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Record a manually reconciled bank transfer reference on a payout (REQ-036 — b13 has `mark_failed` but no
|
||||
* record-reference route). On success we invalidate the owning batch's detail so the reference renders on the
|
||||
* row without a manual refresh.
|
||||
*/
|
||||
export function useRecordTransferReference() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { payoutId: number; reference: string; batchId: number }>({
|
||||
mutationFn: ({ payoutId, reference }) => payoutsApi.recordTransferReference(payoutId, reference),
|
||||
onSuccess: (_data, { batchId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* Re-submit a single `failed` payout to the bank rail. **Idempotency-keyed**: a re-fired retry with the same
|
||||
* key never re-pays. On success we invalidate the owning batch's detail (the row flips `failed → paid`, and
|
||||
* if it was the batch's last failure the batch re-settles `partially_failed → completed`) and the batches
|
||||
* list (its status may have changed).
|
||||
*/
|
||||
export function useRetryPayout() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, unknown, { payoutId: number; idempotencyKey: string; batchId: number }>({
|
||||
mutationFn: ({ payoutId, idempotencyKey }) => payoutsApi.retryPayout(payoutId, idempotencyKey),
|
||||
onSuccess: (_data, { batchId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: [...payoutKeys.adminBatchDetails(), batchId] });
|
||||
queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { payoutsApi } from '../apis';
|
||||
import { payoutKeys } from '../keys';
|
||||
import type { PayoutBatchSummary } from '../types';
|
||||
|
||||
/**
|
||||
* Open + run a payout batch for a window. **Idempotency-keyed**: the caller passes a stable `idempotencyKey`
|
||||
* per run so a retried submit converges on the same batch (never a double-run). On success we invalidate the
|
||||
* batches list so the new batch appears at the top without a manual refresh. Domain 4xx (e.g. no eligible
|
||||
* bookings) surface to the caller's `onError`.
|
||||
*/
|
||||
export function useRunPayoutBatch() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
PayoutBatchSummary,
|
||||
unknown,
|
||||
{ periodStart: string; periodEnd: string; idempotencyKey: string }
|
||||
>({
|
||||
mutationFn: ({ periodStart, periodEnd, idempotencyKey }) =>
|
||||
payoutsApi.runPayoutBatch(periodStart, periodEnd, idempotencyKey),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: payoutKeys.adminBatchLists() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6,3 +6,11 @@ export { useNurseEarningsBalance } from './hooks/useNurseEarningsBalance';
|
||||
export { useNurseEarnings } from './hooks/useNurseEarnings';
|
||||
export { useNursePayoutHistory } from './hooks/useNursePayoutHistory';
|
||||
export { useNursePayoutDetail } from './hooks/useNursePayoutDetail';
|
||||
|
||||
// Admin batch actions (b13 admin_payouts/*).
|
||||
export { usePayoutBatches } from './hooks/usePayoutBatches';
|
||||
export { usePayoutBatchDetail } from './hooks/usePayoutBatchDetail';
|
||||
export { usePreviewPayoutBatch } from './hooks/usePreviewPayoutBatch';
|
||||
export { useRunPayoutBatch } from './hooks/useRunPayoutBatch';
|
||||
export { useRetryPayout } from './hooks/useRetryPayout';
|
||||
export { useRecordTransferReference } from './hooks/useRecordTransferReference';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EarningsState } from './types';
|
||||
import type { PageParams } from '@/lib/api/types';
|
||||
import type { EarningsState, PayoutBatchFilters } from './types';
|
||||
|
||||
/**
|
||||
* React Query key factory for the payouts domain (hierarchical, per the `services/{domain}` pattern).
|
||||
@@ -22,4 +23,14 @@ export const payoutKeys = {
|
||||
|
||||
details: () => [...payoutKeys.all, 'detail'] as const,
|
||||
detail: (payoutId: number) => [...payoutKeys.details(), payoutId] as const,
|
||||
|
||||
// Admin batch actions. The filter object + page params are part of the key (so each filter/page caches
|
||||
// separately); mutations invalidate the `adminBatchLists` / `adminBatchDetails` prefixes.
|
||||
adminBatchLists: () => [...payoutKeys.all, 'admin_batches'] as const,
|
||||
adminBatches: (filters: PayoutBatchFilters, params: PageParams) =>
|
||||
[...payoutKeys.adminBatchLists(), filters, params] as const,
|
||||
|
||||
adminBatchDetails: () => [...payoutKeys.all, 'admin_batch_detail'] as const,
|
||||
adminBatchDetail: (batchId: number, page: number) =>
|
||||
[...payoutKeys.adminBatchDetails(), batchId, page] as const,
|
||||
};
|
||||
|
||||
@@ -173,14 +173,123 @@ export interface EarningsListParams extends PageParams {
|
||||
state?: EarningsState;
|
||||
}
|
||||
|
||||
// ── Admin payout-batch actions (b13 `admin_payouts/*`) ────────────────────────────────────────────────
|
||||
//
|
||||
// The admin side of the same weekly engine: preview eligible earnings, open/run a batch, read batches +
|
||||
// per-payout rows, retry a failed payout, record a reconciled transfer reference. These map published b13
|
||||
// routes 1:1 (`clientApi.ts`) but the domain stays **mock-primary** this phase (see `constants.ts`).
|
||||
// Money is IRR digit-strings; the client renders the **server's** eligibility + holiday-shifted date — it
|
||||
// never computes them. Invariants: per eligible nurse / payout `gross − clawback = net`; a batch's
|
||||
// `totalAmount = Σ its payouts' net`; a payout's booking links sum to its `grossEarningsIrr`.
|
||||
|
||||
/** One nurse's payout-eligible, unpaid earnings for a window (`EligibleNurseEarningsDto`). A nurse missing a
|
||||
* verified primary IBAN is **flagged** (`hasVerifiedPrimaryIban:false`) here, not dropped. */
|
||||
export interface EligibleNurseEarnings {
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
bookingCount: number;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
/** `= grossEarningsIrr − clawbackAppliedIrr` (clawbacks netted into the preview). */
|
||||
netAmountIrr: string;
|
||||
hasVerifiedPrimaryIban: boolean;
|
||||
}
|
||||
|
||||
/** A nurse excluded from a generated batch, with the reason (`SkippedNurseDto`; e.g. `no_verified_primary_iban`). */
|
||||
export interface SkippedNurse {
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
grossEarningsIrr: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** A `nurse_payout_batches` header for the admin reconciliation list (`PayoutBatchDto` + `holidayShifted`). */
|
||||
export interface PayoutBatchSummary {
|
||||
id: number;
|
||||
/** Holiday-shifted server-side; ISO dates `YYYY-MM-DD`. */
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
/** `= Σ its payouts' net_amount_irr`. IRR digit-string. */
|
||||
totalAmount: string;
|
||||
payoutCount: number;
|
||||
status: PayoutBatchStatus;
|
||||
processedAt: string | null;
|
||||
failureNotes: string | null;
|
||||
createdAt: string;
|
||||
/** Whether `processingDate` was shifted off a bank-closed day (server truth; the client only renders it). */
|
||||
holidayShifted: boolean;
|
||||
}
|
||||
|
||||
/** The dry-run before a batch: the eligible nurses, the ones that would be skipped, and the shifted date. */
|
||||
export interface PayoutBatchPreview {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
processingDate: string;
|
||||
holidayShifted: boolean;
|
||||
eligible: EligibleNurseEarnings[];
|
||||
skipped: SkippedNurse[];
|
||||
/** `= Σ eligible.netAmountIrr`. IRR digit-string. */
|
||||
totalNetIrr: string;
|
||||
}
|
||||
|
||||
/** One `nurse_payouts` row expanded for the admin batch detail (`PayoutDto`). Every row reconciles:
|
||||
* `grossEarningsIrr − clawbackAppliedIrr = netAmountIrr`; on a clean payout `amountIrr = netAmountIrr`. */
|
||||
export interface AdminPayoutRow {
|
||||
id: number;
|
||||
nurseId: number;
|
||||
nurseName: string | null;
|
||||
/** Masked, **last-4 only** — an encrypted field; never a full IBAN. */
|
||||
maskedIban: string;
|
||||
grossEarningsIrr: string;
|
||||
clawbackAppliedIrr: string;
|
||||
netAmountIrr: string;
|
||||
/** What was actually transferred (`PayoutDto.amount`); `= netAmountIrr` on a clean payout. */
|
||||
amountIrr: string;
|
||||
status: PayoutStatus;
|
||||
transferReference: string | null;
|
||||
paidAt: string | null;
|
||||
/** `failed` only. Empty otherwise. */
|
||||
failureReason: string | null;
|
||||
/** The bookings this payout covered; `Σ payoutAmountIrr = grossEarningsIrr`. */
|
||||
bookings: { bookingId: number; sessionId: number | null; payoutAmountIrr: string }[];
|
||||
}
|
||||
|
||||
/** A batch header + its paginated payout rows (`PayoutBatchDetailDto`). */
|
||||
export interface AdminPayoutBatchDetail {
|
||||
batch: PayoutBatchSummary;
|
||||
payouts: AdminPayoutRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** `listPayoutBatches` filter — the optional status is part of the query key so each filter caches separately. */
|
||||
export interface PayoutBatchFilters {
|
||||
status?: PayoutBatchStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* selection is by config (`USE_PAYOUTS_MOCK`), never scattered `if (mock)` checks.
|
||||
*
|
||||
* The **nurse read** methods are all reads (a nurse never writes payout state). The **admin** methods
|
||||
* (`admin_payouts/*`) add the batch actions: previewing eligibility, running a batch, retrying a failed
|
||||
* payout, and recording a transfer reference. `runPayoutBatch`/`retryPayout` are **idempotency-keyed** —
|
||||
* the same key returns the same result (never a double-pay).
|
||||
*/
|
||||
export interface PayoutsApi {
|
||||
// Nurse read side.
|
||||
getNurseEarningsBalance(): Promise<NurseEarningsSummary>;
|
||||
getNurseEarnings(params: EarningsListParams): Promise<Paginated<NurseEarningsItem>>;
|
||||
getNursePayoutHistory(params: PageParams): Promise<Paginated<NursePayoutHistoryItem>>;
|
||||
getNursePayoutDetail(payoutId: number): Promise<NursePayoutDetail>;
|
||||
|
||||
// Admin batch actions.
|
||||
listPayoutBatches(filters: PayoutBatchFilters, params: PageParams): Promise<Paginated<PayoutBatchSummary>>;
|
||||
previewPayoutBatch(periodStart: string, periodEnd: string): Promise<PayoutBatchPreview>;
|
||||
runPayoutBatch(periodStart: string, periodEnd: string, idempotencyKey: string): Promise<PayoutBatchSummary>;
|
||||
getPayoutBatchDetail(batchId: number, page: number): Promise<AdminPayoutBatchDetail>;
|
||||
retryPayout(payoutId: number, idempotencyKey: string): Promise<void>;
|
||||
recordTransferReference(payoutId: number, reference: string): Promise<void>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user