QA003highCorrectness

A record is written before the payment it stands for

What it costs you

This handler moves a row out of its pending state and only then makes the payment or entitlement grant that the row stands for, with the failure swallowed. If the transfer fails the record still reads as settled, the money never moved, and nothing will look at that row again because it no longer matches the pending query.

The defect, and the fix

Both samples are scanned as app/api/billing/webhook/route.ts.

Our test suite runs both through the scanner on every build: the first must be reported, the second must not.

Reported
export async function POST(req) {
  const referral = await db.get("SELECT id FROM referrals WHERE status = 'pending' LIMIT 1");
  await db.run("UPDATE referrals SET status = 'rewarded' WHERE id = ?", referral.id);
  try {
    await stripe.customers.createBalanceTransaction(customerId, { amount: -1000, currency: "usd" });
  } catch (err) {
    logError("credit failed", err);
  }
  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
Not reported
export async function POST(req) {
  const referral = await db.get("SELECT id FROM referrals WHERE status = 'pending' LIMIT 1");
  await db.run("UPDATE referrals SET status = 'rewarded' WHERE id = ?", referral.id);
  try {
    await stripe.customers.createBalanceTransaction(customerId, { amount: -1000, currency: "usd" });
  } catch (err) {
    logError("credit failed", err);
    throw err;
  }
  return new Response(JSON.stringify({ ok: true }), { status: 200 });
}

What changed: The catch rethrows, so the row is not left saying a credit was issued that never left Stripe — and the webhook is redelivered.

How to fix it

Make the transfer first and record it after, or make the transfer replayable: pass an idempotency key, keep the row pending until the transfer confirms, or write an outbox row that a reconciler retries.

Why this rule doesn't cry wolf

Each clause below exists because it was attacked: someone was asked to find correct code that the rule would flag, and the clause is what stopped it. This is published because a check you cannot audit is a check you have to take on faith.

Four gates, all required. (1) The effect must be a billing/entitlement mutation — Stripe/Paddle/Chargebee/Lemon Squeezy money movement or a local credits/quota/plan grant. Email, push, webhooks, analytics, revalidation, audit logs, search and CRM sync, and bare fetch() are excluded on purpose and must not be added back. (2) The preceding write must CONSUME eligibility: a read with a pending-ish predicate on a table/column, then a write moving that same column out of it — a plain INSERT of the subject does not qualify. (3) The catch covering the effect must be terminal: no rethrow, no non-2xx, no revert of the column, no enqueue. An effect with no catch at all is not reported — the error is loud. (4) Suppressed by an idempotency key, by a transaction whose handler rethrows, or by ANY file in the repo re-selecting the transitioned value with a staleness predicate (the outbox pattern).

This is not a security finding

QA003 is reported in its own section, separately from security findings. It does not change your security grade, and it does not fail your build unless you pass --fail-on-quality. The security catalogue lives at /rules.

Other Correctness checks

Check your own code

npx xploitscan scan .

Runs on every plan, including free. All 12 quality checks.