Hackorda Docs
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:

  1. Period batch โ€” preferred. Pick a date range; one transaction pays every approved + verified issue across the whole window.
  2. Per-tester batch โ€” pay one tester's outstanding queue in one click. For one-off settlements between periods.
  3. 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)

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1AdminRunBatchPanelPick 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โ€”
2AdminpreviewEnter method (bank_transfer/kaspi/cash/other), reference, notesโ€”โ€”โ€”โ€”
3Adminconfirm dialogClick "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' ร— NISSUE_PAID notifications fan out to N reporterspayout.batch_run
4Frontendsuccess toastReturns batchId, paid count, totalsโ€”โ€”โ€”โ€”

Path B โ€” Per-tester batch

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Admin/app/admin/payouts "Pay queue"Click "Pay all" on a tester's groupโ€”โ€”โ€”โ€”
2AdminBatchPayDialogPick method, reference, notesPOST /api/admin/payouts/batch-pay body { issueIds: [...], method, reference, notes }INSERT INTO issue_payouts ร— N, UPDATE issues SET payout_status='paid' ร— NISSUE_PAID notificationspayout.tester_batch_paid
3Frontendtester group disappears from queuetoastโ€”โ€”โ€”โ€”

Path C โ€” Per-issue manual payment

#ActorUI surfaceActionAPI callDB writesSide effectsSuggested event
1Adminissue detail ApprovalPayoutCardClick "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 notificationpayout.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 any failedIssueIds (bad data) / unverifiedIssueIds (gate blocking).
  • AC-2 โ€” Given the same range and dryRun=false, when the run commits, then payout_batches has one new row, issue_payouts has N rows with batch_id pointing to that batch, and N issues are flipped to paid.
  • 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 stays approved.
  • 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_batches row exists, when the corresponding rows are deleted from issue_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_currency values if you want to verify multi-currency totals

Negative paths

#ScenarioExpected behavior
N-1periodStart > periodEndAPI 400
N-2Bad ISO date stringsAPI 400 "must parse as dates"
N-3Run-batch when zero eligible issuesAPI 400 "no issues eligible in this period" with failedIssueIds / unverifiedIssueIds listed
N-4Per-issue payment without methodUI form blocks (or API allows null โ€” verify)
N-5Pay an issue with payout_amount_cents = 0Skip-and-report-failed (no zero ledger row)
N-6Admin loses connection mid-runThe 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-7Non-admin caller403

Manual QA checklist

  • On /app/admin/payouts aggregate 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_batches has a new row; issue_payouts has 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 (with batch_id), issues.payout_*, PAYMENT_METHODS, ISSUE_PAID notification

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_batches rows have no UI for drill-down / receipt regen / void. This is the highest-leverage follow-up (flagged in feature-matrix.md ยง5.2).
  • Void path exists in the schema (PAYOUT_STATUSES.VOID, paid โ†’ void transition allowed), but no UI button.
  • No idempotency key on run-batch (relies on the approved status filter to prevent double-pay).
  • Multi-currency totals shown as separate lines (intentional โ€” we don't FX-convert).

On this page