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.
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
|||||||
memberStatusPatch,
|
memberStatusPatch,
|
||||||
stripeInvoiceToPaymentRow,
|
stripeInvoiceToPaymentRow,
|
||||||
minorToMajor,
|
minorToMajor,
|
||||||
|
laterPeriodEnd,
|
||||||
} from '@/lib/stripe/webhook-logic'
|
} from '@/lib/stripe/webhook-logic'
|
||||||
|
|
||||||
describe('isoFromUnix', () => {
|
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', () => {
|
describe('minorToMajor', () => {
|
||||||
it('divides by 100, treats nullish as 0', () => {
|
it('divides by 100, treats nullish as 0', () => {
|
||||||
expect(minorToMajor(2500)).toBe(25)
|
expect(minorToMajor(2500)).toBe(25)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
memberStatusPatch,
|
memberStatusPatch,
|
||||||
stripeInvoiceToPaymentRow,
|
stripeInvoiceToPaymentRow,
|
||||||
minorToMajor,
|
minorToMajor,
|
||||||
|
laterPeriodEnd,
|
||||||
} from '@/lib/stripe/webhook-logic'
|
} from '@/lib/stripe/webhook-logic'
|
||||||
import {
|
import {
|
||||||
renderDuesEmail,
|
renderDuesEmail,
|
||||||
@@ -193,7 +194,22 @@ export async function POST(
|
|||||||
customerId,
|
customerId,
|
||||||
)
|
)
|
||||||
if (!memberId) return
|
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(
|
await admin.from('member_billing').upsert(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,3 +99,18 @@ export function stripeInvoiceToPaymentRow(args: {
|
|||||||
export function minorToMajor(amount: number | null | undefined): number {
|
export function minorToMajor(amount: number | null | undefined): number {
|
||||||
return (amount ?? 0) / 100
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user