6.2 KiB
6.2 KiB
Backend Phase 13 report — Weekly nurse payouts (mocked bank transfer)
Date: 2026-07-09 · Track: backend · Status: complete, gate green.
What was built
payoutsschema, 3 tables (one migrationNursePayoutEngine):NursePayoutBatches— weekly aggregation;period_end/processing_dateholiday-shifted;total_amount/payout_count;status(draft|processing|partially_failed|completed|failed);initiated_by_admin_idFK.NursePayouts— one per nurse per batch; DB CHECKnet = gross − clawback+ all ≥ 0; encryptediban_snapshot(EF converter) frozen from the verified primary account;status(pending|submitted|paid|failed, forward-only);transfer_reference,paid_at,failure_reason.NursePayoutBookingLinks— unconditionalUNIQUE(booking_id)(the one-payout-per-booking-ever guard); nullablesession_idfor a future per-session model.
- Domain:
PayoutBatchStatus/PayoutStatus+*Transitions;LedgerPosting.NursePayout(DEBIT nurse_payable / CREDIT escrow_held) +LedgerPosting.ClawbackRecovery(DEBIT nurse_payable / CREDIT nurse_clawback_receivable);NurseClawback.Recover(payoutId, now). - Application:
Features/Payouts/{Commands|Queries}—ComputeEligibleEarnings,GeneratePayoutBatch(build+link inline),ExecutePayoutBatch,RetryFailedPayout,MarkPayoutFailed,GetBatchDetail,ListPayoutBatches,GetNursePayoutHistory; sharedPayoutSettlement(ledger post + clawback netting).IPayoutRepositoryonIUnitOfWork. - Infrastructure:
PayoutRepository,PayoutsConfig/*,MockBankTransferProvider(+BankTransferOptions), the authoritativeNursePayoutLinkStatusService(swapped for the deleted interimNursePayoutStatusService),iban_snapshotencryption wired inApplicationDbContext, 2 newplatform_configsseeds. - API:
AdminPayoutsController(admin, rate-limited) +NursePayoutsController(nurse, tenancy-scoped).
What is now testable and exactly how (per §7 of the phase)
Seed a few completed bookings via the admin flow: some with dispute_window_ends_at in the past (eligible), some
future (not yet), one disputed, one with a pending clawback; one nurse with a verified primary IBAN, one without.
- Eligibility preview —
GET admin_payouts/eligible?periodStart=&periodEnd=→ only completed + dispute-window- closed, unpaid bookings appear, grouped by nurse; future + disputed excluded; the no-IBAN nurse flagged (hasVerifiedPrimaryIban=false). (unit:Preview_includes_only_closed_window_and_flags_missing_iban) - Generate a batch —
POST admin_payouts/batches→ adraftbatch, one payout per eligible nurse; the nurse with a pending clawback showsclawbackAppliedIrr>0andnet = gross − clawback;total_amount = Σ net;iban_snapshotpopulated (encrypted, served masked). (unit:Generate_materializes_one_payout_per_nurse_and_nets_clawback,Generate_skips_nurse_without_verified_primary_iban_with_reason) - Double-pay guard — a second generate over the same window doesn't re-select the linked bookings.
(unit:
Double_pay_guard_second_generate_does_not_reselect_linked_bookings) - Holiday shift —
period_end/processing_dateshift off a seeded bank-closed day. (unit:Holiday_shifts_period_end_and_processing_date) - Execute —
POST admin_payouts/batches/{id}/process→ payouts gopaidwith atransfer_reference; the ledger shows balancedDEBIT nurse_payable / CREDIT escrow_heldper payout (the payable balance drops by the paid amount); a netted clawback is markedrecoveredwithrecovered_in_payout_id. (unit:Execute_posts_balanced_payout_ledger_and_drains_payable,Execute_recovers_clawback_and_posts_recovery_leg) - Idempotency — re-process → no second transfer / ledger group. (unit:
Reprocess_is_idempotent_no_second_ledger_group) - Failure / retry — force a rail failure →
partially_failed;retry(rail back to success) →paid, batchcompleted. (unit:Partial_failure_then_retry_completes_the_batch) - Nurse history —
GET nurse_payouts/historyas the nurse → their payouts (masked IBAN, net, reference); another nurse's are invisible. (api:NursePayoutsApiTests)
API happy-path/401/400: AdminPayoutsApiTests (generate→process pays the nurse; 401 unauth; 400 bad period; list)
and NursePayoutsApiTests (401 unauth; own paid payout with masked IBAN).
What is mocked + how to make it real
IBankTransferProvider(🟡) — PAYA/SATNA rail. Make real = a Jibit/Vandar/Sadad payout adapter with a registered source settlement account, per-nurse verified Sheba, PAYA-vs-SATNA selection, batch caps/minimums, and the async reconciliation callback that flipssubmitted → paid/failed. Config keysSeams:BankTransfer:*.INursePayoutStatus(🟢) — now the real link-based lookup (NursePayoutLinkStatusService).- Reused mocks:
IHolidayCalendar,IFieldEncryptor,IDistributedLock,ICacheService.
Contracts produced
dev/contracts/domains/payouts.md(new) ·dev/contracts/openapi/swagger.v1.jsonrefreshed (7 payout paths).
Confirmed rules recorded (product/business/10-payouts.md §d1)
- Clawback netting recovers whole clawbacks up to a batch's earnings (never negative net, never a partial single clawback); a clawback larger than a batch's earnings waits for a later batch. Recovery is a real ledger movement.
- A booking with an active refund is held out of payouts (the operational reading of "no open dispute").
payout_satna_threshold_irrpicks PAYA vs SATNA;require_bnpl_settlement_for_payout(default off) gates BNPL.
Follow-ups (deferred)
- The weekly cron scheduler (PAYA-aligned) — entry point is
GeneratePayoutBatchCommand; cadence innurse_payout_interval_days. On-demand/instant withdrawal; per-nurse payout frequency; automated clawback recovery beyond next-batch netting; the BNPLsettled_attiming guard (flag shipped, off).
Gate
dotnet build Baya.sln — 0 errors, 0 new code warnings (only pre-existing NU1510/NETSDK1057/NU1903).
dotnet test Baya.sln — 329 pass (223 foundation + 102 api + 4 identity), 0 fail.