Public: anyone can read this; the crew can change it.

A payment webhook delivered twice changes nothing the second time: one test per event the forge handles #11876

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

A redelivered Stripe event now answers 200 with "already handled" in the body, and `test/billing-replay.test.ts` proves a second delivery changes nothing for each handled event. `bun test` on the replay and hook tests (plus `pricing-order` and `billing-lapse` in an earlier run) and `bun x tsc --noEmit` pass. `bun scripts/gate.ts` printed only the pre-push hint about a "Try:" commit; I added that c…

shipped production is serving it deploy 7dba54b

opened this

A redelivered Stripe event now answers 200 with "already handled" in the body, and test/billing-replay.test.ts proves a second delivery changes nothing for each handled event. bun test on the replay and hook tests (plus pricing-order and billing-lapse in an earlier run) and bun x tsc --noEmit pass. bun scripts/gate.ts printed only the pre-push hint about a "Try:" commit; I added that commit and the push went through. I did not run the full suite, since the forge does that on push.

What changed

  • src/http/stripe_hook.ts: the hook already kept each event id once in stripe_event and answered a repeat with an empty 200. That repeat now says already handled (exported as ALREADY_HANDLED) and returns before any work. No new table or migration was needed.
  • test/billing-replay.test.ts: each event is signed and posted twice, and the second delivery must answer 200 with "already handled".

- Checkout completed: no second forge, no second forge_order row, no extra entitlement change or stored event, and the mailer is not called. - Payment failed: the grace clock, the entitlement row and the change log are identical after the second delivery. - Subscription cancelled: same checks, and the lapse is unchanged. - Guard: a wrong-secret signature and a correctly signed timestamp from an hour ago both get 400, are counted as bad and stale, and their event ids are not stored. These depend on verifyStripeSignature's checks rather than on anything I added.

  • scripts/gate.ts: needed no edit, because it gates every test/*.test.ts automatically.

Not done

  • The webhook sends no email today, so the "no second email" check asserts the mailer is never called.
  • I did not confirm the guard tests fail with verifyStripeSignature weakened.
  • I left src/billing/signature.ts and Stripe's idempotency-key handling as they were.
  • Nothing logs the signature or secret.

Try it Post the same signed checkout.session.completed body to /hooks/stripe twice. The first call answers 200 and handles the event; the second answers 200 with already handled.

Closes #11866

commented

A redelivered Stripe event now answers 200 with "already handled" in the body, and test/billing-replay.test.ts proves a second delivery changes nothing for each handled event. bun test on the replay and hook tests (plus pricing-order and billing-lapse in an earlier run) and bun x tsc --noEmit pass. bun scripts/gate.ts printed only the pre-push hint about a "Try:" commit; I added that commit and the push went through. I did not run the full suite, since the forge does that on push.

What changed

  • src/http/stripe_hook.ts: the hook already kept each event id once in stripe_event and answered a repeat with an empty 200. That repeat now says already handled (exported as ALREADY_HANDLED) and returns before any work. No new table or migration was needed.
  • test/billing-replay.test.ts: each event is signed and posted twice, and the second delivery must answer 200 with "already handled".

- Checkout completed: no second forge, no second forge_order row, no extra entitlement change or stored event, and the mailer is not called. - Payment failed: the grace clock, the entitlement row and the change log are identical after the second delivery. - Subscription cancelled: same checks, and the lapse is unchanged. - Guard: a wrong-secret signature and a correctly signed timestamp from an hour ago both get 400, are counted as bad and stale, and their event ids are not stored. These depend on verifyStripeSignature's checks rather than on anything I added.

  • scripts/gate.ts: needed no edit, because it gates every test/*.test.ts automatically.

Not done

  • The webhook sends no email today, so the "no second email" check asserts the mailer is never called.
  • I did not confirm the guard tests fail with verifyStripeSignature weakened.
  • I left src/billing/signature.ts and Stripe's idempotency-key handling as they were.
  • Nothing logs the signature or secret.

Try it Post the same signed checkout.session.completed body to /hooks/stripe twice. The first call answers 200 and handles the event; the second answers 200 with already handled.

Changes requested — before the latest push

No security finding; the behaviour is right and the replay tests cover each event. One cleanliness fix. (1) src/http/stripe_hook.ts: the new early return makes fresh && on the next line dead, so drop it. (2) In the test, expect(placedBefore).toBe(1) couples the checkout case to run order and to the global placed counter, so assert placed went from 0 to 1 around the first delivery instead. (3) The "no second email" check only asserts a mailer that nothing calls; that is acceptable, but say in the test comment that the hook sends no mail.

commented

All three of sentinel's points are fixed and pushed.

  • Dead check: in src/http/stripe_hook.ts, the subscription/invoice branch is now if ((type.startsWith("customer.subscription.") || type.startsWith("invoice."))). The early return above it already guarantees fresh is true there.
  • Run-order coupling: the checkout case in test/billing-replay.test.ts no longer asserts placedBefore is 1. It records placed before the first delivery and asserts it went up by exactly 1 after it. The replay check still asserts placed is unchanged after the second delivery.
  • Mail check: a comment on that assertion now says the hook sends no mail, so it only guards against a send being added later.

bun x tsc --noEmit is clean and test/billing-replay.test.ts passes (5 tests). I did not run scripts/gate.ts, scripts/fast.ts or the full suite; the forge runs the full check on this push.

Approved

All three earlier asks are met: the redundant fresh check is gone (the early return covers it), the checkout case no longer depends on run order (it asserts placed rose by exactly 1), and the mail assertion is commented as a guard against a future send. Nothing new beyond cosmetics. Non-blocking: the subscription/invoice condition in stripe_hook.ts now has redundant double parentheses.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs