From 59456a36e81074ddd8c4d2a6d3228cf37908aa42 Mon Sep 17 00:00:00 2001 From: nams1570 Date: Tue, 23 Jun 2026 22:22:37 -0700 Subject: [PATCH] fix(payments): race with webhook inserts Of course, if we get two events at the same time they will be processed but we cant change that. However, two events in near succession should now be dropped --- .../src/lib/stripe-webhook-events.test.ts | 43 +++++++++++++-- apps/backend/src/lib/stripe-webhook-events.ts | 53 ++++++++----------- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/lib/stripe-webhook-events.test.ts b/apps/backend/src/lib/stripe-webhook-events.test.ts index 65aae6ce3..5918d01c6 100644 --- a/apps/backend/src/lib/stripe-webhook-events.test.ts +++ b/apps/backend/src/lib/stripe-webhook-events.test.ts @@ -38,16 +38,16 @@ describe("stripe webhook event idempotency (real DB)", () => { expect(row?.payload).toMatchObject({ id: event.id, type: event.type }); }); - it("allows reprocessing while the prior delivery is still PENDING", async ({ expect }) => { + it("skips a redelivery while the prior delivery is still in-flight (PENDING)", async ({ expect }) => { const event = makeEvent(); const first = await claimStripeEvent(event); expect(first.shouldProcess).toBe(true); - // Redelivery before the background work finished: we must NOT skip, otherwise - // a crash between claim and processing would silently drop the event forever. + // Single-flight: a redelivery that arrives while the first attempt is still + // PENDING must not spin up a second processor (that would double the fan-out). const second = await claimStripeEvent(event); - expect(second.shouldProcess).toBe(true); + expect(second.shouldProcess).toBe(false); }); it("deduplicates once the event has been fully PROCESSED", async ({ expect }) => { @@ -83,5 +83,40 @@ describe("stripe webhook event idempotency (real DB)", () => { // FAILED rows must reprocess so a manual Stripe "Resend" can recover them. const recovery = await claimStripeEvent(event); expect(recovery.shouldProcess).toBe(true); + + // ...but reclaiming a FAILED row flips it back to in-flight (PENDING), so a + // further redelivery during that retry is once again skipped (single-flight). + const concurrentRetry = await claimStripeEvent(event); + expect(concurrentRetry.shouldProcess).toBe(false); + }); + + it("scrubs a stale processedAt when a row leaves the PROCESSED state", async ({ expect }) => { + const event = makeEvent(); + + await claimStripeEvent(event); + await markStripeEventProcessed(event.id); + + // markStripeEventFailed must clear processedAt so a recovered/re-failed row is + // never readable as "completed at