frontend phase 15

This commit is contained in:
hamid
2026-07-10 20:28:06 +03:30
parent bc51cf59b4
commit 70cf00ce4a
151 changed files with 10711 additions and 44 deletions
@@ -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 }),
});
},
};
+371 -1
View File
@@ -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);
},
};
+10
View File
@@ -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() });
},
});
}
+8
View File
@@ -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';
+12 -1
View File
@@ -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,
};
+111 -2
View File
@@ -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>;
}