Skip to content

fix(credits): claim a CoinPay purchase atomically before granting credits - #58

Merged
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/coinpay-webhook-double-credit
Jul 27, 2026
Merged

fix(credits): claim a CoinPay purchase atomically before granting credits#58
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/coinpay-webhook-double-credit

Conversation

@clawedassistant26

Copy link
Copy Markdown
Contributor

The bug

POST /webhooks/coinpay credits a purchase with a read-check-write sequence:

const p = await get(`SELECT * FROM credit_purchases WHERE id = ? AND status = 'pending'`, [payId]);
if (p) {
  await grant(p.user_id, p.credits, "topup.coinpay", { ... });
  await run(`UPDATE credit_purchases SET status = 'cleared' WHERE id = ?`, [payId]);
}

CoinPay retries a webhook it never got an ack for, so the same confirmation can be in flight twice. Against a remote (network) database both handlers read pending before either UPDATE lands, and the pack is granted twice — a $5 starter pack pays out 2,000 credits.

Reproduced against a throwaway libsql database with each statement deferred to a macrotask (the same technique the device-code tests use, since the local driver resolves in microtasks and serialises handlers that a network DB would interleave). Two concurrent deliveries of one payment.confirmed produce 2 ledger rows totalling 2,000 credits instead of 1 row of 1,000.

Sequential replay was already safe — only the concurrent case leaks credits.

The fix

One file, apps/pwa/src/routes/credits.mjs: claim the row with a conditional UPDATE and grant only if the claim wins.

const claimed = await run(
  `UPDATE credit_purchases SET status = 'cleared' WHERE id = ? AND status = 'pending'`, [payId]);
if (claimed.rowsAffected) await grant(...);

This is the same shape /cli/token (#46) and /cli/device/token (#51) already use to claim single-use codes, so it adds no new vocabulary. Behaviour for a losing duplicate is unchanged from the caller's view: still 200 {ok:true}, so CoinPay stops retrying.

Tests

New apps/pwa/test/credits-webhook.test.mjs (4 tests, built on the existing PWA harness, skips when the PWA deps are not installed):

  1. a confirmed payment credits once and clears the purchase
  2. a sequential replay does not credit twice
  3. concurrent duplicate deliveries credit exactly once — fails on main, passes with the fix
  4. a non-payment event credits nothing and leaves the purchase pending

Verified with git stash push -- apps/pwa/src/: 3 pass / 1 fail without the fix, 4/4 with it. Full suite from the repo root: 187 tests, 187 pass, 0 fail.

…dits

A retried webhook delivery could credit the same purchase twice. The
handler read the purchase as pending, granted the pack, and only then
marked it cleared, so two concurrent deliveries of the same confirmation
both passed the pending check and both granted.

Claim the row with a conditional UPDATE and grant only when it wins,
matching how /cli/token and /cli/device/token claim single-use codes.

Adds test/credits-webhook.test.mjs covering the single delivery, the
sequential replay, the concurrent duplicate, and a non-payment event.
@ralyodio
ralyodio merged commit 14410f1 into moshcoder:main Jul 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants