fix(credits): hold approval credits before delivering, not after - #59
Merged
ralyodio merged 1 commit intoJul 28, 2026
Merged
Conversation
Approval ingest read the balance, delivered to every enabled channel, then inserted the charge. Two ingests in flight at once — a moshscript firing ask()/notify() in parallel — both read a balance that covers one paid delivery, both deliver, and both charge, driving the balance negative and sending paid notifications (sms, slack, telegram) nobody paid for. Reserve the cost in a single INSERT ... SELECT ... WHERE guarded by the user's summed balance, the same way /cli/token and /webhooks/coinpay claim their rows, then settle the hold down to what actually went out. The ledger still keeps one row per delivery for exactly what was delivered.
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.
Approval ingest gates paid channels on the balance, but the gate leaks under concurrency: it reads the balance, delivers, and only then inserts the charge.
Repro (run against unmodified
main, real router + real libsql)A user with 12 credits and one enabled
smschannel (12 credits per delivery). TwoPOST /api/approvalsin flight at once:Both handlers ran
bal >= fullCostagainst the same 12 credits before either charge landed, so both delivered and both charged. The same two requests sequentially behave correctly — B falls back to free channels and returns theinsufficient creditswarning — so this is purely the concurrent window.This is reachable from normal use: a moshscript can fire
ask()/notify()in parallel, and each one is an ingest. The cost is real money on the operator side, sincesms/slack/telegramare outbound provider sends.Fix
apps/pwa/src/lib/credits.mjsgainsreserve()andsettle():reserve()writes the charge withINSERT ... SELECT ... WHERE (SELECT COALESCE(SUM(delta),0) ...) >= ?, so the balance check happens inside the insert. This is the same atomic-claim shape/cli/token,/cli/device/tokenand/webhooks/coinpayalready use — only the first reservation a balance can cover is written.settle()then updates that one row down to whatfanOutactually accepted, so the ledger keeps its existing shape: oneapproval.deliveredrow per delivery, for exactly what went out. Settling to 0 releases the hold, matching the current behaviour of not writing a row when nothing was charged.apps/pwa/src/routes/approvals.mjsreserves beforefanOutinstead of charging after it. The response contract is unchanged (delivered,charged,warning).Tests
New
apps/pwa/test/approvals-credits.test.mjs(5 tests), reusing the harness fromtest/credits-webhook.test.mjs— statements deferred to a macrotask so handlers genuinely interleave (the local libsql driver resolves in microtasks and hides every read-check-write race), and rawhttp.request({agent:false})so keep-alive does not serialise the requests.main(4 pass / 1 fail), passes with the fixFull root suite: 192 tests / 192 pass / 0 fail (187 before these 5). Run
npm installinapps/pwaor the PWA tests skip.