fix(credits): match the CoinPay status as a whole word, not a substring - #81
Merged
ralyodio merged 1 commit intoJul 30, 2026
Conversation
The webhook guard tested /confirmed|completed|paid/i against the event name, which answers "does this name contain a success word anywhere" rather than "did the payment land". A negated status ends in one of those words - "payment.unpaid" ends in "paid", "payment.unconfirmed" ends in "confirmed" - so it was read as a confirmation and granted the full credit pack for money that never arrived. Anchor the status to the end of the event name and require a segment separator (or the start of the string) in front of it, so the last segment of a dotted event name has to be the status itself.
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/coinpaydecides whether an event is a payment confirmation with a bare substring test:Unanchored, that answers "does this event name contain a success word anywhere" rather than "did the payment land". Negated statuses end in the very words it looks for:
payment.unpaidcontainspaidpayment.unconfirmedcontainsconfirmedEither one is read as a confirmation, so the handler claims the pending purchase, marks it
cleared, and grants the full credit pack for money that never arrived.The existing test
webhooks/coinpay: an unrelated event type credits nothingalready encodes the intent that a non-success event must credit nothing. It passes only because its example,payment.failed, happens to contain none of the three substrings.Reproduced first
Against unmodified
main(df9d1e0), realcreditsRouter+ real libsql, no fault injection. Seed a pending 1000-credit purchase, deliver one webhook:payment.unpaidpayment.unconfirmedBoth should have granted nothing and left the purchase
pending.Scope, stated honestly
I could not enumerate CoinPay's event vocabulary from this repo. The only names that appear anywhere are
payment.confirmedandpayment.failed, both in tests. So this is not a report that CoinPay emitspayment.unpaid— it is that the guard cannot distinguish a status from its own negation, which is a property of the code regardless of which names the provider sends.For the same reason the fix is deliberately conservative and does not introduce an allow-list of event names I would have to invent. One known limit remains: a name like
payment.not_completedwould still match, because its final segment iscompleted. Closing that needs the real event list. Happy to tighten it if you can confirm the vocabulary.The fix
Anchor the status to the end of the event name and require a segment separator (or start of string) in front of it:
The last segment of a dotted event name now has to be the status.
unpaidandunconfirmedno longer qualify, because there is no separator between theunprefix and the word.Tests
New
apps/pwa/test/credits-webhook-event-match.test.mjs, 6 tests, same harness ascredits-webhook.test.mjs(real router, throwaway libsql, skip-guard when PWA deps are absent).payment.confirmed,payment.completed,payment.paid,PAYMENT.CONFIRMEDmust all still grant the pack exactly once.Fail-before verified by reverting the source file only and keeping the tests:
The 4 controls pass both ways by design — they prove the guard was tightened rather than the webhook disabled.
Full suites, 0 failures:
apps/pwa30 to 36, rootnpm test204 to 210.