add build development phases
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
# 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](./backend-phase-10.md) (ledger / `nurse_payable`), [b11](./backend-phase-11.md) (clawbacks), [b9](./backend-phase-9.md) (booking/session eligibility, dispute window), [b3](./backend-phase-3.md) (nurse bank accounts), [b1](./backend-phase-1.md) (`iranian_holidays`) · **Unlocks:** nurse earnings; frontend **f12-b13**
|
||||
> **Before you start, read [`../_shared/agent-operating-rules.md`](../_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`](../../../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` accrual** — [b10](./backend-phase-10.md) built `ledger_entries`
|
||||
(append-only, balanced, `transaction_group_id`, the six `account_type`s 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.**
|
||||
- **Clawbacks** — [b11](./backend-phase-11.md) built `nurse_clawbacks` (`nurse_id`, `booking_id`,
|
||||
`refund_id`, `original_payout_id`, `amount_irr`, `status` ∈ `pending|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 window** — [b9](./backend-phase-9.md) 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 accounts** — [b3](./backend-phase-3.md) 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_holidays`** — [b1](./backend-phase-1.md) seeded the holiday calendar (`holiday_date`,
|
||||
`is_bank_closed`) behind the **`IHolidayCalendar`** seam. **Reuse `IHolidayCalendar`** for date shifting.
|
||||
- **The platform config accessor** — [b1](./backend-phase-1.md)'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`](../_shared/agent-operating-rules.md) and
|
||||
[`../_shared/backend-conventions-checklist.md`](../_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.md`](../../../product/business/10-payouts.md) — **the 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.md`](../../../product/payments/cancellation-and-payout.md) —
|
||||
**Q2 "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.md`](../../../product/data-model/07-payouts.md) — **the 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`](../../../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`](../../contracts/conventions/api-conventions.md)
|
||||
and [`money-and-types.md`](../../contracts/conventions/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`](../../../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`](../../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](../_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 preview** — `GET 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 batch** — `POST 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. **Execute** — `POST 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 history** — `GET 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`](../../../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`](../../../product/business/10-payouts.md) — don't invent rules.
|
||||
- **Contract to write:** **`dev/contracts/domains/payouts.md`** (per
|
||||
[`../../contracts/domains/_TEMPLATE.md`](../../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`](../../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`.
|
||||
Reference in New Issue
Block a user