Files
baya-monorepo/dev/contracts/domains/payouts.md
T
2026-07-09 04:09:35 +03:30

8.9 KiB

Contract — Payouts (backend phase b13)

The weekly nurse-payout engine: an admin previews eligible earnings, opens a draft batch, submits it to the (mocked) PAYA/SATNA bank rail, retries/marks failed payouts, and reads batches; a nurse reads their own payout history. Assumes ../conventions/api-conventions.md + ../conventions/money-and-types.md. Machine schema: ../openapi/swagger.v1.json.

Status: live as of backend-phase-b13 · Frontend consumer: frontend-phase-f12-b13

All money is IRR BIGINT and crosses the wire as a digit string ("8500000"). Dates are yyyy-MM-dd. List query params are camelCase (page, pageSize, status, periodStart, periodEnd) — not snake_case. The response envelope is the standard { data, … }; the shapes below are the data.

Enums used

  • PayoutBatchStatus: draft | processing | partially_failed | completed | failed — the batch lifecycle. A draft is materialized but unsubmitted; partially_failed has some paid + some failed (retryable).
  • PayoutStatus: pending | submitted | paid | failed — the per-payout lifecycle (forward-only; paid is an irreversible transfer with no outgoing edge; failed re-submits on retry).

Endpoints

GET api/v1/admin_payouts/eligible

  • Purpose: Preview the payout-eligible, unpaid earnings for a window, grouped by nurse (the dry-run before a batch).
  • Auth: admin (dynamic-permission policy) · Rate-limited: yes · Idempotency key: n/a (read).
  • Query params: periodStart (date, required), periodEnd (date, required, ≤ today, ≥ periodStart), page (default 1), pageSize (default 20, max 100).
  • Success 200 (data): PagedResult<EligibleNurseEarningsDto>.
  • Failure cases: 400 periodStart > periodEnd or periodEnd in the future; 401 unauthenticated; 403 non-admin.
  • Notes: Eligible = booking status='completed' AND dispute_window_ends_at < now AND no active refund AND not already paid. The periodEnd is holiday-shifted the same way a generate would shift it. A nurse without a verified primary IBAN is flagged (hasVerifiedPrimaryIban=false), not dropped. Pending clawbacks are netted into the preview.

POST api/v1/admin_payouts/batches

  • Purpose: Open a draft batch: select eligible bookings, materialize one payout per nurse (net of clawbacks), link each booking under the UNIQUE guard, snapshot the verified primary IBAN. No money moves.
  • Auth: admin · Rate-limited: yes · Idempotency: the booking_id UNIQUE link makes a re-run over an overlapping window unable to re-select an already-paid booking.
  • Request body: { "periodStart": "2026-06-01", "periodEnd": "2026-06-30" }
  • Success 200 (data): GeneratePayoutBatchResult — the draft batch, its materialized payouts, and the nurses skipped (with reasons).
  • Failure cases: 400 invalid period; 401/403; a plain failure when no eligible bookings in the window or no eligible nurse has a verified primary IBAN; 409 a concurrent run already claimed one of the bookings (the UNIQUE backstop).
  • Notes: period_end/processing_date are shifted off bank-closed days via IHolidayCalendar. total_amount = Σ net_amount_irr, payout_count = COUNT(payouts).

POST api/v1/admin_payouts/batches/{id}/process

  • Purpose: Submit a draft (or partially-failed) batch to the bank rail — the one irreversible money-out step.
  • Auth: admin · Rate-limited: yes · Idempotency key: yes (payout-batch:{id}; a retried process never re-sends a paid payout or re-posts the ledger).
  • Path params: id (long) — the batch id. Body: none.
  • Success 200 (data): ExecutePayoutBatchResult.
  • Failure cases: 401/403; 404 batch not found; 409 the batch already failed (open a new one). A re-process of a completed batch is an idempotent 200.
  • Notes: Per accepted transfer it posts DEBIT nurse_payable / CREDIT escrow_held (paid net) and, for a netted clawback, DEBIT nurse_payable / CREDIT nurse_clawback_receivable + marks the nurse_clawbacks row recovered. Batch ends completed (all paid) or partially_failed (some failed). PAYA vs SATNA is chosen by payout_satna_threshold_irr.

POST api/v1/admin_payouts/{payoutId}/retry

  • Purpose: Re-submit a single failed payout (holiday-aware).
  • Auth: admin · Rate-limited: yes · Idempotency key: yes (payout:{id}:retry).
  • Path params: payoutId (long). Body: none.
  • Success 200 (data): true.
  • Failure cases: 400 a processing_date failure when banks are closed today, or a channel failure when the rail declines again; 401/403; 404 payout not found; 409 the payout is not failed. An already-paid payout returns an idempotent 200.
  • Notes: On success it posts the ledger + nets clawbacks like the first process and re-settles the batch (partially_failed → completed when it was the last failure).

POST api/v1/admin_payouts/{payoutId}/mark_failed

  • Purpose: Record a reconciled bank rejection on a payout — no ledger movement (no money left).
  • Auth: admin · Rate-limited: yes.
  • Path params: payoutId (long). Request body: { "failureReason": "invalid_sheba" }
  • Success 200 (data): true.
  • Failure cases: 400 empty reason; 401/403; 404 not found; 409 the payout is paid (a confirmed transfer can't be failed). An already-failed payout is an idempotent 200.

GET api/v1/admin_payouts/batches/{id}

  • Purpose: Batch header + its paginated payouts (status, net, masked IBAN, transfer reference) + the bookings each covers.
  • Auth: admin · Rate-limited: yes.
  • Path params: id (long). Query: page (default 1), pageSize (default 50, max 200).
  • Success 200 (data): PayoutBatchDetailDto.
  • Failure cases: 401/403; 404 not found.

GET api/v1/admin_payouts/batches

  • Purpose: Admin reconciliation list of batches.
  • Auth: admin · Rate-limited: yes.
  • Query: status (optional PayoutBatchStatus), page (default 1), pageSize (default 20, max 100).
  • Success 200 (data): PagedResult<PayoutBatchDto>.

GET api/v1/nurse_payouts/history

  • Purpose: The signed-in nurse's own payouts (tenancy-scoped) — status, net, masked IBAN + transfer reference, clawback applied, the batch window.
  • Auth: authenticated (nurse) · Rate-limited: no.
  • Query: page (default 1), pageSize (default 20, max 100).
  • Success 200 (data): PagedResult<NursePayoutHistoryDto>.
  • Failure cases: 401 unauthenticated. A caller who is not a nurse gets an empty page (never another nurse's data).

Shared shapes

  • EligibleNurseEarningsDto: nurseId (long), nurseName (string?), bookingCount (int), grossEarningsIrr (string), clawbackAppliedIrr (string), netAmountIrr (string), hasVerifiedPrimaryIban (bool).
  • PayoutBatchDto: id (long), periodStart/periodEnd/processingDate (date), totalAmount (string), payoutCount (int), status (PayoutBatchStatus), initiatedByAdminId (int), processedAt (datetime?), failureNotes (string?), createdAt (datetime).
  • PayoutDto: id (long), nurseId (long), nurseName (string?), maskedIban (string, last-4 only), grossEarningsIrr/clawbackAppliedIrr/netAmountIrr/amount (string), bookingCount (int), status (PayoutStatus), transferReference (string?), paidAt (datetime?), failureReason (string?), bookings (PayoutBookingLinkDto[]).
  • PayoutBookingLinkDto: bookingId (long), sessionId (long?), payoutAmountIrr (string).
  • PayoutBatchDetailDto: batch (PayoutBatchDto), payouts (PayoutDto[]), total (int), page (int), pageSize (int).
  • SkippedNurseDto: nurseId (long), nurseName (string?), grossEarningsIrr (string), reason (string, e.g. no_verified_primary_iban).
  • GeneratePayoutBatchResult: batch (PayoutBatchDto), payouts (PayoutDto[]), skipped (SkippedNurseDto[]).
  • ExecutePayoutBatchResult: batchId (long), status (PayoutBatchStatus), paidCount (int), failedCount (int), totalPaid (string).
  • NursePayoutHistoryDto: id (long), batchId (long), status (PayoutStatus), grossEarningsIrr/clawbackAppliedIrr/netAmountIrr (string), maskedIban (string), transferReference (string?), paidAt (datetime?), periodStart/periodEnd (date).

Side effects

  • Ledger: process/retry post balanced groups out of nurse_payable (payout + clawback-recovery). Never a payout_released boolean — paid-ness derives from a link row + the ledger.
  • One payout per booking, forever via the nurse_payout_booking_links.booking_id UNIQUE.
  • Bank rail is mocked behind IBankTransferProvider (PAYA/SATNA) — no real transfer.

Changelog

  • b13 — initial contract.