fix(credits): claim a CoinPay purchase atomically before granting credits - #58
Merged
ralyodio merged 1 commit intoJul 27, 2026
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
POST /webhooks/coinpaycredits a purchase with a read-check-write sequence: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
pendingbefore 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.confirmedproduce 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.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: still200 {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):main, passes with the fixVerified 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.