4.5 KiB
Phase 11 — Nurse pay / payouts
Blocker: blockers.md § "Nurse pay," and closes §B.6 (real bank-transfer rail) as a side effect — see the
note at the end.
Depends on: nothing to start; benefits from 01-admin-rbac.md to actually exercise the
admin-side actions with the seeded finance account.
Three distinct sub-issues, all independently real.
11a. The mock hides four already-real endpoints
client/src/services/payouts/constants.ts:13 —
USE_PAYOUTS_MOCK = true, with a stale comment claiming 3 of 4 nurse reads are server gaps. All four are
real (NursePayoutsController.cs:27-48, fully implemented handlers, and clientApi.ts:162-271 already
correctly calls them). The mock/comment simply predate the server catching up (server phase merged after the
client wrote the mock).
Fix: flip USE_PAYOUTS_MOCK to false. Two secondary defects surface immediately and should land in the
same change: clientApi.ts:60 hardcodes failureReason: null though the wire carries it; and
previewPayoutBatch (clientApi.ts:205-227) computes money client-side (sums, fabricates a processing
date) — violates the client's own hard rule 18 ("the client never computes money"). The real fix needs a
server-side preview endpoint (already tracked as REQ-036); until that lands, this is a documented gap, not
something to silently patch client-side.
11b. No client entry point for the one irreversible action
The real, correct implementation already
exists: AdminPayoutsController.cs:49-52, POST admin_payouts/batches/{id}/process →
ExecutePayoutBatchCommand.Handler.cs (submits to IBankTransferProvider, posts the ledger, nets clawbacks,
idempotent). PayoutsApi has no method for it at all — grepped the whole client, nothing calls /process.
The admin UI's scary-looking "Run" button with a typed confirmation
(admin/payouts/page.tsx:220-235, useRunPayoutBatch.ts) actually calls Generate
(POST admin_payouts/batches, draft-only, moves no money) — the UX implies it sends money; it doesn't. A
generated batch sits in draft forever with no way to advance it.
Fix: add processPayoutBatch(batchId) to PayoutsApi (both clientApi.ts/mockApi.ts), a
useProcessPayoutBatch hook, and a distinct, clearly-separate "Process" action on
admin/payouts/[batchId]/page.tsx (gated on caps.canPayout). Relabel the existing "Run"/"payout_run" copy
so it's honestly "Generate a draft batch," not "send money." Bundle in the same change: POST admin_payouts/{id}/mark_failed also exists server-side with no client op — add the console action to record
a manually-reconciled bank rejection (this is also the forgotten-features.md item
"no way to confirm a payout succeeded or failed").
(This is also where blockers.md §B.6's "mock the bank-transfer payment pages for now" mostly resolves
itself: IBankTransferProvider/MockBankTransferProvider already exist server-side — the "mock" is already
built and DI-registered. What's actually missing is exactly 11a/11b/11c — the client UI in front of that
existing mock. No new bank-transfer mock needs to be invented; de-mocking + adding the process/mark-failed
actions above is the whole of it.)
11c. "Paid" is set at link-time, not bank-confirmation-time
PayoutRepository.cs:279, inside DeriveEarningsState:
if (payout is not null)
return NurseEarningsState.Paid;
payout is non-null as soon as GeneratePayoutBatchCommand links the booking to a batch
(GeneratePayoutBatchCommand.Handler.cs:107-112) — while NursePayout.Status still defaults to Pending.
The struct carries Status already; it's fetched and never read here.
Fix: gate on payout.Status == PayoutStatus.Paid instead — the definition the codebase already treats as
authoritative elsewhere (NursePayoutLinkStatusService.cs:22-33). For the mocked bank rail this'll be set at
process time; for a real async rail it should only flip via the reconciliation webhook
(WebhooksPayoutsController.cs:33-43).
Flag: once fixed, a linked-but-unpaid booking falls into no bucket at all in the 4-state summary model
(pending/eligible/paid/clawback_applied — none currently represents "batched, awaiting settlement").
Needs a product decision: add a state, or fold it into eligible. Separately flagged (needs its own look,
not necessarily the same root cause): the earnings-balance buckets and the raw ledger net don't reconcile on
a live probe — which one is authoritative isn't documented; don't pick one silently.