Flows
F 11 Payouts
Status: ๐ข ready to test Owner: Adilet Last updated: 2026-05-08 Last verified on production: โ
Goal
An admin settles approved issues by paying testers. Three paths exist, in order of preference:
- Period batch โ preferred. Pick a date range; one transaction pays every approved + verified issue across the whole window.
- Per-tester batch โ pay one tester's outstanding queue in one click. For one-off settlements between periods.
- Per-issue manual payment โ pay a single issue. For corrections and edge cases.
All three paths write to issue_payouts (the audit ledger) first, then
flip issues.payout_status='paid'. The verification gate (F-10)
applies to all three.
Actors
- Primary โ Admin
- Secondary โ Testers (notified via
ISSUE_PAID)
Preconditions
- At least one issue exists with
payout_status='approved'and (if gate active)status='verified' - Admin is signed in
Trigger
Admin opens /app/admin/payouts.
Path A โ Period batch (preferred settlement)
| # | Actor | UI surface | Action | API call | DB writes | Side effects | Suggested event |
|---|---|---|---|---|---|---|---|
| 1 | Admin | RunBatchPanel | Pick range (Today / Last 7 days / This month / Last month / custom) | POST /api/admin/payouts/run-batch body { periodStart, periodEnd, dryRun: true } | none (dry-run) | preview returned: eligibleCount, totals/currency, testerCount, failedIssueIds, unverifiedIssueIds | โ |
| 2 | Admin | preview | Enter method (bank_transfer/kaspi/cash/other), reference, notes | โ | โ | โ | โ |
| 3 | Admin | confirm dialog | Click "Run batch" | POST /api/admin/payouts/run-batch body { ..., dryRun: false } | INSERT INTO payout_batches, INSERT INTO issue_payouts ร N (with batch_id), UPDATE issues SET payout_status='paid' ร N | ISSUE_PAID notifications fan out to N reporters | payout.batch_run |
| 4 | Frontend | success toast | Returns batchId, paid count, totals | โ | โ | โ | โ |
Path B โ Per-tester batch
| # | Actor | UI surface | Action | API call | DB writes | Side effects | Suggested event |
|---|---|---|---|---|---|---|---|
| 1 | Admin | /app/admin/payouts "Pay queue" | Click "Pay all" on a tester's group | โ | โ | โ | โ |
| 2 | Admin | BatchPayDialog | Pick method, reference, notes | POST /api/admin/payouts/batch-pay body { issueIds: [...], method, reference, notes } | INSERT INTO issue_payouts ร N, UPDATE issues SET payout_status='paid' ร N | ISSUE_PAID notifications | payout.tester_batch_paid |
| 3 | Frontend | tester group disappears from queue | toast | โ | โ | โ | โ |
Path C โ Per-issue manual payment
| # | Actor | UI surface | Action | API call | DB writes | Side effects | Suggested event |
|---|---|---|---|---|---|---|---|
| 1 | Admin | issue detail ApprovalPayoutCard | Click "Mark paid" (gate must be passed; see F-10) | POST /api/test-cycles/:id/issues/:issueId/payments body { method, reference, notes } | INSERT INTO issue_payouts, UPDATE issues SET payout_status='paid' | ISSUE_PAID notification | payout.issue_paid |
Acceptance criteria
- AC-1 โ Given a date range, when admin runs
dryRun=true, then the response lists every issue that would be paid, totals by currency, unique tester count, and anyfailedIssueIds(bad data) /unverifiedIssueIds(gate blocking). - AC-2 โ Given the same range and
dryRun=false, when the run commits, thenpayout_batcheshas one new row,issue_payoutshas N rows withbatch_idpointing to that batch, and N issues are flipped topaid. - AC-3 โ Given the gate is active and an approved-unverified issue
falls in the range, then the run excludes it and reports it in
unverifiedIssueIds. The issue staysapproved. - AC-4 โ Given a tester has 5 paid issues in a batch, when the notifications fan out, then they receive one row per paid issue (5 rows), each linking to the issue detail.
- AC-5 โ Given the per-tester batch endpoint, when a list of issue IDs is supplied, then it pays exactly those (intersection with approved + verified-if-gated). Non-eligible IDs are reported back.
- AC-6 โ Given the per-issue payment, when the gate is active and
the issue is not
verified, then the API returns 400 "issue not verified". - AC-7 โ Given a paid issue, when admin tries to approve it again
via triage, then API 400 (allowed transitions:
paid โ void). - AC-8 โ Given a
payout_batchesrow exists, when the corresponding rows are deleted fromissue_payouts(manual fix), then the batch row is harmlessly orphaned โ no FK violation (soft FK, set null on delete).
Test data / fixtures
- Baseline seed
- 3+ approved + verified issues spread across 2 testers, with at least
2 different
payout_currencyvalues if you want to verify multi-currency totals
Negative paths
| # | Scenario | Expected behavior |
|---|---|---|
| N-1 | periodStart > periodEnd | API 400 |
| N-2 | Bad ISO date strings | API 400 "must parse as dates" |
| N-3 | Run-batch when zero eligible issues | API 400 "no issues eligible in this period" with failedIssueIds / unverifiedIssueIds listed |
| N-4 | Per-issue payment without method | UI form blocks (or API allows null โ verify) |
| N-5 | Pay an issue with payout_amount_cents = 0 | Skip-and-report-failed (no zero ledger row) |
| N-6 | Admin loses connection mid-run | The batch row is created first; issue_payouts and issues.payout_status updates may be partial. Rerun is safe โ already-paid issues won't be re-picked (status filter excludes paid). |
| N-7 | Non-admin caller | 403 |
Manual QA checklist
- On
/app/admin/payoutsaggregate header, confirm "Approved ยท unpaid" totals match the pay queue - Open
RunBatchPanel, pick "Last 7 days" โ preview loads, shows N issues + totals - Confirm any unverified issues are listed in the preview's "excluded" callout
- Pick method=Kaspi, reference="TST-2026-W19", notes="QA test batch"
- Click "Run batch" โ success toast with paid count + totals
- Verify
payout_batcheshas a new row;issue_payoutshas N rows linking to it - Sign in as a tester who got paid โ bell shows "Paid: via Kaspi ยท ref TST-2026-W19"
- Sign in as same tester โ
/app/test-cycles/balance"Total paid" bumped, "Available" reduced - Run pay-all on a tester group via "Pay queue" โ second path (per-tester batch) works the same
- On an individual approved+verified issue, click "Mark paid" โ per-issue path
- Try a per-issue payment on a non-verified issue (gate active) โ 400
Automated test outline
test.describe("F-11 payouts", () => {
test("period batch dry run + commit", async ({ request }) => { โฆ });
test("per-tester batch", async ({ request }) => { โฆ });
test("per-issue gated by verification", async ({ request }) => { โฆ });
test("notifications fan out", async ({ request }) => { โฆ });
test("paid โ void only", async ({ request }) => { โฆ });
});Code references
- Pages:
src/app/app/admin/payouts/page.tsx - UI:
src/components/test-cycles/admin/RunBatchPanel.tsx(period batch)src/components/test-cycles/admin/BatchPayDialog.tsx(per-tester)src/components/test-cycles/admin/PayoutsByTesterTable.tsx(queue)src/components/test-cycles/ApprovalPayoutCard.tsx(per-issue)src/components/test-cycles/PayoutTotalsCard.tsx(aggregate header)
- API:
src/app/api/admin/payouts/run-batch/route.ts(period batch โ dry-run + commit)src/app/api/admin/payouts/batch-pay/route.ts(per-tester)src/app/api/test-cycles/[id]/issues/[issueId]/payments/route.ts(per-issue)src/app/api/admin/payouts/route.ts(queue feed)
- Hooks:
src/hooks/test-cycles/admin-payouts.ts - Lib:
src/lib/issue-payout.ts(requiresVerification,effectiveRatesFor) - Schema:
payout_batches,issue_payouts(withbatch_id),issues.payout_*,PAYMENT_METHODS,ISSUE_PAIDnotification
Events emitted (proposed)
payout.batch_runโ{ batch_id, paid_count, tester_count, totals_by_currency, method, period_start, period_end, run_by }payout.tester_batch_paidโ{ paid_count, tester_id, totals_by_currency, method, run_by }payout.issue_paidโ{ issue_id, amount_cents, currency, method, paid_by }payout.gate_blockedโ{ issue_id, source: 'run_batch' \| 'batch_pay' \| 'manual' }
Open questions / known gaps
- Past batches view is not built yet โ
payout_batchesrows have no UI for drill-down / receipt regen / void. This is the highest-leverage follow-up (flagged infeature-matrix.mdยง5.2). - Void path exists in the schema (
PAYOUT_STATUSES.VOID,paid โ voidtransition allowed), but no UI button. - No idempotency key on run-batch (relies on the
approvedstatus filter to prevent double-pay). - Multi-currency totals shown as separate lines (intentional โ we don't FX-convert).