Approval Process
The operator's runbook for taking a single bug from "tester just filed it" to
"tester sees the money land". It accounts for the role split between admin
and super_admin, the verification gate, and the self-dealing rules — all of
which now sit between approval and a paid-out tester.
If you only need the per-step API surface for one of these stages, jump to the flow doc: F-09 triage, F-10 verification gate, F-11 payouts, F-12 tester balance. This doc stitches them into one procedure for the human running the cycle.
1. Who does what
The two tiers exist so that everyday QA work and money-moving actions need
different levels of trust. Promotion happened automatically — every account
that was admin before the role split is now super_admin. Anyone created
after that ships as a plain admin unless a super-admin promotes them.
| Step | Plain admin CAN | Plain admin CANNOT |
|---|---|---|
| Triage — reject an issue | ✅ | — |
| Triage — request info | ✅ | — |
| Triage — reclassify severity | ✅ | — |
| Triage — approve a payout | ❌ | needs super_admin |
Verify a fix (fixed → verified) | ✅ | — |
| Toggle cycle gate | ✅ | — |
| Disburse payouts (per-issue / per-tester / period batch) | ❌ | needs super_admin |
| Change a user's role | ❌ | needs super_admin |
Open /app/admin/audit | ❌ | super-admin only |
Two rules hold for everyone — including super-admins:
- Self-dealing block. You cannot approve OR pay out a bug you reported.
The API returns 403 with
selfReportedIssueIdspopulated. There is no override. - Closed-out auto-handling. If you close an issue out as
duplicate,cant_reproduce, orwont_fix, any pending or approved payout auto-rejects. Paid payouts are never auto-reverted — you have to void them explicitly.
2. The shape of an "approval"
When the triage approve decision lands, the database row changes — nothing
else moves. Specifically: issues.payoutStatus = 'approved',
payoutDecidedBy = <you>, payoutDecidedAt = NOW(), and
payoutAmountCents + payoutCurrency get set (either from your explicit
override, or recomputed from the cycle's effective rates for the issue's
severity). One row is appended to audit_log with action
issue.payout_decision. The reporter gets a bell ping.
That is the entire effect of "approve". No money moves at this step. Disbursement is a separate, super-admin-only action (see step 4).
3. Step 1 — Tester files the bug
The tester runs through F-08: submit the form
with title, body, repro steps, expected/actual, severity, type, and at least
one attachment. On submit the issue lands with status = 'open' and
payoutStatus = 'pending'. It now sits in the cross-cycle triage queue,
waiting on you.
4. Step 2 — Triage
Open /app/admin/triage. You see a two-pane layout — the queue on the left,
the full preview (title, body, attachments inline, AI hints, reporter
context) on the right. On mobile the card opens as a fullscreen overlay.
Keyboard shortcuts (desktop)
| Key | Action |
|---|---|
j | Next card |
k | Previous card |
s | Skip (same as j without deciding) |
e | Approve (super-admin only — blocked on your own filings) |
r | Reject (opens reason dialog) |
o | Open issue detail in a new tab |
The three decisions
| Decision | Who | What it sets | What the tester sees |
|---|---|---|---|
| Approve | super-admin only | payoutStatus = 'approved', payoutDecidedBy, payoutDecidedAt, payoutAmountCents, payoutCurrency | Bell: "Issue approved: <title>. Payout: <amount>." Issue rolls into their Pending verification or Available bucket on /app/test-cycles/balance. |
| Reject | admin OK | payoutStatus = 'rejected', status = 'rejected' | Bell: "Issue rejected" + the reason you typed. |
| Request info | admin OK | payoutStatus = 'info_requested', posts your question as a comment | Bell: "Clarification needed". When they reply, payoutStatus auto-flips back to pending and the card re-enters the queue. |
Severity is reclassifiable inline from the right pane — pick a different severity and the payout amount recomputes from the cycle's effective rates for that severity (same source as the file-issue path). Amount and currency are also overridable directly in the approve dialog if you want to deviate from the rate.
What "approve" enforces under the hood
- Tier check. API requires
super_admin. A plainadminpressingegets a 403 toast. - Self-dealing. If you reported the issue yourself, the approve path returns 403 with the message "You can't approve a payout for a bug you reported." There is no UI escape hatch. Pass it to another super-admin.
- Allowed transitions. You cannot re-approve a
paidissue — onlypaid → voidis allowed once money has moved. - Audit. Every approve, reject, and request-info writes one
issue.payout_decisionrow toaudit_logwithbeforeandaftersnapshots.
Tip — reclassifying a high down to low on approve. When you change severity on the triage card, the amount silently recomputes for the new severity unless you also passed an explicit
amountCents. So if you both reclassify and want a custom amount, set the amount in the same approve click — don't expect a stalehigh-tier number to survive.
See F-09 for the full API surface and the negative-path matrix.
5. Step 3 — Verification gate (when active)
If the cycle has payoutRequiresVerification = true — either by direct
setting or by inheriting from the product — an approved issue is not
eligible for payout until its status is moved to verified. This separates
"the bug is real and we'll pay" (your approve at step 2) from "the fix landed
and we re-tested" (step 3).
If the gate is inactive, skip this step — approved issues are immediately eligible for the next batch.
Where the work happens
Two surfaces feed the gate:
- Bulk verification queue on the cycle Overview tab. When at least one
issue in the cycle sits at
status = 'fixed', the Overview tab shows an amber "Awaiting fix verification (N)" card. Each row offers Fix verified (setsstatus = 'verified', posts an audit comment) or Still broken (requires a ≥5-character reason, posts the comment, flips the issue back toin_progress). Rows drop off the card immediately on action; the card hides itself when nothing is awaiting. - The reporter's own verify-fix banner. The original reporter can also
verify or reopen a fix from their issue detail page (see commit
f5d2a9afor the recently-added reporter-verify path).
What the gate means for your payout queue
While the gate is active:
- An approved-but-unverified issue counts in the tester's Pending
verification bucket on
/app/test-cycles/balance. It will not be picked up by a period batch payout — the run-batch endpoint excludes it and reports the id inunverifiedIssueIds. - Per-issue "Mark paid" is disabled with the hint "Awaiting verification" on the issue detail page.
- Once you mark
verified, the issue rolls into Available and becomes eligible for the next disbursement.
See F-10 for the gate-resolution rule and edge cases.
6. Step 4 — Disburse
Open /app/admin/payouts. The page is super-admin only — a plain admin
gets the page chrome but every action returns 403.
The pay queue
The queue groups eligible issues by tester. "Eligible" means
payoutStatus = 'approved' and (gate inactive OR status = 'verified').
Aggregate totals (by currency) sit in the header card; per-tester totals are
on each row.
Three disbursement paths
In order of preference, smallest-blast-radius first when correcting:
- Period batch (
RunBatchPanel→POST /api/admin/payouts/run-batch) — preferred. Pick a date range (Today / Last 7 days / This month / Last month / custom), pick a method (bank_transfer/kaspi/cash/other), reference, notes. Hit dry-run first — the preview listseligibleCount, totals by currency, tester count,failedIssueIds,unverifiedIssueIds. When it looks right, commit. Onepayout_batchesrow is created, Nissue_payoutsrows are inserted under it, N issues flip topaid, and N bell pings fan out. - Per-tester batch (
BatchPayDialog→POST /api/admin/payouts/batch-pay) — pay one tester's outstanding queue in one click. Same dialog (method, reference, notes) but scoped to one set ofissueIds. - Per-issue manual payment (
ApprovalPayoutCard→POST /api/test-cycles/:id/issues/:issueId/payments) — for one-off corrections.
What disburse enforces under the hood
- Tier check. Both
run-batchandbatch-payrequiresuper_admin. - Self-payment block. The endpoint scans the candidate set for
reportedBy === <you>. If any match, it returns 403 withselfReportedIssueIds. No partial pay — the whole call is refused so you can re-form the set. (The idempotency key is released so you can retry cleanly.) - Gate enforcement. Approved-unverified issues are filtered out of
run-batchand surfaced inunverifiedIssueIds. Per-issue payment on an unverified issue returns 400. - Idempotency key.
run-batchandbatch-payboth require anIdempotency-Keyheader. The UI generates one per click; if you retry the same call with the same key, the server returns the original response instead of paying twice. - Audit. Every batch writes one
payout.batch_paidrow toaudit_log.
What the tester sees after the batch commits
- Their
payoutStatusflips topaidfor each issue. - A row appears in
issue_payoutswith the method + reference (andbatch_idlinking it to the batch). - A bell notification per paid issue: "Paid: <title> via Kaspi · ref TST-2026-W19" (or whatever method/reference you used).
See F-11 for the full API surface, dry-run shape, and the void path.
7. Step 5 — Tester sees it
The tester opens /app/test-cycles/balance. The 5-column header now shows
the paid issue in Total paid instead of Available; their issue
history table picks up paid_at, payment method, and reference. They can
hit Export CSV to download a personal-accounting file
(balance.csv — one row per non-draft issue, with payment method and
reference rendered as friendly labels).
See F-12 for the bucket definitions and the multi-currency rendering.
8. The audit trail
Every sensitive action above writes one row to audit_log:
| Action verb | Fired by |
|---|---|
issue.payout_decision | Triage approve / reject / request_info |
issue.status_changed | Mark verified / mark fixed / reopen / etc. |
payout.batch_paid | Period batch + per-tester batch commits |
cycle.status_changed | Cycle lifecycle transitions |
user.role_changed | Role changes via the users admin page |
Open /app/admin/audit (super-admin only). The screen shows newest-first
rows with a coloured action tint, the actor's name, the entity (issue /
cycle / user), and a before → after diff of the fields that changed. The
filter row lets you scope by action and by actor — useful when an
owner wants "every payout decision in the last 7 days" or "everything Babur
did yesterday".
Audit writes are fire-and-forget from the request path — they never block or fail the primary action. If you don't see an entry within a few seconds, refresh the audit page; if it still doesn't show, the underlying mutation may have rolled back.
9. One-page checklist — super-admin runbook
A compact tick-through for the operator who's settling a cycle.
- Open
/app/admin/triage— work the queue. For each card:- Read the body and attachments. Reclassify severity if needed.
- Approve (
e) for real bugs. Note: you'll be blocked on your own filings — pass those to another super-admin. - Reject (
r) with a one-sentence reason for the noise. - Request info for the ambiguous ones — the reporter's reply will requeue the card automatically.
- If the cycle's verification gate is active:
- Open the cycle Overview tab → "Awaiting fix verification" card.
- Fix verified for each issue where the fix actually lands; Still broken with a ≥5-char reason for the ones that don't.
- Open
/app/admin/payouts:- Skim the aggregate header — totals match what you expected?
- Open
RunBatchPanel, pick the period, fill method + reference + notes. - Dry-run first. Read the preview. Check
failedIssueIdsis empty. CheckunverifiedIssueIdsis the set you expect. - Commit the batch. Note the
batchIdfrom the success toast.
- Spot-check
/app/admin/audit:- Filter by action = "Payout batch paid". Confirm the new batch row.
- Filter by action = "Payout decided" + actor = you. Confirm the triage decisions you made today are in the trail.
- (Optional) Sign in as one of the paid testers — confirm
/app/test-cycles/balanceshows the new "Total paid" total and the bell carries the payout notification.