Files
baya-monorepo/dev/phases/backend/backend-phase-13.md
T
2026-06-28 21:59:59 +03:30

28 KiB
Raw Blame History

Backend Phase 13 — Weekly nurse payouts (mocked bank transfer)

Mission: pay nurses what they have earned. Build the weekly payout engine that aggregates payout-eligible, unpaid bookings/sessions into a nurse_payout_batches run, fans them out to one nurse_payouts row per nurse (netting any pending clawback the nurse owes back), links each booking under a UNIQUE guard so it can never be paid twice, snapshots the nurse's verified primary IBAN, submits the transfers through a mocked PAYA/SATNA bank rail, and posts the outbound nurse_payable ledger movement — all holiday-aware so a Nowruz-landing batch shifts off bank-closed days. This is the last money-out phase; after this a nurse's earnings are real.

Track: backend · Depends on: b10 (ledger / nurse_payable), b11 (clawbacks), b9 (booking/session eligibility, dispute window), b3 (nurse bank accounts), b1 (iranian_holidays) · Unlocks: nurse earnings; frontend f12-b13 Before you start, read ../_shared/agent-operating-rules.md. It is not optional.


1. Context — where this sits

This is backend phase b13, the final money-out leg of the payments arc (b10 ledger → b11 refunds/clawbacks/invoices → b12 BNPL → b13 payouts). The platform never custodies cash: "escrow" is an internal double-entry ledger state (product/payments/escrow-ledger.md), and a nurse's owed balance lives in ledger_entries as the nurse_payable account. This phase drains that accrual to a real bank transfer, once per booking, only after the dispute window has closed — the one irreversible step in the whole money flow. Because an Iranian PAYA/SATNA transfer cannot be charged back, eligibility gating and the clawback fallback (b11) are what protect the platform from overpaying.

What already exists (do not rebuild) — built by prior phases:

  • The ledger & nurse_payable accrualb10 built ledger_entries (append-only, balanced, transaction_group_id, the six account_types incl. escrow_held, nurse_payable, nurse_clawback_receivable), payment_transactions, payment_webhook_events, the card-capture posting (DEBIT escrow_held / CREDIT platform_revenue + nurse_payable), the IDistributedLock Redis-lock pattern on the money path, and GetNursePayableBalance (sum of nurse_payable legs). Reuse the ledger posting helper and the lock — do not re-implement them.
  • Clawbacksb11 built nurse_clawbacks (nurse_id, booking_id, refund_id, original_payout_id, amount_irr, statuspending|recovered|written_off, recovered_in_payout_id, resolved_at) and the nurse_clawback_receivable ledger leg. This phase nets pending clawbacks into a payout and marks them recovered — it does not create them.
  • Bookings, sessions & the dispute windowb9 built bookings (the three-amount split gross_price_irr = balinyaar_commission_irr + nurse_payout_amount), booking_sessions (per-visit visit_payout_amount, payout_eligible_at), visit_verifications (EVV), and the dispute_window_ends_at set on completion (completed_at + config(dispute_window_hours, 72)). The payout_released boolean was deliberately CUT — never reintroduce it.
  • Nurse bank accountsb3 built nurse_bank_accounts (iban enc, iban_hash UNIQUE, is_primary filtered-UNIQUE per nurse, is_verified, matched_national_id, account_holder_from_bank, ownership_vendor_ref) and the IBankAccountOwnershipVerifier seam. This phase reads the verified primary account and snapshots its IBAN — it does not register or verify.
  • iranian_holidaysb1 seeded the holiday calendar (holiday_date, is_bank_closed) behind the IHolidayCalendar seam. Reuse IHolidayCalendar for date shifting.
  • The platform config accessorb1's typed, cached platform_configs reader. Read dispute_window_hours (already used by b9) and any payout-window config through it; never hardcode.
  • The b0 foundation: REST surface, BaseController, OperationResult<T>, CQRS via martinothamar/Mediator, IFieldEncryptor (for iban_snapshot), ICurrentUser + audit interceptor, rate limiting, IDateTimeProvider.

What this phase introduces: the three payout tables, the eligibility/build/link/execute capabilities, and one new seam — IBankTransferProvider (the mocked PAYA/SATNA rail). The weekly cron scheduler is DEFERRED — batches are triggered manually by an admin endpoint now (see §3, SchedulePayoutJob DEFERRED).

2. Required reading (do this first)

  • ../_shared/agent-operating-rules.md and ../_shared/backend-conventions-checklist.md — especially the Performance, caching, money, idempotency block (IRR BIGINT, append-only balanced ledger, idempotent money writes, Redis lock on the money path).
  • product/business/10-payouts.mdthe business rules: weekly batches, EVV + dispute-window gating, one-payout-per-booking, clawback netting, holiday-aware scheduling, verified-primary-IBAN destination, MVP vs DEFERRED (no on-demand withdrawal).
  • product/payments/cancellation-and-payout.mdQ2 "who pays the nurse, and when": the nurse payout is gross_price_irr balinyaar_commission_irr, identical and on the identical weekly timing whether the family paid by card or BNPL; the BNPL provider's commission never touches the nurse; the optional settled_at timing guard.
  • product/data-model/07-payouts.mdthe canonical schema for nurse_payout_batches, nurse_payouts (incl. the gross_earnings_irr / clawback_applied_irr / net_amount_irr additions), and nurse_payout_booking_links (the booking_id UNIQUE guard). Mirror these field names exactly.
  • product/payments/escrow-ledger.md — the payout ledger posting (DEBIT nurse_payable / CREDIT escrow_held for nurse_payout_amount) and the clawback leg.
  • Code to mirror: b10's ledger posting helper + IDistributedLock usage, the payment_webhook_events idempotency pattern, and any Features/Payments/** command structure; b11's nurse_clawbacks config & Features/Refunds/**; b9's bookings/booking_sessions configs and the eligibility columns; b3's nurse_bank_accounts config; b1's IHolidayCalendar and the typed config accessor.
  • Contract conventions: ../../contracts/conventions/api-conventions.md and money-and-types.md (IRR BIGINT, envelope).
  • Prior handoffs: dev/shared-working-context/backend/handoff/after-backend-phase-10.md, …-11.md, …-9.md, …-3.md, …-1.md, and reports/mocks-registry.md (seam rows you reuse/add).

3. Scope — build this

All money is IRR long / BIGINT. Features live under Baya.Application/Features/Payouts/{Commands|Queries}/<Name>/; entities in Baya.Domain/Entities/Payouts/; one IEntityTypeConfiguration<T> per entity in Persistence/Configuration/PayoutsConfig/; one EF migration for the three tables.

3.1 Entities + migration

nurse_payout_batches [CORE] — weekly aggregation, admin/job-initiated, holiday-aware.

  • Fields: id, period_start, period_end (holiday-shifted off is_bank_closed days), processing_date (holiday-shifted), total_amount (BIGINT), payout_count (int), status (enum, see below), initiated_by_admin_id (FK users), processed_at (nullable), failure_notes (nullable), audit fields.
  • CHECK / invariant: total_amount = Σ(nurse_payouts.net_amount_irr) for the batch — enforce in the handler when materializing rows; add a DB CHECK where SQL Server allows (else a verified invariant in ExecutePayoutBatch). payout_count = COUNT(nurse_payouts).
  • Relations: 1:N → nurse_payouts.

nurse_payouts [CORE] — one row per nurse per batch.

  • Fields: id, batch_id (FK), nurse_id (FK nurse_profiles), bank_account_id (FK nurse_bank_accounts), iban_snapshot (encrypted via IFieldEncryptor, frozen at build time), gross_earnings_irr (BIGINT — Σ eligible booking/session payouts), clawback_applied_irr (BIGINT — pending clawbacks netted this batch, ≥ 0), net_amount_irr (BIGINT — gross_earnings_irr clawback_applied_irr), amount (BIGINT — actually transferred net; equals net_amount_irr on success), booking_count (int), status (enum), transfer_reference (nullable — the bank track id), paid_at (nullable), failure_reason (nullable), audit fields.
  • Invariant (handler + CHECK where possible): net_amount_irr = gross_earnings_irr clawback_applied_irr; all amounts ≥ 0; net_amount_irr ≥ 0 (a nurse whose clawback exceeds earnings nets to zero this batch with the remainder staying pending — see §5; never produce a negative transfer).
  • Relations: N:1 → nurse_payout_batches, nurse_profiles, nurse_bank_accounts; 1:N → nurse_payout_booking_links; referenced by nurse_clawbacks.recovered_in_payout_id.

nurse_payout_booking_links [CORE] — the structural anti-double-pay guard.

  • Fields: id, payout_id (FK nurse_payouts), booking_id (FK bookings) UNIQUE, session_id (nullable FK booking_sessions — set when paying per-session accrual), payout_amount_irr (BIGINT — the portion of this booking/session in this payout), audit fields.
  • The booking_id UNIQUE index is the hard guard — a booking can be linked to exactly one payout across all batches, ever. Do not drop it. (When paying per-session, the unique guard is on (booking_id, session_id) so each session is paid once — confirm against b9's session model; the booking-level booking_id UNIQUE still holds for single-session bookings.)
  • Relations: N:1 → nurse_payouts; 1:1 → bookings (and per-session → booking_sessions).

Status enums (define as proper enums, persist as string/byte per project convention):

  • PayoutBatchStatus: draft | processing | partially_failed | completed | failed.
  • PayoutStatus: pending | submitted | paid | failed.

3.2 Commands & queries (CQRS, OperationResult, never throw for expected failures)

Capability Type Route (admin/nurse) What it does
ComputeEligibleEarningsQuery Query GET api/v1/admin_payouts/eligible?period_start=&period_end= Projects (AsNoTracking + .Select) the payout-eligible, unpaid bookings/sessions for the window and groups them by nurse, returning a preview (per-nurse gross_earnings_irr, pending clawback_applied_irr, net_amount_irr, booking count). Eligible = status='completed' AND dispute_window_ends_at < now (per-session: payout_eligible_at < now) AND no open dispute AND not already in a nurse_payout_booking_links row. Paginated.
GeneratePayoutBatchCommand Command POST api/v1/admin_payouts/batches Opens a nurse_payout_batches row in draft. Computes period_end/processing_date and shifts them off is_bank_closed days via IHolidayCalendar to the next business day. Selects the eligible set (same predicate as the query) under a lock(payout:batch) so two runs can't grab the same bookings. Orchestrates BuildNursePayouts + LinkPayoutBookings inside one unit of work. Returns the draft batch with materialized payouts for admin preview. Idempotent: re-running for an overlapping window cannot re-select an already-linked booking (the UNIQUE link is the backstop).
BuildNursePayouts Command (internal step) Groups the eligible bookings per nurse; computes gross_earnings_irr = Σ(gross_price_irr balinyaar_commission_irr) (per-session: Σ visit_payout_amount); reads the nurse's pending nurse_clawbacks, nets them into clawback_applied_irr (capped at gross_earnings_irr), sets net_amount_irr and amount; snapshots iban_snapshot from the nurse's verified primary nurse_bank_accounts (is_primary=1 AND is_verified=1 AND matched_national_id=1). A nurse with no verified primary account is skipped with a recorded reason (not silently dropped).
LinkPayoutBookings Command (internal step) Inserts nurse_payout_booking_links rows under the booking_id UNIQUE constraint. A duplicate-key violation is the already-paid guard — catch it, treat that booking as not-eligible, and continue (never let it abort the batch or double-pay).
ExecutePayoutBatchCommand Command POST api/v1/admin_payouts/batches/{id}/process Transitions draft → processing. Under lock(payout:batch) (and lock(nurse:{id}:payout) per row), submits the batch to IBankTransferProvider.SubmitPayoutBatchAsync with one PayoutInstruction per payout (nurse IBAN, net_amount_irr, PAYA/SATNA method), stores each transfer_reference, transitions payouts to submitted then paid, posts the payout ledger group (DEBIT nurse_payable / CREDIT escrow_held for nurse_payout_amount, per payout, balanced, append-only) via b10's helper, and marks each netted clawback recovered with recovered_in_payout_id + resolved_at. Batch ends completed (all paid) or partially_failed (some failed). Idempotent: carries an idempotencyKey so a retried call never re-submits an already-paid payout or double-posts the ledger.
RetryFailedPayoutCommand Command POST api/v1/admin_payouts/{payout_id}/retry Re-submits a single failed payout (holiday-aware: won't submit on a closed day), updating transfer_reference/status. Idempotent on the same key.
MarkPayoutFailedCommand Command POST api/v1/admin_payouts/{payout_id}/mark_failed Records failure_reason/failure_notes, sets status='failed'. Used on a reconciled bank rejection. Does not post a ledger movement (no money left).
GetBatchDetailQuery Query GET api/v1/admin_payouts/batches/{id} Batch header + its payouts (status, net, transfer_reference) + per-payout linked bookings. Projected, paginated.
ListPayoutBatchesQuery Query GET api/v1/admin_payouts/batches?status=&page=&page_size= Admin reconciliation list. Projected + paginated.
GetNursePayoutHistoryQuery Query GET api/v1/nurse_payouts/history?page=&page_size= The nurse's own payouts (tenancy-scoped to ICurrentUser): status, net_amount_irr, transfer_reference, paid_at, masked IBAN, any clawback applied. Projected + paginated. Feeds f12's earnings screen.
  • Controllers: AdminPayoutsController (admin policy, payout-sensitive endpoints rate-limited) and NursePayoutsController (nurse policy, tenancy-scoped). Both sealed : BaseController, inject ISender, return base.OperationResult(...), snake_case [controller]/[action] routes, CancellationToken threaded.
  • Validators: FluentValidation on GeneratePayoutBatchCommand (period_start ≤ period_end, not in the future) and the id-bearing commands.

3.3 DEFERRED (build the seam/flag, not the feature)

  • SchedulePayoutJob — the recurring weekly cron trigger (PAYA-aligned). DEFERRED: batches are admin-triggered now. Leave a clean entry point (the GeneratePayoutBatchCommand the cron will call) and a config key for the cadence; note it in the report. (Roadmap: a hosted scheduler later.)
  • On-demand / instant nurse withdrawal, per-nurse configurable payout frequency, automated clawback recovery beyond next-batch netting — DEFERRED per product/business/10-payouts.md §(c).
  • The optional BNPL settled_at timing guard (don't pay before BNPL cash is actually received) — expose it as a config flag (require_bnpl_settlement_for_payout, default off) and apply it in the eligibility predicate when set; do not hard-couple payouts to BNPL settlement. Note in the report.

4. Mocks & seams in this phase

Seam Owner Mock behaviour Registry
IBankTransferProvider introduced here SubmitPayoutBatchAsync(PayoutBatchId, IReadOnlyList<PayoutInstruction>, idempotencyKey, ct) returns a deterministic externalBatchRef + a per-instruction transfer_reference, status Submitted then Paid for all rows (no money moves); GetPayoutStatusAsync(externalBatchRef, ct) echoes Paid. PAYA vs SATNA selection: mock honours the method on each PayoutInstruction (choose SATNA for high-value rows above a config threshold, else PAYA) and records it. A config switch can force a deterministic failure (closed-day / insufficient-provider-balance) so partially_failed/retry paths are testable. add a new row (🟡)
IHolidayCalendar reuse from b1 static seeded iranian_holidays; used to shift period_end/processing_date off is_bank_closed days. reuse row
IFieldEncryptor reuse from b0 local symmetric key; encrypts iban_snapshot, never logs plaintext. reuse row
IDistributedLock reuse from b10 in-memory mock lock; lock(payout:batch) + lock(nurse:{id}:payout). reuse row
ICacheService reuse from b0/b1 in-memory; behind the typed config accessor. reuse row

The mock lives behind a DI-registered interface in Infrastructure (real impl is a drop-in later); a real JibitBankTransferProvider / VandarPayoutProvider selection is config-driven, never an if (mock) branch in a handler. Append the IBankTransferProvider row to ../../shared-working-context/reports/mocks-registry.md (seam, file, what's faked, config keys, step-by-step how to make it real — Jibit transferor / Vandar payout API, registered source settlement account, each nurse's verified Sheba, PAYA-vs-SATNA selection, batch caps/minimums, the reconciliation callback that flips submitted → paid/failed).

5. Critical rules you must not get wrong

Money correctness is sacred — the following must hold verbatim:

  • Money is IRR BIGINT, no floats, ever. Every amount (gross_earnings_irr, clawback_applied_irr, net_amount_irr, amount, payout_amount_irr, total_amount) is long/BIGINT. No float path.
  • One payout per booking — nurse_payout_booking_links.booking_id UNIQUE is the hard guard; never pay a booking in two batches. A duplicate insert is the already-paid signal; treat it as not-eligible and continue. Do not bypass the constraint across batches.
  • Payout eligibility requires dispute_window_ends_at (or per-session payout_eligible_at) passed AND no open dispute — never pay on completed alone. EVV completion alone is not enough; the dispute window must have closed.
  • Net prior clawbacks before transfer: net_amount = gross_earnings clawback, and mark recovered clawbacks (status='recovered', recovered_in_payout_id, resolved_at). Don't overpay a nurse who owes money back. If pending clawbacks exceed this batch's earnings, net to zero (never negative) and leave the remainder pending for the next batch.
  • 'paid' derives from a nurse_payout_booking_links link row + a ledger movement out of nurse_payable — the payout_released boolean is gone. Never reintroduce it; never infer paid-state from a status flag alone.
  • Append-only, balanced ledger. The payout posts DEBIT nurse_payable nurse_payout_amount / CREDIT escrow_held nurse_payout_amount per payout, in one transaction_group_id, Σdebit = Σcredit, via b10's helper. Never UPDATE/DELETE a ledger row; corrections are new balancing postings. The nurse's payable balance is derived from the ledger and may go negative (clawbacks) — don't clamp it to zero.
  • Holiday-aware shifting: a Nowruz batch must move off bank-closed days. Shift period_end and processing_date to the next is_bank_closed=0 day via IHolidayCalendar, or PAYA/SATNA fails.
  • total_amount = Σ payouts must hold per batch (CHECK / verified invariant); payout_count = COUNT(payouts).
  • Gross = commission + payout: the payout amount is gross_price_irr balinyaar_commission_irr (the booking's own split), never a BNPL provider's settled_amount_irr; bnpl_commission_irr is a platform expense and never touches the nurse. The nurse's pay is invariant to payment method.
  • Webhook / transfer idempotency: the bank submit carries an idempotencyKey and the payout status state machine (pending → submitted → paid) is forward-only, so a retried ExecutePayoutBatchCommand never double-sends an irreversible transfer or double-posts the ledger. Redis lock(payout:batch) is the fast first line; the status state machine + the link UNIQUE are the authoritative backstop.
  • First payout is gated on the nurse's matched_national_id (b3): only a verified primary IBAN (is_primary=1 AND is_verified=1 AND matched_national_id=1) may receive a transfer. Snapshot that IBAN into iban_snapshot (encrypted) and store the transfer_reference for reconciliation.
  • Real bank transfers are effectively irreversible — which is why payout is dispute-window-gated and refund-after-payout falls back to a clawback (b11), not a transfer reversal. Treat the execute step as the point of no return.
  • Tenancy: GetNursePayoutHistoryQuery is scoped to the authenticated nurse via ICurrentUser; a nurse can never read another nurse's payouts. Admin endpoints sit behind the admin policy and are rate-limited.

6. Definition of Done

The shared definition-of-done.md, plus:

  • The three tables (nurse_payout_batches, nurse_payouts, nurse_payout_booking_links) exist via one migration, each with its IEntityTypeConfiguration<T>, the booking_id UNIQUE index, the net_amount_irr = gross_earnings_irr clawback_applied_irr and total_amount = Σ payouts invariants, encrypted iban_snapshot, and soft-delete/audit wiring per conventions.
  • All §3.2 commands/queries implemented (CQRS, OperationResult, projected + paginated reads, validators), with AdminPayoutsController + NursePayoutsController.
  • IBankTransferProvider introduced (Application interface, Infrastructure mock, DI registration via a ServiceConfiguration/ extension, config-selected). No if (mock) in handlers.
  • Eligibility predicate is correct (completed + dispute-window/payout_eligible_at passed + no open dispute + not already linked); clawback netting + recovered marking works; the payout ledger group posts balanced out of nurse_payable; holiday shifting works.
  • Handler unit tests (NSubstitute) for eligibility selection, clawback netting, the duplicate-link guard, ledger posting, and holiday shifting; ≥1 WebApplicationFactory integration test per controller (happy path, 401, validation 400). dotnet build Baya.sln zero new warnings; dotnet test Baya.sln green.
  • The Baya.Application/Features/Payouts/** area is reflected in the Project map in server/CLAUDE.md; the IBankTransferProvider seam noted where seams are documented.
  • The contract dev/contracts/domains/payouts.md written and the swagger.json snapshot republished.

7. How to test (what a human can verify after this phase)

Seed (or reuse from prior phases) a few completed bookings: some with dispute_window_ends_at in the past (eligible), some in the future (not yet), one disputed, and one with a pending clawback on the nurse. Ensure one nurse has a verified primary IBAN and one does not.

  1. Eligibility previewGET api/v1/admin_payouts/eligible?period_start=…&period_end=…only the completed-and-dispute-window-closed, unpaid bookings appear, grouped by nurse; the future-window and disputed bookings are excluded; the nurse without a verified IBAN is flagged.
  2. Generate a batchPOST api/v1/admin_payouts/batches → a draft batch with one nurse_payouts row per eligible nurse; the nurse with a pending clawback shows clawback_applied_irr > 0 and net_amount_irr = gross_earnings_irr clawback_applied_irr; total_amount = Σ net_amount_irr; iban_snapshot populated (encrypted).
  3. Double-pay guard — attempt to generate a second batch covering the same bookings → those bookings are not re-selected (the booking_id UNIQUE link blocks it); no booking appears in two payouts.
  4. Holiday shift — set processing_date to land on a seeded is_bank_closed=1 Nowruz day → the batch period_end/processing_date is shifted to the next business day.
  5. ExecutePOST api/v1/admin_payouts/batches/{id}/process → payouts go submitted → paid with a transfer_reference; the ledger shows a balanced DEBIT nurse_payable / CREDIT escrow_held per payout (verify GetNursePayableBalance drops by the paid amount); the netted clawback is marked recovered with recovered_in_payout_id set.
  6. Idempotency — re-process the same batch → no second transfer, no second ledger posting (statuses already paid).
  7. Failure / retry — flip the mock to force a failure → batch ends partially_failed; POST …/{payout_id}/retry re-submits and (with the mock back to success) flips to paid.
  8. Nurse historyGET api/v1/nurse_payouts/history as the nurse → their payouts with masked IBAN, net amount, transfer reference, and clawback explanation; another nurse's payouts are not visible.

8. Hand off & document (close the phase)

  • Docs to update: the Project map in server/CLAUDE.md (add the Features/Payouts/** area + the IBankTransferProvider seam); if you discover/confirm a rule the product docs don't capture (e.g. the clawback-exceeds-earnings → net-to-zero behaviour, or the require_bnpl_settlement_for_payout flag default), record it in product/business/10-payouts.md — don't invent rules.
  • Contract to write: dev/contracts/domains/payouts.md (per ../../contracts/domains/_TEMPLATE.md) — the admin payout endpoints (eligible/preview, create batch, process, retry, mark-failed, batch detail, list) and the nurse …/history endpoint; the PayoutBatchStatus / PayoutStatus enums; the batch/payout/link DTO shapes (IRR BIGINT, masked iban_snapshot); auth/rate-limit/idempotency notes; the one-payout-per-booking and dispute-window-gating side-effects. Republish the swagger.json snapshot per ../../contracts/openapi/README.md. This is what f12-b13 consumes.
  • Handoff & report: write dev/shared-working-context/backend/handoff/after-backend-phase-13.md (the payout engine is live, what f12 can now build — nurse earnings/payout history, admin payout console — which endpoints/contracts are live, that the bank rail is mocked behind IBankTransferProvider), append to backend/STATUS.md, write dev/shared-working-context/reports/backend-phase-13-report.md (what was built, what is now testable and exactly how per §7, what is mocked + how to make it real, contracts produced, follow-ups: the cron scheduler, the BNPL settled_at guard, on-demand withdrawal), and update dev/shared-working-context/reports/mocks-registry.md (the IBankTransferProvider row → 🟡).
  • Memory: save a project memory note for the non-obvious decisions this phase fixes — the one-payout-per-booking UNIQUE guard, the clawback-netting + recovered_in_payout_id flow, the 'paid'-derives-from-link+ledger rule (no payout_released), holiday-aware shifting, and the IBankTransferProvider seam — with a one-line pointer in MEMORY.md.