Payouts And Tester Balance
Status: ✅ Shipped end-to-end — all 5 phases landed on main 2026-05-04.
Goal: make the issue review → pricing → payout → tester-balance loop fully operational for the QA team. Most of the infrastructure already exists; this plan ties it together with the missing surfaces.
Out of scope for this plan: AI agents (already shipped, awaiting
ANTHROPIC_API_KEY to activate). Re-pricing, currency conversion beyond what
already exists, multi-currency reconciliation. Linear/Jira sync.
Shipped log
| Phase | PR | What landed |
|---|---|---|
| 1 — Tester balance dashboard | #138 | /app/test-cycles/balance · totals (paid / pending / awaiting decision) · by-cycle list · issue history table · acceptance rate |
| — Auth self-heal + impersonation | #139 | Self-healing clerk_id linkage on first sign-in · admin "View as user" with audit table (migration 0024) · sticky banner |
| 2 — Admin payouts dashboard | #140 | /app/admin/payouts · aggregate header · pay queue grouped by tester · POST /api/admin/payouts/batch-pay |
| 3 — Triage queue | #141 | /app/admin/triage · two-pane preview · keyboard shortcuts j/k/e/r/o · POST /api/admin/triage/decide |
| 4 — Per-cycle rate overrides | #142 | "Settings" tab on cycle detail · per-severity inputs + currency picker · stored in metadata.payout_rates |
| 5 — Tester earnings CSV export | #143 | GET /api/me/balance/export.csv · download button on balance page |
| — UX follow-up | #144 | Impersonation hides admin sidebar correctly · "Cycles assigned" panel + add-to-cycle dialog on user profile |
Decision deviations from the original plan:
- Phase 4 used
metadata.payout_ratesinstead of a dedicated jsonb column.suggestedAmountCentsFor()already read that path, so adding a column would be redundant. Migration0024ended up housingadmin_impersonation_log(auth/impersonation work) instead. - Phase 2's
GET /api/admin/payouts/by-testerwas folded intoGET /api/admin/payouts. The single endpoint returns aggregate + grouped pay queue + pending review in one shape; no need for a separate "by-tester" route.
What still requires manual / ops work
ANTHROPIC_API_KEYon the droplet — when set, the three already-deployed AI agents (intake, run summary, cycle close) activate. UI handles failure modes gracefully if not.- Smoke-test the full loop end-to-end on production: file → triage → approve → batch-pay → tester sees in balance + downloads CSV.
What already exists — do not rebuild
| Piece | Where |
|---|---|
issues.payout_status state machine: pending → approved → paid (or rejected / void) | src/db/schema.ts |
issue_payouts table — actual payment records with method (Kaspi / bank / cash) + reference | src/db/schema.ts |
payoutSideEffectForStatusChange() — auto-rejects payout when issue closes negative (duplicate / rejected / cant_reproduce / wont_fix) | src/lib/issue-payout.ts |
ApprovalPayoutCard per-issue: Approve / Custom amount / Reject; Mark paid dialog with method + reference | src/components/test-cycles/ApprovalPayoutCard.tsx |
PayoutTotalsCard per-cycle: Approved unpaid / Paid / Total committed (grouped by currency) | src/components/test-cycles/PayoutTotalsCard.tsx |
| Cross-cycle "All Issues" table with payout column + filters | /app/issues |
Default rates by severity: 15k / 8k / 3k / 1k KZT cents (critical / high / medium / low) | src/lib/issue-payout.ts → DEFAULT_PAYOUT_RATES_KZT_CENTS |
Gaps to close — five phases
Phase 1 — Tester balance dashboard ⭐ start here
Page: /app/test-cycles/balance (linked from QASectionSidebar; replaces
the current "My Reports" filter, or sits next to it)
What it shows for the logged-in tester:
┌─ Header ─────────────────────────────────────────────────┐
│ Total earned (paid) ₸ 120,000 │
│ Pending payout (approved) ₸ 45,000 │
│ Awaiting decision ₸ ~30,000 (12 issues) │
│ Lifetime issues filed 42 │
│ Acceptance rate 71% (30/42) │
└──────────────────────────────────────────────────────────┘
┌─ By cycle ──────────────────────────────────────────────┐
│ Hackorda Onboarding — QA shake-down │
│ Issues: 18 · Approved: 12 · Paid: 8 · Earned: ₸ 75k │
│ Akashi Cloud — Pre-launch sweep │
│ Issues: 12 · Approved: 9 · Paid: 9 · Earned: ₸ 45k │
└─────────────────────────────────────────────────────────┘
┌─ Issue history (table) ─────────────────────────────────┐
│ date │ cycle │ severity │ title │ status │ amount │ paid_at │ method │
└─────────────────────────────────────────────────────────┘Schema: zero new tables. Compute from issues + issue_payouts rows.
API:
GET /api/me/balance— totals + breakdown for the current user
Files:
src/app/app/test-cycles/balance/page.tsx(new)src/app/api/me/balance/route.ts(new)src/components/test-cycles/BalanceSummary.tsx(new — header card + by-cycle list)- Update
src/components/test-cycles/QASectionSidebar.tsxto point at the new page
Effort: ~1 day.
Phase 2 — Admin payouts dashboard
Page: /app/admin/payouts
What it shows for admins:
┌─ Aggregate ─────────────────────────────────────────────┐
│ Approved unpaid (committed) ₸ 342,000 · 27 issues │
│ Paid (lifetime) ₸ 1,420,000 │
│ Pending review 18 issues │
│ Top tester (by earned) Aibek — ₸ 220k │
└─────────────────────────────────────────────────────────┘
┌─ Pay queue (approved-not-paid) ─────────────────────────┐
│ ☐ Aibek 8 issues ₸ 75,000 [Mark all paid] │
│ ☐ Nazira 5 issues ₸ 42,000 [Mark all paid] │
│ ... │
│ [Pay selected →] │
└─────────────────────────────────────────────────────────┘
┌─ Pending review queue ──────────────────────────────────┐
│ Issues with payout_status='pending' that aren't drafts │
│ Bulk approve at default rate · per-row override │
└─────────────────────────────────────────────────────────┘Schema: zero new tables.
API:
GET /api/admin/payouts— aggregate + listsGET /api/admin/payouts/by-tester— groupedPOST /api/admin/payouts/batch-pay— mark N issues paid in one transaction with one method + reference per tester
Files:
src/app/app/admin/payouts/page.tsx(new)src/app/api/admin/payouts/route.ts(new)src/app/api/admin/payouts/batch-pay/route.ts(new)src/components/test-cycles/PayoutsByTesterTable.tsx(new)
Effort: ~1 day.
Phase 3 — Triage queue surface
Page: /app/admin/triage (or as a tab on the admin cycle page)
What it shows:
┌─ Inbox: 12 untriaged issues across 3 cycles ───────────┐
│ ○ [critical] Payment fails on mobile Safari approve [j] reject [k]
│ ○ [high] Profile photo upload broken approve [j] reject [k]
│ ○ [medium] Tooltip clips on small screens approve [j] reject [k]
│ ... │
│ │
│ Right pane: full issue body + screenshots + AI suggestions
└─────────────────────────────────────────────────────────┘Behaviour:
- Inbox of
status='open'andpayout_status='pending' - Keyboard shortcuts:
jnext,kprevious,eapprove,rreject,oopen detail - Sort: severity desc, created_at desc
- One click moves issue to triaged + sets payout (default rate)
Files:
src/app/app/admin/triage/page.tsx(new)src/components/test-cycles/admin/TriageQueue.tsx(new)- New hook
useTriageInbox()that returnspayout_status='pending'issues across all open cycles
Effort: ~0.5 day.
Phase 4 — Per-cycle rate overrides
Currently DEFAULT_PAYOUT_RATES_KZT_CENTS is hard-coded. Some cycles will pay
differently (high-stakes pre-launch sweep vs. routine regression).
Approach:
- Add
test_cycles.severity_payout_overrides jsonb(nullable; null = use defaults) - Shape:
{ critical?: number, high?: number, medium?: number, low?: number, currency?: 'KZT' | 'USD' } suggestedAmountCentsFor(cycle, severity)resolves to override → default- Cycle settings page (admin) gets a "Payout rates" panel with four inputs + currency picker
Files:
- Migration:
0024_*.sql src/db/schema.ts— new columnsrc/lib/issue-payout.ts—suggestedAmountCentsForreads override firstsrc/app/app/admin/test-cycles/[id]/edit/page.tsx— new "Payout rates" form section
Effort: ~0.5 day.
Phase 5 — Tester earnings receipts / export
For tax + Kaspi reconciliation. Per-tester export of their issue_payouts
rows.
Format: CSV (server-generated) or HTML page they can print to PDF.
Columns: date, cycle name, issue title, severity, amount, currency, method, reference.
Files:
src/app/api/me/balance/export.csv/route.ts(new — streams CSV)- "Download CSV" button on the balance page
Effort: ~0.5 day.
Recommended phasing
| Order | Phase | Effort | Why |
|---|---|---|---|
| 1 | Tester balance dashboard | 1 day | Highest user-visible value. Testers can finally see what they earned. |
| 2 | Admin payouts dashboard | 1 day | Operator unblocks at scale — pay 10 testers in one workflow instead of 10 issue-detail clicks. |
| 3 | Triage queue | 0.5 day | Speeds review when the issue queue grows. |
| 4 | Rate overrides | 0.5 day | Flexibility per cycle. |
| 5 | CSV export | 0.5 day | Compliance / accounting. |
Total: ~3.5 days of focused work.
Schema changes needed
Most data is computed from existing tables. Only Phase 4 needs a migration.
| Phase | Change |
|---|---|
| 1 | None — query issues + issue_payouts |
| 2 | None |
| 3 | None |
| 4 | test_cycles.severity_payout_overrides jsonb (migration 0024) |
| 5 | None |
API additions
GET /api/me/balance # current user's totals + breakdown
GET /api/me/balance/export.csv # tester self-export
GET /api/admin/payouts # aggregate + queue
GET /api/admin/payouts/by-tester # grouped
POST /api/admin/payouts/batch-pay # mark N issues paid for one tester
GET /api/admin/triage # inbox of payout_status='pending' issuesAll admin routes use requireAdmin middleware. /api/me/* uses resolveCurrentDbUser (already exists).
Open questions to resolve before phase 1
-
Should "My Reports" stay as a filter on
/app/issuesand/app/test-cycles/balancebe a NEW page, or should the new balance page replace "My Reports"?Recommendation: keep both. "My Reports" = list view, "Balance" = financial view. Different intents.
-
Currency handling
Today: most payouts in KZT;
payout_currencyfield exists per row. What does the balance dashboard show if a tester has both KZT and USD payouts?Recommendation: group totals by currency. Show e.g. "₸ 120,000 + $ 50". Don't auto-convert.
-
Should rejected issues count as "filed" in the acceptance rate?
Recommendation: yes — acceptance rate = approved / total filed (excluding drafts). Rejected and cant_reproduce both lower the rate.
-
Tester sees other testers' issues in balance view?
No. Balance is strictly per-user. Admin payouts dashboard sees everyone.
-
What happens to
issue_payoutsif admin marks an already-paid issue asvoid?Today:
voidflipspayoutStatusbut doesn't remove theissue_payoutsrow. Audit trail preserved. We'll surface voids in the admin dashboard as a distinct row.
Existing code to read before starting
src/lib/issue-payout.ts— state machine, transitions, default rates, side-effect logicsrc/components/test-cycles/ApprovalPayoutCard.tsx(~440 lines) — the per-issue widgetsrc/components/test-cycles/PayoutTotalsCard.tsx— per-cycle totalssrc/app/app/issues/page.tsx— All Issues table with payout columnsrc/app/api/test-cycles/[id]/issues/[issueId]/payments/route.ts— payment insert flow
Read these first; they cover most of the patterns the new surfaces will reuse.
When this plan is done
A QA tester logs in, opens Balance, and sees: ₸ 120,000 earned · ₸ 45,000 pending · 42 issues filed across 3 cycles · 71% acceptance rate. They click
into any cycle to see exactly which bugs paid out.
A QA admin logs in, opens Payouts, and sees: 27 approved-unpaid issues
totalling ₸ 342,000, grouped by tester. Selects three testers, hits "Pay
selected", confirms via Kaspi, done. The whole batch is logged in
issue_payouts and the tester balances update on next refresh.
That's the operational target.