Skip to content

fix(credits): match the CoinPay status as a whole word, not a substring - #81

Merged
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/coinpay-webhook-substring-status
Jul 30, 2026
Merged

fix(credits): match the CoinPay status as a whole word, not a substring#81
ralyodio merged 1 commit into
moshcoder:mainfrom
clawedassistant26:fix/coinpay-webhook-substring-status

Conversation

@clawedassistant26

Copy link
Copy Markdown
Contributor

The bug

POST /webhooks/coinpay decides whether an event is a payment confirmation with a bare substring test:

if (event && /confirmed|completed|paid/i.test(event) && payId) {

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.unpaid contains paid
  • payment.unconfirmed contains confirmed

Either 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 nothing already 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), real creditsRouter + real libsql, no fault injection. Seed a pending 1000-credit purchase, deliver one webhook:

event credits granted purchase status
payment.unpaid 1000 cleared
payment.unconfirmed 1000 cleared

Both 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.confirmed and payment.failed, both in tests. So this is not a report that CoinPay emits payment.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_completed would still match, because its final segment is completed. 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:

const CONFIRMED_EVENT = /(?:^|[.\-_/:])(?:confirmed|completed|paid)$/i;

The last segment of a dotted event name now has to be the status. unpaid and unconfirmed no longer qualify, because there is no separator between the un prefix and the word.

Tests

New apps/pwa/test/credits-webhook-event-match.test.mjs, 6 tests, same harness as credits-webhook.test.mjs (real router, throwaway libsql, skip-guard when PWA deps are absent).

  • 2 assert the negated statuses credit nothing and leave the purchase pending.
  • 4 are controls: payment.confirmed, payment.completed, payment.paid, PAYMENT.CONFIRMED must all still grant the pack exactly once.

Fail-before verified by reverting the source file only and keeping the tests:

  • unpatched: 2 fail / 4 pass (exactly the two negated statuses)
  • patched: 6 / 6 pass

The 4 controls pass both ways by design — they prove the guard was tightened rather than the webhook disabled.

Full suites, 0 failures: apps/pwa 30 to 36, root npm test 204 to 210.

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.
@ralyodio
ralyodio merged commit ff64a00 into moshcoder:main Jul 30, 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