Hackorda Docs
Plans

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

PhasePRWhat 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#139Self-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#143GET /api/me/balance/export.csv · download button on balance page
— UX follow-up#144Impersonation 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_rates instead of a dedicated jsonb column. suggestedAmountCentsFor() already read that path, so adding a column would be redundant. Migration 0024 ended up housing admin_impersonation_log (auth/impersonation work) instead.
  • Phase 2's GET /api/admin/payouts/by-tester was folded into GET /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_KEY on 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

PieceWhere
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) + referencesrc/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 + referencesrc/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.tsDEFAULT_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.tsx to 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 + lists
  • GET /api/admin/payouts/by-tester — grouped
  • POST /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' and payout_status='pending'
  • Keyboard shortcuts: j next, k previous, e approve, r reject, o open 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 returns payout_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 column
  • src/lib/issue-payout.tssuggestedAmountCentsFor reads override first
  • src/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.


OrderPhaseEffortWhy
1Tester balance dashboard1 dayHighest user-visible value. Testers can finally see what they earned.
2Admin payouts dashboard1 dayOperator unblocks at scale — pay 10 testers in one workflow instead of 10 issue-detail clicks.
3Triage queue0.5 daySpeeds review when the issue queue grows.
4Rate overrides0.5 dayFlexibility per cycle.
5CSV export0.5 dayCompliance / accounting.

Total: ~3.5 days of focused work.


Schema changes needed

Most data is computed from existing tables. Only Phase 4 needs a migration.

PhaseChange
1None — query issues + issue_payouts
2None
3None
4test_cycles.severity_payout_overrides jsonb (migration 0024)
5None

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' issues

All admin routes use requireAdmin middleware. /api/me/* uses resolveCurrentDbUser (already exists).


Open questions to resolve before phase 1

  1. Should "My Reports" stay as a filter on /app/issues and /app/test-cycles/balance be a NEW page, or should the new balance page replace "My Reports"?

    Recommendation: keep both. "My Reports" = list view, "Balance" = financial view. Different intents.

  2. Currency handling

    Today: most payouts in KZT; payout_currency field 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.

  3. 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.

  4. Tester sees other testers' issues in balance view?

    No. Balance is strictly per-user. Admin payouts dashboard sees everyone.

  5. What happens to issue_payouts if admin marks an already-paid issue as void?

    Today: void flips payoutStatus but doesn't remove the issue_payouts row. 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 logic
  • src/components/test-cycles/ApprovalPayoutCard.tsx (~440 lines) — the per-issue widget
  • src/components/test-cycles/PayoutTotalsCard.tsx — per-cycle totals
  • src/app/app/issues/page.tsx — All Issues table with payout column
  • src/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.

On this page