From d2b7aa6897983c6cd3bf9b44e5a5931c97a1126e Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Mon, 18 May 2026 18:34:03 -0700 Subject: [PATCH] fix(stripe): never rewind billing period on out-of-order webhooks (D5) Stripe doesn't guarantee event order. A late customer.subscription. updated carrying an older period overwrote a newer current_period_end, so graceExceeded flipped a paid member to 'late' and fired a false 'dues lapsed' email + access loss. applySubscription now reads the stored period and keeps the later of stored vs incoming (laterPeriodEnd, pure + tested); status still tracks latest sub.status but grace is computed from the monotonic period. --- __tests__/stripe-webhook-logic.test.ts | 17 +++++++++++++++++ app/api/stripe/webhook/[space]/route.ts | 18 +++++++++++++++++- lib/stripe/webhook-logic.ts | 15 +++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/__tests__/stripe-webhook-logic.test.ts b/__tests__/stripe-webhook-logic.test.ts index 6cb5259..663be73 100644 --- a/__tests__/stripe-webhook-logic.test.ts +++ b/__tests__/stripe-webhook-logic.test.ts @@ -8,6 +8,7 @@ import { memberStatusPatch, stripeInvoiceToPaymentRow, minorToMajor, + laterPeriodEnd, } from '@/lib/stripe/webhook-logic' describe('isoFromUnix', () => { @@ -123,6 +124,22 @@ describe('stripeInvoiceToPaymentRow', () => { }) }) +describe('laterPeriodEnd (never rewind on out-of-order events)', () => { + const older = '2026-05-01T00:00:00.000Z' + const newer = '2026-06-01T00:00:00.000Z' + it('keeps the later of stored vs incoming regardless of arrival order', () => { + expect(laterPeriodEnd(newer, older)).toBe(newer) // stale event arrives late + expect(laterPeriodEnd(older, newer)).toBe(newer) // normal advance + expect(laterPeriodEnd(newer, newer)).toBe(newer) + }) + it('handles nulls (first event / canceled sub with no period)', () => { + expect(laterPeriodEnd(null, newer)).toBe(newer) + expect(laterPeriodEnd(newer, null)).toBe(newer) + expect(laterPeriodEnd(null, null)).toBeNull() + expect(laterPeriodEnd(undefined, undefined)).toBeNull() + }) +}) + describe('minorToMajor', () => { it('divides by 100, treats nullish as 0', () => { expect(minorToMajor(2500)).toBe(25) diff --git a/app/api/stripe/webhook/[space]/route.ts b/app/api/stripe/webhook/[space]/route.ts index d257fe4..a5239ac 100644 --- a/app/api/stripe/webhook/[space]/route.ts +++ b/app/api/stripe/webhook/[space]/route.ts @@ -21,6 +21,7 @@ import { memberStatusPatch, stripeInvoiceToPaymentRow, minorToMajor, + laterPeriodEnd, } from '@/lib/stripe/webhook-logic' import { renderDuesEmail, @@ -193,7 +194,22 @@ export async function POST( customerId, ) if (!memberId) return - const periodEnd = subscriptionPeriodEnd(sub) + const incomingPeriodEnd = subscriptionPeriodEnd(sub) + + // Never rewind the period on a stale/out-of-order event. Status still + // reflects the latest sub.status, but graceExceeded is computed from the + // monotonic (non-rewound) period so a late event can't false-lapse a + // paid member. + const { data: existingBilling } = await admin + .from('member_billing') + .select('current_period_end') + .eq('space_id', spaceId) + .eq('member_id', memberId) + .maybeSingle() + const periodEnd = laterPeriodEnd( + (existingBilling?.current_period_end as string | null) ?? null, + incomingPeriodEnd, + ) await admin.from('member_billing').upsert( { diff --git a/lib/stripe/webhook-logic.ts b/lib/stripe/webhook-logic.ts index 7457fed..d103091 100644 --- a/lib/stripe/webhook-logic.ts +++ b/lib/stripe/webhook-logic.ts @@ -99,3 +99,18 @@ export function stripeInvoiceToPaymentRow(args: { export function minorToMajor(amount: number | null | undefined): number { return (amount ?? 0) / 100 } + +// Stripe does not guarantee webhook ordering. A stale/late +// customer.subscription.updated carrying an OLDER period must never rewind +// the stored current_period_end (that would make graceExceeded flip a paid +// member to 'late' and fire a false "dues lapsed" email). Keep the later of +// stored vs incoming. ISO-8601 UTC ('...Z') strings sort lexically == +// chronologically, so string compare is correct here. +export function laterPeriodEnd( + stored: string | null | undefined, + incoming: string | null | undefined, +): string | null { + if (!stored) return incoming ?? null + if (!incoming) return stored + return stored >= incoming ? stored : incoming +}