From baf26e863262c5c4af27e81aaf6e71c0919c827a Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Tue, 8 Sep 2026 08:20:06 -0400 Subject: [PATCH 1/5] feat(stripe): capture normalized billing facts --- prisma/schema.prisma | 1 + .../stripe/utils/idempotency.postgres.test.ts | 86 ++++- .../api/stripe/utils/idempotency.test.ts | 46 ++- .../api/stripe/webhook-reliability.test.ts | 222 ++++++++++- .../utils/__tests__/billing-facts.test.ts | 354 ++++++++++++++++++ src/lib/stripe/utils/billing-facts.ts | 216 +++++++++++ src/lib/stripe/utils/idempotency.ts | 3 + src/pages/api/stripe/webhook.ts | 19 +- ...150000_add_webhook_event_billing_facts.sql | 2 + 9 files changed, 925 insertions(+), 24 deletions(-) create mode 100644 src/lib/stripe/utils/__tests__/billing-facts.test.ts create mode 100644 src/lib/stripe/utils/billing-facts.ts create mode 100644 supabase/migrations/20260908150000_add_webhook_event_billing_facts.sql diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2d485ca7..6cd1abf1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -327,6 +327,7 @@ model WebhookEvent { stripeEventId String @unique eventType String processedAt DateTime + billingFacts Json? } model ScheduledMessage { diff --git a/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts b/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts index 5a9014c9..422b6997 100644 --- a/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts +++ b/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts @@ -4,6 +4,7 @@ import { setTimeout } from 'node:timers/promises' import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import prisma from '@/lib/db' +import type { BillingFacts } from '@/lib/stripe/utils/billing-facts' import { processEventIdempotently } from '@/lib/stripe/utils/idempotency' import { withTransaction } from '@/lib/stripe/utils/transaction' @@ -39,13 +40,30 @@ describePostgres('Stripe webhook PostgreSQL reliability', () => { let processorCalls = 0 receiptIds.add(eventId) scheduledMessageIds.add(scheduledMessageId) + const billingFacts = { + cancelAtPeriodEnd: false, + canceledAt: null, + cancellationReason: null, + currentPeriodEnd: 1_800_000_000, + endedAt: null, + kind: 'subscription', + livemode: true, + occurredAt: 1_700_000_000, + status: 'past_due', + stripeCustomerId: 'cus_rollback', + stripeSubscriptionId: 'sub_rollback', + trialEnd: null, + trialStart: null, + version: 1, + } satisfies BillingFacts await expect( withTransaction( async (tx) => await processEventIdempotently( eventId, - 'checkout.session.completed', + 'customer.subscription.updated', + billingFacts, async (transactionClient) => { processorCalls += 1 await transactionClient.scheduledMessage.create({ @@ -87,6 +105,7 @@ describePostgres('Stripe webhook PostgreSQL reliability', () => { await processEventIdempotently( eventId, 'checkout.session.completed', + undefined, async () => { processorCalls += 1 processorStarted.resolve(true) @@ -100,7 +119,13 @@ describePostgres('Stripe webhook PostgreSQL reliability', () => { const secondDelivery = withTransaction( async (tx) => - await processEventIdempotently(eventId, 'checkout.session.completed', countProcessor, tx), + await processEventIdempotently( + eventId, + 'checkout.session.completed', + undefined, + countProcessor, + tx, + ), ) const deliveries = Promise.allSettled([firstDelivery, secondDelivery]) @@ -113,11 +138,66 @@ describePostgres('Stripe webhook PostgreSQL reliability', () => { const laterDelivery = await withTransaction( async (tx) => - await processEventIdempotently(eventId, 'checkout.session.completed', countProcessor, tx), + await processEventIdempotently( + eventId, + 'checkout.session.completed', + undefined, + countProcessor, + tx, + ), ) expect(laterDelivery.kind).toBe('duplicate') expect(processorCalls).toBe(1) await expect(prisma.webhookEvent.count({ where: { stripeEventId: eventId } })).resolves.toBe(1) }, 20_000) + + it('keeps the original billing fact when a later delivery is a duplicate', async () => { + const eventId = `evt_fact_${randomUUID()}` + receiptIds.add(eventId) + const originalFact = { + cancelAtPeriodEnd: false, + canceledAt: null, + cancellationReason: null, + currentPeriodEnd: 1_800_000_000, + endedAt: null, + kind: 'subscription', + livemode: true, + occurredAt: 1_700_000_000, + status: 'active', + stripeCustomerId: 'cus_original', + stripeSubscriptionId: 'sub_original', + trialEnd: null, + trialStart: null, + version: 1, + } satisfies BillingFacts + const laterFact = { ...originalFact, status: 'canceled' } satisfies BillingFacts + const processor = async () => await Promise.resolve() + + await withTransaction( + async (tx) => + await processEventIdempotently( + eventId, + 'customer.subscription.updated', + originalFact, + processor, + tx, + ), + ) + const duplicate = await withTransaction( + async (tx) => + await processEventIdempotently( + eventId, + 'customer.subscription.updated', + laterFact, + processor, + tx, + ), + ) + + expect(duplicate.kind).toBe('duplicate') + await expect( + prisma.webhookEvent.findUnique({ where: { stripeEventId: eventId } }), + ).resolves.toMatchObject({ billingFacts: originalFact }) + }) }) diff --git a/src/__tests__/pages/api/stripe/utils/idempotency.test.ts b/src/__tests__/pages/api/stripe/utils/idempotency.test.ts index 09a53369..7df57f09 100644 --- a/src/__tests__/pages/api/stripe/utils/idempotency.test.ts +++ b/src/__tests__/pages/api/stripe/utils/idempotency.test.ts @@ -2,6 +2,7 @@ import type { Prisma } from '@prisma/client' import { PrismaClient } from '@prisma/client' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { BillingFacts } from '@/lib/stripe/utils/billing-facts' import { processEventIdempotently } from '@/lib/stripe/utils/idempotency' describe(processEventIdempotently, () => { @@ -10,6 +11,7 @@ describe(processEventIdempotently, () => { const remove = vi.spyOn(tx.webhookEvent, 'delete') const findUnique = vi.spyOn(tx.webhookEvent, 'findUnique') const receipt = { + billingFacts: null, eventType: 'checkout.session.completed', id: 'receipt-1', processedAt: new Date('2026-09-07T12:00:00.000Z'), @@ -35,7 +37,7 @@ describe(processEventIdempotently, () => { .mockResolvedValue() await expect( - processEventIdempotently('evt_1', 'checkout.session.completed', processor, tx), + processEventIdempotently('evt_1', 'checkout.session.completed', undefined, processor, tx), ).resolves.toStrictEqual({ kind: 'processed' }) expect(processor).toHaveBeenCalledOnce() @@ -48,7 +50,7 @@ describe(processEventIdempotently, () => { const processor = vi.fn<(transaction: Prisma.TransactionClient) => Promise>() await expect( - processEventIdempotently('evt_1', 'checkout.session.completed', processor, tx), + processEventIdempotently('evt_1', 'checkout.session.completed', undefined, processor, tx), ).resolves.toStrictEqual({ kind: 'duplicate', processedAt: receipt.processedAt }) expect(processor).not.toHaveBeenCalled() @@ -62,6 +64,7 @@ describe(processEventIdempotently, () => { processEventIdempotently( 'evt_1', 'checkout.session.completed', + undefined, async () => { await Promise.resolve() throw processorError @@ -73,4 +76,43 @@ describe(processEventIdempotently, () => { expect(create).toHaveBeenCalledOnce() expect(remove).not.toHaveBeenCalled() }) + + it('stores billing facts on the receipt create', async () => { + const billingFacts = { + cancelAtPeriodEnd: false, + canceledAt: null, + cancellationReason: null, + currentPeriodEnd: 1_800_000_000, + endedAt: null, + kind: 'subscription', + livemode: true, + occurredAt: 1_700_000_000, + status: 'active', + stripeCustomerId: 'cus_test', + stripeSubscriptionId: 'sub_test', + trialEnd: null, + trialStart: null, + version: 1, + } satisfies BillingFacts + const processor = vi + .fn<(transaction: Prisma.TransactionClient) => Promise>() + .mockResolvedValue() + + await processEventIdempotently( + 'evt_1', + 'customer.subscription.updated', + billingFacts, + processor, + tx, + ) + + expect(create).toHaveBeenCalledWith({ + data: { + billingFacts, + eventType: 'customer.subscription.updated', + processedAt: expect.any(Date), + stripeEventId: 'evt_1', + }, + }) + }) }) diff --git a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts index 02b30cdd..b8265e4a 100644 --- a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts +++ b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts @@ -4,35 +4,139 @@ import { createMocks } from 'node-mocks-http' import { Stripe } from 'stripe' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { stripe as serverStripe } from '@/lib/stripe-server' import type { WebhookHandlerDependencies } from '@/pages/api/stripe/webhook' -import { createWebhookHandler } from '@/pages/api/stripe/webhook' +import type { WebhookEventProcessorDependencies } from '@/pages/api/stripe/webhook' +import { + createWebhookHandler, + processWebhookEvent as processVerifiedWebhookEvent, +} from '@/pages/api/stripe/webhook' -const stripe = new Stripe('sk_test_dummy') +const testStripe = new Stripe('sk_test_dummy') const webhookSecret = 'test-secret' const payload = JSON.stringify({ data: { object: { id: 'cs_reliability' } }, id: 'evt_reliability', type: 'checkout.session.completed', }) -const signature = stripe.webhooks.generateTestHeaderString({ +const signature = testStripe.webhooks.generateTestHeaderString({ payload, secret: webhookSecret, }) -const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret) +const event = testStripe.webhooks.constructEvent(payload, signature, webhookSecret) const irrelevantPayload = JSON.stringify({ data: { object: { id: 'pm_irrelevant' } }, id: 'evt_irrelevant', type: 'payment_method.attached', }) -const irrelevantSignature = stripe.webhooks.generateTestHeaderString({ +const irrelevantSignature = testStripe.webhooks.generateTestHeaderString({ payload: irrelevantPayload, secret: webhookSecret, }) -const irrelevantEvent = stripe.webhooks.constructEvent( +const irrelevantEvent = testStripe.webhooks.constructEvent( irrelevantPayload, irrelevantSignature, webhookSecret, ) +const voidedPayload = JSON.stringify({ + created: 1_750_000_000, + data: { + object: { + amount_due: 2_500, + amount_paid: 0, + amount_remaining: 2_500, + attempt_count: 1, + billing_reason: 'subscription_cycle', + collection_method: 'charge_automatically', + currency: 'usd', + customer: 'cus_voided', + id: 'in_voided', + lines: { data: [] }, + parent: null, + status: 'void', + status_transitions: { + marked_uncollectible_at: null, + paid_at: null, + voided_at: 1_750_000_100, + }, + }, + }, + id: 'evt_voided', + livemode: true, + type: 'invoice.voided', +}) +const voidedSignature = testStripe.webhooks.generateTestHeaderString({ + payload: voidedPayload, + secret: webhookSecret, +}) +const voidedEvent = testStripe.webhooks.constructEvent( + voidedPayload, + voidedSignature, + webhookSecret, +) +const noLocalRowPayload = JSON.stringify({ + created: 1_750_000_000, + data: { + object: { + amount_due: 2_500, + amount_paid: 0, + amount_remaining: 2_500, + attempt_count: 1, + billing_reason: 'subscription_cycle', + collection_method: 'charge_automatically', + currency: 'usd', + customer: 'cus_no_local_row', + id: 'in_no_local_row', + lines: { data: [{ parent: null, subscription: 'sub_no_local_row' }] }, + metadata: {}, + parent: null, + status: 'open', + status_transitions: { + marked_uncollectible_at: null, + paid_at: null, + voided_at: null, + }, + }, + }, + id: 'evt_no_local_row', + livemode: true, + type: 'invoice.payment_failed', +}) +const noLocalRowSignature = testStripe.webhooks.generateTestHeaderString({ + payload: noLocalRowPayload, + secret: webhookSecret, +}) +const noLocalRowEvent = testStripe.webhooks.constructEvent( + noLocalRowPayload, + noLocalRowSignature, + webhookSecret, +) +const providerSubscriptionPayload = JSON.stringify({ + data: { + object: { + cancel_at_period_end: false, + customer: 'cus_no_local_row', + id: 'sub_no_local_row', + items: { data: [{ current_period_end: 1_800_000_000 }] }, + status: 'active', + }, + }, + id: 'evt_provider_subscription', + type: 'customer.subscription.updated', +}) +const providerSubscriptionSignature = testStripe.webhooks.generateTestHeaderString({ + payload: providerSubscriptionPayload, + secret: webhookSecret, +}) +const providerSubscriptionEvent = testStripe.webhooks.constructEvent( + providerSubscriptionPayload, + providerSubscriptionSignature, + webhookSecret, +) + +if (providerSubscriptionEvent.type !== 'customer.subscription.updated') { + throw new Error('Expected a subscription event fixture') +} const createRequestResponse = (method: 'GET' | 'POST' = 'POST') => createMocks({ @@ -43,17 +147,18 @@ describe('Stripe webhook reliability', () => { const tx = new PrismaClient() const create = vi.spyOn(tx.webhookEvent, 'create') const findUnique = vi.spyOn(tx.webhookEvent, 'findUnique') - const processWebhookEvent = vi.fn() + const processWebhookEventMock = vi.fn() const verifyWebhook = vi.fn() const transaction = vi.fn() const receipt = { + billingFacts: null, eventType: 'checkout.session.completed', id: 'receipt-1', processedAt: new Date('2026-09-07T12:00:00.000Z'), stripeEventId: event.id, } const handler = createWebhookHandler({ - processWebhookEvent, + processWebhookEvent: processWebhookEventMock, verifyWebhook, withTransaction: transaction, }) @@ -63,7 +168,7 @@ describe('Stripe webhook reliability', () => { vi.stubEnv('VERCEL_ENV', 'production') vi.spyOn(console, 'error').mockImplementation(() => {}) - processWebhookEvent.mockResolvedValue() + processWebhookEventMock.mockResolvedValue() verifyWebhook.mockResolvedValue({ event }) transaction.mockImplementation(async (operation) => await operation(tx)) findUnique.mockResolvedValue(null) @@ -77,7 +182,7 @@ describe('Stripe webhook reliability', () => { expect(verifyWebhook).not.toHaveBeenCalled() expect(transaction).not.toHaveBeenCalled() - expect(processWebhookEvent).not.toHaveBeenCalled() + expect(processWebhookEventMock).not.toHaveBeenCalled() expect(res.statusCode).toBe(405) expect(res._getJSONData()).toStrictEqual({ error: 'Method not allowed' }) }) @@ -90,7 +195,7 @@ describe('Stripe webhook reliability', () => { expect(verifyWebhook).toHaveBeenCalledOnce() expect(transaction).not.toHaveBeenCalled() - expect(processWebhookEvent).not.toHaveBeenCalled() + expect(processWebhookEventMock).not.toHaveBeenCalled() expect(res.statusCode).toBe(400) expect(res._getJSONData()).toStrictEqual({ error: 'Webhook verification failed' }) }) @@ -103,19 +208,19 @@ describe('Stripe webhook reliability', () => { expect(verifyWebhook).toHaveBeenCalledOnce() expect(transaction).not.toHaveBeenCalled() - expect(processWebhookEvent).not.toHaveBeenCalled() + expect(processWebhookEventMock).not.toHaveBeenCalled() expect(res.statusCode).toBe(200) expect(res._getJSONData()).toStrictEqual({ received: true }) }) it('returns 500 and invokes a rejected handler once per delivery', async () => { - processWebhookEvent.mockRejectedValue(new Error('processor failed')) + processWebhookEventMock.mockRejectedValue(new Error('processor failed')) const { req, res } = createRequestResponse() await handler(req, res) expect(transaction).toHaveBeenCalledOnce() - expect(processWebhookEvent).toHaveBeenCalledOnce() + expect(processWebhookEventMock).toHaveBeenCalledOnce() expect(res.statusCode).toBe(500) expect(res._getJSONData()).toStrictEqual({ error: 'Webhook processing failed', @@ -130,7 +235,7 @@ describe('Stripe webhook reliability', () => { await handler(req, res) expect(transaction).toHaveBeenCalledOnce() - expect(processWebhookEvent).not.toHaveBeenCalled() + expect(processWebhookEventMock).not.toHaveBeenCalled() expect(res.statusCode).toBe(500) expect(res._getJSONData()).toStrictEqual({ error: 'Webhook processing failed', @@ -145,7 +250,7 @@ describe('Stripe webhook reliability', () => { await handler(req, res) expect(transaction).toHaveBeenCalledOnce() - expect(processWebhookEvent).not.toHaveBeenCalled() + expect(processWebhookEventMock).not.toHaveBeenCalled() expect(res.statusCode).toBe(200) expect(res._getJSONData()).toStrictEqual({ processed: true, @@ -160,7 +265,90 @@ describe('Stripe webhook reliability', () => { await handler(req, res) - expect(processWebhookEvent).toHaveBeenCalledOnce() + expect(processWebhookEventMock).toHaveBeenCalledOnce() + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) + }) + + it('stores invoice.voided facts without invoking invoice entitlement handling', async () => { + verifyWebhook.mockResolvedValue({ event: voidedEvent }) + const handleInvoiceEvent = vi + .fn() + .mockResolvedValue(true) + const voidedHandler = createWebhookHandler({ + processWebhookEvent: async (verifiedEvent, transactionClient) => + await processVerifiedWebhookEvent(verifiedEvent, transactionClient, { + handleInvoiceEvent, + }), + verifyWebhook, + withTransaction: transaction, + }) + const { req, res } = createRequestResponse() + + await voidedHandler(req, res) + + expect(handleInvoiceEvent).not.toHaveBeenCalled() + expect(create).toHaveBeenCalledWith({ + data: { + billingFacts: { + amountDueMinor: 2_500, + amountPaidMinor: 0, + amountRemainingMinor: 2_500, + attemptCount: 1, + billingReason: 'subscription_cycle', + collectionMethod: 'charge_automatically', + currency: 'usd', + invoicePayments: [], + kind: 'invoice', + livemode: true, + markedUncollectibleAt: null, + occurredAt: 1_750_000_000, + paidAt: null, + paymentEvidence: 'unavailable', + status: 'void', + stripeCustomerId: 'cus_voided', + stripeInvoiceId: 'in_voided', + stripeSubscriptionId: null, + version: 1, + voidedAt: 1_750_000_100, + }, + eventType: 'invoice.voided', + processedAt: expect.any(Date), + stripeEventId: 'evt_voided', + }, + }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) + }) + + it('retains an invoice fact when the existing handler updates no local subscription row', async () => { + verifyWebhook.mockResolvedValue({ event: noLocalRowEvent }) + vi.spyOn(tx, '$executeRaw').mockResolvedValue(0) + const updateMany = vi.spyOn(tx.subscription, 'updateMany').mockResolvedValue({ count: 0 }) + vi.spyOn(serverStripe.subscriptions, 'retrieve').mockResolvedValue( + providerSubscriptionEvent.data.object, + ) + const noLocalRowHandler = createWebhookHandler({ + processWebhookEvent: processVerifiedWebhookEvent, + verifyWebhook, + withTransaction: transaction, + }) + const { req, res } = createRequestResponse() + + await noLocalRowHandler(req, res) + + expect(updateMany).toHaveBeenCalledOnce() + expect(create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + billingFacts: expect.objectContaining({ + kind: 'invoice', + stripeInvoiceId: 'in_no_local_row', + stripeSubscriptionId: 'sub_no_local_row', + }), + eventType: 'invoice.payment_failed', + stripeEventId: 'evt_no_local_row', + }), + }) expect(res.statusCode).toBe(200) expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) }) diff --git a/src/lib/stripe/utils/__tests__/billing-facts.test.ts b/src/lib/stripe/utils/__tests__/billing-facts.test.ts new file mode 100644 index 00000000..90f26f28 --- /dev/null +++ b/src/lib/stripe/utils/__tests__/billing-facts.test.ts @@ -0,0 +1,354 @@ +import { Stripe } from 'stripe' +import { describe, expect, it } from 'vitest' + +import { extractBillingFacts } from '../billing-facts' + +const stripe = new Stripe('sk_test_dummy') +const webhookSecret = 'billing-facts-test-secret' + +const createEvent = function createEvent(type: string, object: Record) { + const payload = JSON.stringify({ + created: 1_750_000_000, + data: { object }, + id: `evt_${type.replaceAll('.', '_')}`, + livemode: true, + type, + }) + const signature = stripe.webhooks.generateTestHeaderString({ payload, secret: webhookSecret }) + + return stripe.webhooks.constructEvent(payload, signature, webhookSecret) +} + +const invoice = function invoice(overrides: Record = {}) { + return { + amount_due: 2_500, + amount_paid: 2_500, + amount_remaining: 0, + attempt_count: 2, + billing_reason: 'subscription_cycle', + collection_method: 'charge_automatically', + currency: 'usd', + customer: { + email: 'excluded@example.test', + id: 'cus_invoice', + name: 'Excluded Customer', + }, + customer_email: 'excluded@example.test', + hosted_invoice_url: 'https://excluded.example.test/invoice', + id: 'in_paid', + lines: { + data: [], + has_more: false, + object: 'list', + url: '/v1/invoices/in_paid/lines', + }, + metadata: { excluded: 'value' }, + parent: { + quote_details: null, + subscription_details: { + metadata: { excluded: 'value' }, + subscription: { id: 'sub_parent', metadata: { excluded: 'value' } }, + }, + type: 'subscription_details', + }, + payments: { + data: [ + { + amount_paid: 1_500, + currency: 'usd', + id: 'inpay_charge', + payment: { + charge: { billing_details: { email: 'excluded@example.test' }, id: 'ch_paid' }, + type: 'charge', + }, + status: 'paid', + status_transitions: { canceled_at: null, paid_at: 1_750_000_100 }, + }, + { + amount_paid: 1_000, + currency: 'usd', + id: 'inpay_intent', + payment: { payment_intent: 'pi_paid', type: 'payment_intent' }, + status: 'paid', + status_transitions: { canceled_at: null, paid_at: 1_750_000_200 }, + }, + ], + has_more: false, + object: 'list', + url: '/v1/invoice_payments', + }, + status: 'paid', + status_transitions: { + finalized_at: 1_749_999_900, + marked_uncollectible_at: null, + paid_at: 1_750_000_200, + voided_at: null, + }, + ...overrides, + } +} + +const subscription = function subscription(overrides: Record = {}) { + return { + cancel_at_period_end: false, + canceled_at: null, + cancellation_details: null, + customer: { email: 'excluded@example.test', id: 'cus_subscription' }, + description: 'Excluded description', + ended_at: null, + id: 'sub_fact', + items: { + data: [ + { current_period_end: 1_800_000_000, id: 'si_earlier' }, + { current_period_end: 1_810_000_000, id: 'si_later' }, + ], + has_more: false, + object: 'list', + url: '/v1/subscription_items', + }, + metadata: { excluded: 'value' }, + status: 'active', + trial_end: null, + trial_start: null, + ...overrides, + } +} + +describe(extractBillingFacts, () => { + it('returns the exact allowlisted invoice projection with complete payment evidence', () => { + const event = createEvent('invoice.paid', invoice()) + + expect(extractBillingFacts(event)).toStrictEqual({ + amountDueMinor: 2_500, + amountPaidMinor: 2_500, + amountRemainingMinor: 0, + attemptCount: 2, + billingReason: 'subscription_cycle', + collectionMethod: 'charge_automatically', + currency: 'usd', + invoicePayments: [ + { + amountPaidMinor: 1_500, + currency: 'usd', + paidAt: 1_750_000_100, + paymentObjectId: 'ch_paid', + paymentObjectType: 'charge', + status: 'paid', + stripeInvoicePaymentId: 'inpay_charge', + }, + { + amountPaidMinor: 1_000, + currency: 'usd', + paidAt: 1_750_000_200, + paymentObjectId: 'pi_paid', + paymentObjectType: 'payment_intent', + status: 'paid', + stripeInvoicePaymentId: 'inpay_intent', + }, + ], + kind: 'invoice', + livemode: true, + markedUncollectibleAt: null, + occurredAt: 1_750_000_000, + paidAt: 1_750_000_200, + paymentEvidence: 'available', + status: 'paid', + stripeCustomerId: 'cus_invoice', + stripeInvoiceId: 'in_paid', + stripeSubscriptionId: 'sub_parent', + version: 1, + voidedAt: null, + }) + }) + + it.each([ + { + eventType: 'invoice.payment_failed', + status: 'open', + transitions: { + finalized_at: 1_749_999_900, + marked_uncollectible_at: null, + paid_at: null, + voided_at: null, + }, + }, + { + eventType: 'invoice.voided', + status: 'void', + transitions: { + finalized_at: 1_749_999_900, + marked_uncollectible_at: null, + paid_at: null, + voided_at: 1_750_000_300, + }, + }, + { + eventType: 'invoice.marked_uncollectible', + status: 'uncollectible', + transitions: { + finalized_at: 1_749_999_900, + marked_uncollectible_at: 1_750_000_400, + paid_at: null, + voided_at: null, + }, + }, + ])('preserves $eventType status transitions', ({ eventType, status, transitions }) => { + const fact = extractBillingFacts( + createEvent( + eventType, + invoice({ payments: undefined, status, status_transitions: transitions }), + ), + ) + + expect(fact).toMatchObject({ + markedUncollectibleAt: transitions.marked_uncollectible_at, + paidAt: transitions.paid_at, + status, + voidedAt: transitions.voided_at, + }) + }) + + it.each([ + { + lines: [ + { parent: null, subscription: null }, + { parent: null, subscription: { id: 'sub_legacy_expanded' } }, + ], + stripeSubscriptionId: 'sub_legacy_expanded', + }, + { + lines: [ + { parent: null, subscription: null }, + { + parent: { + invoice_item_details: null, + subscription_item_details: { subscription: 'sub_item_parent' }, + type: 'subscription_item_details', + }, + subscription: null, + }, + ], + stripeSubscriptionId: 'sub_item_parent', + }, + { + lines: [ + { parent: null, subscription: null }, + { + parent: { + invoice_item_details: { subscription: 'sub_invoice_item_parent' }, + subscription_item_details: null, + type: 'invoice_item_details', + }, + subscription: null, + }, + ], + stripeSubscriptionId: 'sub_invoice_item_parent', + }, + ])( + 'falls back across all invoice lines to $stripeSubscriptionId', + ({ lines, stripeSubscriptionId }) => { + const fact = extractBillingFacts( + createEvent( + 'invoice.payment_failed', + invoice({ lines: { data: lines }, parent: null, payments: undefined }), + ), + ) + + expect(fact).toMatchObject({ stripeSubscriptionId }) + }, + ) + + it('keeps an invoice fact when subscription linkage is unresolved', () => { + const fact = extractBillingFacts( + createEvent( + 'invoice.payment_failed', + invoice({ lines: { data: [] }, parent: null, payments: undefined }), + ), + ) + + expect(fact).toMatchObject({ stripeInvoiceId: 'in_paid', stripeSubscriptionId: null }) + }) + + it.each([ + undefined, + { data: [], has_more: false }, + { data: [], has_more: true }, + { + data: [ + { + amount_paid: null, + currency: 'usd', + id: 'inpay_incomplete', + payment: { payment_intent: 'pi_incomplete', type: 'payment_intent' }, + status: 'open', + status_transitions: { paid_at: null }, + }, + ], + has_more: false, + }, + ])('marks absent or incomplete embedded payment evidence unavailable', (payments) => { + const fact = extractBillingFacts(createEvent('invoice.payment_failed', invoice({ payments }))) + + expect(fact).toMatchObject({ invoicePayments: [], paymentEvidence: 'unavailable' }) + }) + + it('rejects an invoice event without an invoice ID', () => { + expect(() => + extractBillingFacts(createEvent('invoice.payment_failed', invoice({ id: undefined }))), + ).toThrow('missing an invoice ID') + }) + + it.each(['active', 'past_due', 'unpaid', 'incomplete_expired', 'canceled'])( + 'preserves the raw subscription status %s', + (status) => { + const fact = extractBillingFacts( + createEvent( + 'customer.subscription.updated', + subscription({ + cancel_at_period_end: status === 'canceled', + canceled_at: status === 'canceled' ? 1_750_000_500 : null, + cancellation_details: status === 'canceled' ? { reason: 'payment_failed' } : null, + ended_at: status === 'canceled' ? 1_750_000_600 : null, + status, + trial_end: 1_760_000_000, + trial_start: 1_750_000_000, + }), + ), + ) + + expect(fact).toStrictEqual({ + cancelAtPeriodEnd: status === 'canceled', + canceledAt: status === 'canceled' ? 1_750_000_500 : null, + cancellationReason: status === 'canceled' ? 'payment_failed' : null, + currentPeriodEnd: 1_810_000_000, + endedAt: status === 'canceled' ? 1_750_000_600 : null, + kind: 'subscription', + livemode: true, + occurredAt: 1_750_000_000, + status, + stripeCustomerId: 'cus_subscription', + stripeSubscriptionId: 'sub_fact', + trialEnd: 1_760_000_000, + trialStart: 1_750_000_000, + version: 1, + }) + }, + ) + + it('uses null when a subscription has no item period end', () => { + const fact = extractBillingFacts( + createEvent('customer.subscription.created', subscription({ items: { data: [] } })), + ) + + expect(fact).toMatchObject({ currentPeriodEnd: null }) + }) + + it.each(['checkout.session.completed', 'charge.succeeded', 'customer.deleted'])( + 'returns no fact for %s', + (eventType) => { + expect( + extractBillingFacts(createEvent(eventType, { id: 'object_non_billing' })), + ).toBeUndefined() + }, + ) +}) diff --git a/src/lib/stripe/utils/billing-facts.ts b/src/lib/stripe/utils/billing-facts.ts new file mode 100644 index 00000000..5d0e46d4 --- /dev/null +++ b/src/lib/stripe/utils/billing-facts.ts @@ -0,0 +1,216 @@ +import type Stripe from 'stripe' + +type CommonBillingFacts = { + livemode: boolean + occurredAt: number + stripeCustomerId: string | null + version: 1 +} + +export type InvoicePaymentFact = { + amountPaidMinor: number + currency: string + paidAt: number + paymentObjectId: string + paymentObjectType: Stripe.InvoicePayment.Payment.Type + status: string + stripeInvoicePaymentId: string +} + +export type InvoiceBillingFacts = CommonBillingFacts & { + amountDueMinor: number + amountPaidMinor: number + amountRemainingMinor: number + attemptCount: number + billingReason: Stripe.Invoice.BillingReason | null + collectionMethod: Stripe.Invoice.CollectionMethod + currency: string + invoicePayments: InvoicePaymentFact[] + kind: 'invoice' + markedUncollectibleAt: number | null + paidAt: number | null + paymentEvidence: 'available' | 'unavailable' + status: Stripe.Invoice.Status | null + stripeInvoiceId: string + stripeSubscriptionId: string | null + voidedAt: number | null +} + +export type SubscriptionBillingFacts = CommonBillingFacts & { + cancelAtPeriodEnd: boolean + canceledAt: number | null + cancellationReason: Stripe.Subscription.CancellationDetails.Reason | null + currentPeriodEnd: number | null + endedAt: number | null + kind: 'subscription' + status: Stripe.Subscription.Status + stripeSubscriptionId: string + trialEnd: number | null + trialStart: number | null +} + +export type BillingFacts = InvoiceBillingFacts | SubscriptionBillingFacts + +type StripeIdReference = string | { id: string } | null | undefined + +const normalizeStripeId = function normalizeStripeId(reference: StripeIdReference): string | null { + if (typeof reference === 'string') { + return reference + } + + return reference?.id ?? null +} + +const findInvoiceSubscriptionId = function findInvoiceSubscriptionId( + invoice: Stripe.Invoice, +): string | null { + if (invoice.parent?.type === 'subscription_details') { + const parentSubscriptionId = normalizeStripeId( + invoice.parent.subscription_details?.subscription, + ) + + if (parentSubscriptionId) { + return parentSubscriptionId + } + } + + for (const line of invoice.lines.data) { + const legacySubscriptionId = normalizeStripeId(line.subscription) + if (legacySubscriptionId) { + return legacySubscriptionId + } + + if (line.parent?.type === 'subscription_item_details') { + const subscriptionId = line.parent.subscription_item_details?.subscription + if (subscriptionId) { + return subscriptionId + } + } + + if (line.parent?.type === 'invoice_item_details') { + const subscriptionId = line.parent.invoice_item_details?.subscription + if (subscriptionId) { + return subscriptionId + } + } + } + + return null +} + +const extractInvoicePayments = function extractInvoicePayments( + invoice: Stripe.Invoice, +): Pick { + const payments = invoice.payments + if (!payments || payments.has_more !== false || payments.data.length === 0) { + return { invoicePayments: [], paymentEvidence: 'unavailable' } + } + + const invoicePayments: InvoicePaymentFact[] = [] + + for (const invoicePayment of payments.data) { + const paymentObjectId = + invoicePayment.payment.type === 'charge' + ? normalizeStripeId(invoicePayment.payment.charge) + : normalizeStripeId(invoicePayment.payment.payment_intent) + const paidAt = invoicePayment.status_transitions.paid_at + + if ( + !invoicePayment.id || + !paymentObjectId || + !invoicePayment.status || + invoicePayment.amount_paid === null || + !invoicePayment.currency || + paidAt === null + ) { + return { invoicePayments: [], paymentEvidence: 'unavailable' } + } + + invoicePayments.push({ + amountPaidMinor: invoicePayment.amount_paid, + currency: invoicePayment.currency, + paidAt, + paymentObjectId, + paymentObjectType: invoicePayment.payment.type, + status: invoicePayment.status, + stripeInvoicePaymentId: invoicePayment.id, + }) + } + + return { invoicePayments, paymentEvidence: 'available' } +} + +const extractInvoiceBillingFacts = function extractInvoiceBillingFacts( + event: Stripe.Event, + invoice: Stripe.Invoice, +): InvoiceBillingFacts { + if (!invoice.id) { + throw new Error(`Stripe invoice event ${event.id} is missing an invoice ID`) + } + + return { + amountDueMinor: invoice.amount_due, + amountPaidMinor: invoice.amount_paid, + amountRemainingMinor: invoice.amount_remaining, + attemptCount: invoice.attempt_count, + billingReason: invoice.billing_reason, + collectionMethod: invoice.collection_method, + currency: invoice.currency, + ...extractInvoicePayments(invoice), + kind: 'invoice', + livemode: event.livemode, + markedUncollectibleAt: invoice.status_transitions.marked_uncollectible_at, + occurredAt: event.created, + paidAt: invoice.status_transitions.paid_at, + status: invoice.status, + stripeCustomerId: normalizeStripeId(invoice.customer), + stripeInvoiceId: invoice.id, + stripeSubscriptionId: findInvoiceSubscriptionId(invoice), + version: 1, + voidedAt: invoice.status_transitions.voided_at, + } +} + +const extractSubscriptionBillingFacts = function extractSubscriptionBillingFacts( + event: Stripe.Event, + subscription: Stripe.Subscription, +): SubscriptionBillingFacts { + const periodEnds = subscription.items.data.map((item) => item.current_period_end) + + return { + cancelAtPeriodEnd: subscription.cancel_at_period_end, + canceledAt: subscription.canceled_at, + cancellationReason: subscription.cancellation_details?.reason ?? null, + currentPeriodEnd: periodEnds.length === 0 ? null : Math.max(...periodEnds), + endedAt: subscription.ended_at, + kind: 'subscription', + livemode: event.livemode, + occurredAt: event.created, + status: subscription.status, + stripeCustomerId: normalizeStripeId(subscription.customer), + stripeSubscriptionId: subscription.id, + trialEnd: subscription.trial_end, + trialStart: subscription.trial_start, + version: 1, + } +} + +export const extractBillingFacts = function extractBillingFacts( + event: Stripe.Event, +): BillingFacts | undefined { + switch (event.type) { + case 'invoice.marked_uncollectible': + case 'invoice.overdue': + case 'invoice.paid': + case 'invoice.payment_failed': + case 'invoice.payment_succeeded': + case 'invoice.voided': + return extractInvoiceBillingFacts(event, event.data.object) + case 'customer.subscription.created': + case 'customer.subscription.deleted': + case 'customer.subscription.updated': + return extractSubscriptionBillingFacts(event, event.data.object) + default: + return undefined + } +} diff --git a/src/lib/stripe/utils/idempotency.ts b/src/lib/stripe/utils/idempotency.ts index 65ec3d42..14b64a79 100644 --- a/src/lib/stripe/utils/idempotency.ts +++ b/src/lib/stripe/utils/idempotency.ts @@ -1,5 +1,6 @@ import type { Prisma } from '@prisma/client' +import type { BillingFacts } from './billing-facts' import { debugLog } from './debug-log' export type IdempotentProcessingResult = @@ -9,6 +10,7 @@ export type IdempotentProcessingResult = export const processEventIdempotently = async function processEventIdempotently( eventId: string, eventType: string, + billingFacts: BillingFacts | undefined, processor: (tx: Prisma.TransactionClient) => Promise, tx: Prisma.TransactionClient, ): Promise { @@ -37,6 +39,7 @@ export const processEventIdempotently = async function processEventIdempotently( debugLog(`Attempting to create webhookEvent record for event ${eventId}`) await tx.webhookEvent.create({ data: { + ...(billingFacts && { billingFacts }), eventType, processedAt: new Date(), stripeEventId: eventId, diff --git a/src/pages/api/stripe/webhook.ts b/src/pages/api/stripe/webhook.ts index 8f8412c3..786f440c 100644 --- a/src/pages/api/stripe/webhook.ts +++ b/src/pages/api/stripe/webhook.ts @@ -12,6 +12,7 @@ import { handleSubscriptionDeleted, handleSubscriptionEvent, } from '@/lib/stripe/handlers/subscription-events' +import { extractBillingFacts } from '@/lib/stripe/utils/billing-facts' import { debugLog } from '@/lib/stripe/utils/debug-log' import { processEventIdempotently } from '@/lib/stripe/utils/idempotency' import { withTransaction } from '@/lib/stripe/utils/transaction' @@ -32,6 +33,7 @@ const relevantEvents = new Set([ 'invoice.payment_failed', 'invoice.marked_uncollectible', 'invoice.overdue', + 'invoice.voided', // Fallback for invoices marked paid_out_of_band via crypto settlement 'invoice.paid', 'checkout.session.completed', @@ -84,9 +86,18 @@ const verifyWebhook = async function verifyWebhook( } } -const processWebhookEvent = async function processWebhookEvent( +export interface WebhookEventProcessorDependencies { + handleInvoiceEvent: typeof handleInvoiceEvent +} + +const defaultWebhookEventProcessorDependencies: WebhookEventProcessorDependencies = { + handleInvoiceEvent, +} + +export const processWebhookEvent = async function processWebhookEvent( event: Stripe.Event, tx: Prisma.TransactionClient, + dependencies: WebhookEventProcessorDependencies = defaultWebhookEventProcessorDependencies, ): Promise { debugLog(`Entering processWebhookEvent for event ${event.id} (${event.type})`) // Type guard to ensure event.type is one of our supported event types @@ -210,11 +221,13 @@ const processWebhookEvent = async function processWebhookEvent( case 'invoice.paid': { const invoice = event.data.object debugLog(`Calling handleInvoiceEvent for invoice ${invoice.id} (event: ${event.type})`) - const handled = await handleInvoiceEvent(event.data.object, tx) + const handled = await dependencies.handleInvoiceEvent(event.data.object, tx) assertHandled(handled, event.type) debugLog(`Finished handleInvoiceEvent for invoice ${invoice.id} (event: ${event.type})`) break } + case 'invoice.voided': + break case 'checkout.session.completed': { const session = event.data.object debugLog(`Calling handleCheckoutCompleted for session ${session.id}`) @@ -300,12 +313,14 @@ export const createWebhookHandler = function createWebhookHandler( debugLog('Event is relevant.', { eventId: event.id, eventType: event.type }) try { + const billingFacts = extractBillingFacts(event) debugLog(`Starting processing for event ${event.id} (${event.type})`) const result = await dependencies.withTransaction(async (transactionClient) => { debugLog(`Inside transaction for event ${event.id} (${event.type})`) return await processEventIdempotently( event.id, event.type, + billingFacts, async (processorTransactionClient) => { debugLog(`Executing processWebhookEvent for event ${event.id} (${event.type})`) await dependencies.processWebhookEvent(event, processorTransactionClient) diff --git a/supabase/migrations/20260908150000_add_webhook_event_billing_facts.sql b/supabase/migrations/20260908150000_add_webhook_event_billing_facts.sql new file mode 100644 index 00000000..ac3363d8 --- /dev/null +++ b/supabase/migrations/20260908150000_add_webhook_event_billing_facts.sql @@ -0,0 +1,2 @@ +alter table public."WebhookEvent" + add column if not exists "billingFacts" jsonb; From b147a1c5ac9118e6fc7b551a69ca88e2c0a60b70 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Tue, 8 Sep 2026 08:31:22 -0400 Subject: [PATCH 2/5] fix(stripe): preserve unknown billing coverage --- .../stripe/utils/idempotency.postgres.test.ts | 4 +- .../api/stripe/utils/idempotency.test.ts | 3 +- .../api/stripe/webhook-reliability.test.ts | 99 +++++-------------- .../utils/__tests__/billing-facts.test.ts | 96 +++++++++++++----- src/lib/stripe/utils/billing-facts.ts | 42 +++++--- src/pages/api/stripe/webhook.ts | 23 ++--- 6 files changed, 133 insertions(+), 134 deletions(-) diff --git a/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts b/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts index 422b6997..69552ced 100644 --- a/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts +++ b/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts @@ -172,7 +172,9 @@ describePostgres('Stripe webhook PostgreSQL reliability', () => { version: 1, } satisfies BillingFacts const laterFact = { ...originalFact, status: 'canceled' } satisfies BillingFacts - const processor = async () => await Promise.resolve() + const processor = async () => { + await Promise.resolve() + } await withTransaction( async (tx) => diff --git a/src/__tests__/pages/api/stripe/utils/idempotency.test.ts b/src/__tests__/pages/api/stripe/utils/idempotency.test.ts index 7df57f09..9f3849eb 100644 --- a/src/__tests__/pages/api/stripe/utils/idempotency.test.ts +++ b/src/__tests__/pages/api/stripe/utils/idempotency.test.ts @@ -110,9 +110,10 @@ describe(processEventIdempotently, () => { data: { billingFacts, eventType: 'customer.subscription.updated', - processedAt: expect.any(Date), + processedAt: create.mock.calls[0]?.[0].data.processedAt, stripeEventId: 'evt_1', }, }) + expect(create.mock.calls[0]?.[0].data.processedAt).toBeInstanceOf(Date) }) }) diff --git a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts index b8265e4a..a63507f0 100644 --- a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts +++ b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts @@ -4,13 +4,7 @@ import { createMocks } from 'node-mocks-http' import { Stripe } from 'stripe' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { stripe as serverStripe } from '@/lib/stripe-server' -import type { WebhookHandlerDependencies } from '@/pages/api/stripe/webhook' -import type { WebhookEventProcessorDependencies } from '@/pages/api/stripe/webhook' -import { - createWebhookHandler, - processWebhookEvent as processVerifiedWebhookEvent, -} from '@/pages/api/stripe/webhook' +import { createWebhookHandler, type WebhookHandlerDependencies } from '@/pages/api/stripe/webhook' const testStripe = new Stripe('sk_test_dummy') const webhookSecret = 'test-secret' @@ -42,9 +36,9 @@ const voidedPayload = JSON.stringify({ created: 1_750_000_000, data: { object: { - amount_due: 2_500, + amount_due: 2500, amount_paid: 0, - amount_remaining: 2_500, + amount_remaining: 2500, attempt_count: 1, billing_reason: 'subscription_cycle', collection_method: 'charge_automatically', @@ -78,9 +72,9 @@ const noLocalRowPayload = JSON.stringify({ created: 1_750_000_000, data: { object: { - amount_due: 2_500, + amount_due: 2500, amount_paid: 0, - amount_remaining: 2_500, + amount_remaining: 2500, attempt_count: 1, billing_reason: 'subscription_cycle', collection_method: 'charge_automatically', @@ -111,33 +105,6 @@ const noLocalRowEvent = testStripe.webhooks.constructEvent( noLocalRowSignature, webhookSecret, ) -const providerSubscriptionPayload = JSON.stringify({ - data: { - object: { - cancel_at_period_end: false, - customer: 'cus_no_local_row', - id: 'sub_no_local_row', - items: { data: [{ current_period_end: 1_800_000_000 }] }, - status: 'active', - }, - }, - id: 'evt_provider_subscription', - type: 'customer.subscription.updated', -}) -const providerSubscriptionSignature = testStripe.webhooks.generateTestHeaderString({ - payload: providerSubscriptionPayload, - secret: webhookSecret, -}) -const providerSubscriptionEvent = testStripe.webhooks.constructEvent( - providerSubscriptionPayload, - providerSubscriptionSignature, - webhookSecret, -) - -if (providerSubscriptionEvent.type !== 'customer.subscription.updated') { - throw new Error('Expected a subscription event fixture') -} - const createRequestResponse = (method: 'GET' | 'POST' = 'POST') => createMocks({ method, @@ -272,28 +239,18 @@ describe('Stripe webhook reliability', () => { it('stores invoice.voided facts without invoking invoice entitlement handling', async () => { verifyWebhook.mockResolvedValue({ event: voidedEvent }) - const handleInvoiceEvent = vi - .fn() - .mockResolvedValue(true) - const voidedHandler = createWebhookHandler({ - processWebhookEvent: async (verifiedEvent, transactionClient) => - await processVerifiedWebhookEvent(verifiedEvent, transactionClient, { - handleInvoiceEvent, - }), - verifyWebhook, - withTransaction: transaction, - }) const { req, res } = createRequestResponse() - await voidedHandler(req, res) + await handler(req, res) - expect(handleInvoiceEvent).not.toHaveBeenCalled() + expect(processWebhookEventMock).not.toHaveBeenCalled() + const createData = create.mock.calls[0]?.[0].data expect(create).toHaveBeenCalledWith({ data: { billingFacts: { - amountDueMinor: 2_500, + amountDueMinor: 2500, amountPaidMinor: 0, - amountRemainingMinor: 2_500, + amountRemainingMinor: 2500, attemptCount: 1, billingReason: 'subscription_cycle', collectionMethod: 'charge_automatically', @@ -313,42 +270,30 @@ describe('Stripe webhook reliability', () => { voidedAt: 1_750_000_100, }, eventType: 'invoice.voided', - processedAt: expect.any(Date), + processedAt: createData?.processedAt, stripeEventId: 'evt_voided', }, }) + expect(createData?.processedAt).toBeInstanceOf(Date) expect(res.statusCode).toBe(200) expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) }) - it('retains an invoice fact when the existing handler updates no local subscription row', async () => { + it('retains an invoice fact when processing succeeds without a local subscription write', async () => { verifyWebhook.mockResolvedValue({ event: noLocalRowEvent }) - vi.spyOn(tx, '$executeRaw').mockResolvedValue(0) - const updateMany = vi.spyOn(tx.subscription, 'updateMany').mockResolvedValue({ count: 0 }) - vi.spyOn(serverStripe.subscriptions, 'retrieve').mockResolvedValue( - providerSubscriptionEvent.data.object, - ) - const noLocalRowHandler = createWebhookHandler({ - processWebhookEvent: processVerifiedWebhookEvent, - verifyWebhook, - withTransaction: transaction, - }) const { req, res } = createRequestResponse() - await noLocalRowHandler(req, res) + await handler(req, res) - expect(updateMany).toHaveBeenCalledOnce() - expect(create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - billingFacts: expect.objectContaining({ - kind: 'invoice', - stripeInvoiceId: 'in_no_local_row', - stripeSubscriptionId: 'sub_no_local_row', - }), - eventType: 'invoice.payment_failed', - stripeEventId: 'evt_no_local_row', - }), + expect(processWebhookEventMock).toHaveBeenCalledOnce() + const createData = create.mock.calls[0]?.[0].data + expect(createData?.billingFacts).toMatchObject({ + kind: 'invoice', + stripeInvoiceId: 'in_no_local_row', + stripeSubscriptionId: 'sub_no_local_row', }) + expect(createData?.eventType).toBe('invoice.payment_failed') + expect(createData?.stripeEventId).toBe('evt_no_local_row') expect(res.statusCode).toBe(200) expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) }) diff --git a/src/lib/stripe/utils/__tests__/billing-facts.test.ts b/src/lib/stripe/utils/__tests__/billing-facts.test.ts index 90f26f28..f468a8f6 100644 --- a/src/lib/stripe/utils/__tests__/billing-facts.test.ts +++ b/src/lib/stripe/utils/__tests__/billing-facts.test.ts @@ -6,7 +6,37 @@ import { extractBillingFacts } from '../billing-facts' const stripe = new Stripe('sk_test_dummy') const webhookSecret = 'billing-facts-test-secret' -const createEvent = function createEvent(type: string, object: Record) { +interface InvoiceStatusTransitionsFixture { + finalized_at: number | null + marked_uncollectible_at: number | null + paid_at: number | null + voided_at: number | null +} + +interface InvoiceFixtureOverrides { + id?: string + lines?: { data: object[] } + parent?: object | null + payments?: object + status?: string + status_transitions?: InvoiceStatusTransitionsFixture +} + +interface SubscriptionFixtureOverrides { + cancel_at_period_end?: boolean + canceled_at?: number | null + cancellation_details?: { reason: string } | null + ended_at?: number | null + items?: { + data: Array<{ current_period_end: number; id?: string }> + has_more?: boolean + } + status?: string + trial_end?: number | null + trial_start?: number | null +} + +const createEvent = function createEvent(type: string, object: object) { const payload = JSON.stringify({ created: 1_750_000_000, data: { object }, @@ -19,10 +49,10 @@ const createEvent = function createEvent(type: string, object: Record = {}) { +const invoice = function invoice(overrides: InvoiceFixtureOverrides = {}) { return { - amount_due: 2_500, - amount_paid: 2_500, + amount_due: 2500, + amount_paid: 2500, amount_remaining: 0, attempt_count: 2, billing_reason: 'subscription_cycle', @@ -54,7 +84,7 @@ const invoice = function invoice(overrides: Record = {}) { payments: { data: [ { - amount_paid: 1_500, + amount_paid: 1500, currency: 'usd', id: 'inpay_charge', payment: { @@ -65,7 +95,7 @@ const invoice = function invoice(overrides: Record = {}) { status_transitions: { canceled_at: null, paid_at: 1_750_000_100 }, }, { - amount_paid: 1_000, + amount_paid: 1000, currency: 'usd', id: 'inpay_intent', payment: { payment_intent: 'pi_paid', type: 'payment_intent' }, @@ -88,7 +118,15 @@ const invoice = function invoice(overrides: Record = {}) { } } -const subscription = function subscription(overrides: Record = {}) { +const invoiceWithoutPayments = function invoiceWithoutPayments( + overrides: Omit = {}, +) { + const fixture = invoice(overrides) + Reflect.deleteProperty(fixture, 'payments') + return fixture +} + +const subscription = function subscription(overrides: SubscriptionFixtureOverrides = {}) { return { cancel_at_period_end: false, canceled_at: null, @@ -119,8 +157,8 @@ describe(extractBillingFacts, () => { const event = createEvent('invoice.paid', invoice()) expect(extractBillingFacts(event)).toStrictEqual({ - amountDueMinor: 2_500, - amountPaidMinor: 2_500, + amountDueMinor: 2500, + amountPaidMinor: 2500, amountRemainingMinor: 0, attemptCount: 2, billingReason: 'subscription_cycle', @@ -128,7 +166,7 @@ describe(extractBillingFacts, () => { currency: 'usd', invoicePayments: [ { - amountPaidMinor: 1_500, + amountPaidMinor: 1500, currency: 'usd', paidAt: 1_750_000_100, paymentObjectId: 'ch_paid', @@ -137,7 +175,7 @@ describe(extractBillingFacts, () => { stripeInvoicePaymentId: 'inpay_charge', }, { - amountPaidMinor: 1_000, + amountPaidMinor: 1000, currency: 'usd', paidAt: 1_750_000_200, paymentObjectId: 'pi_paid', @@ -194,10 +232,7 @@ describe(extractBillingFacts, () => { }, ])('preserves $eventType status transitions', ({ eventType, status, transitions }) => { const fact = extractBillingFacts( - createEvent( - eventType, - invoice({ payments: undefined, status, status_transitions: transitions }), - ), + createEvent(eventType, invoiceWithoutPayments({ status, status_transitions: transitions })), ) expect(fact).toMatchObject({ @@ -250,7 +285,7 @@ describe(extractBillingFacts, () => { const fact = extractBillingFacts( createEvent( 'invoice.payment_failed', - invoice({ lines: { data: lines }, parent: null, payments: undefined }), + invoiceWithoutPayments({ lines: { data: lines }, parent: null }), ), ) @@ -262,15 +297,22 @@ describe(extractBillingFacts, () => { const fact = extractBillingFacts( createEvent( 'invoice.payment_failed', - invoice({ lines: { data: [] }, parent: null, payments: undefined }), + invoiceWithoutPayments({ lines: { data: [] }, parent: null }), ), ) expect(fact).toMatchObject({ stripeInvoiceId: 'in_paid', stripeSubscriptionId: null }) }) + it('marks absent embedded payment evidence unavailable', () => { + const fixture = invoiceWithoutPayments() + + const fact = extractBillingFacts(createEvent('invoice.payment_failed', fixture)) + + expect(fact).toMatchObject({ invoicePayments: [], paymentEvidence: 'unavailable' }) + }) + it.each([ - undefined, { data: [], has_more: false }, { data: [], has_more: true }, { @@ -286,16 +328,19 @@ describe(extractBillingFacts, () => { ], has_more: false, }, - ])('marks absent or incomplete embedded payment evidence unavailable', (payments) => { + ])('marks incomplete embedded payment evidence unavailable', (payments) => { const fact = extractBillingFacts(createEvent('invoice.payment_failed', invoice({ payments }))) expect(fact).toMatchObject({ invoicePayments: [], paymentEvidence: 'unavailable' }) }) it('rejects an invoice event without an invoice ID', () => { - expect(() => - extractBillingFacts(createEvent('invoice.payment_failed', invoice({ id: undefined }))), - ).toThrow('missing an invoice ID') + const fixture = invoice() + Reflect.deleteProperty(fixture, 'id') + + expect(() => extractBillingFacts(createEvent('invoice.payment_failed', fixture))).toThrow( + 'missing an invoice ID', + ) }) it.each(['active', 'past_due', 'unpaid', 'incomplete_expired', 'canceled'])( @@ -335,9 +380,12 @@ describe(extractBillingFacts, () => { }, ) - it('uses null when a subscription has no item period end', () => { + it.each([ + { data: [], has_more: false }, + { data: [{ current_period_end: 1_800_000_000 }], has_more: true }, + ])('uses null when subscription item period coverage is unavailable', (items) => { const fact = extractBillingFacts( - createEvent('customer.subscription.created', subscription({ items: { data: [] } })), + createEvent('customer.subscription.created', subscription({ items })), ) expect(fact).toMatchObject({ currentPeriodEnd: null }) diff --git a/src/lib/stripe/utils/billing-facts.ts b/src/lib/stripe/utils/billing-facts.ts index 5d0e46d4..74415003 100644 --- a/src/lib/stripe/utils/billing-facts.ts +++ b/src/lib/stripe/utils/billing-facts.ts @@ -7,7 +7,7 @@ type CommonBillingFacts = { version: 1 } -export type InvoicePaymentFact = { +type InvoicePaymentFact = { amountPaidMinor: number currency: string paidAt: number @@ -61,6 +61,12 @@ const normalizeStripeId = function normalizeStripeId(reference: StripeIdReferenc return reference?.id ?? null } +const isPresentStripeId = function isPresentStripeId( + value: string | null | undefined, +): value is string { + return value !== null && value !== undefined && value !== '' +} + const findInvoiceSubscriptionId = function findInvoiceSubscriptionId( invoice: Stripe.Invoice, ): string | null { @@ -69,27 +75,27 @@ const findInvoiceSubscriptionId = function findInvoiceSubscriptionId( invoice.parent.subscription_details?.subscription, ) - if (parentSubscriptionId) { + if (isPresentStripeId(parentSubscriptionId)) { return parentSubscriptionId } } for (const line of invoice.lines.data) { const legacySubscriptionId = normalizeStripeId(line.subscription) - if (legacySubscriptionId) { + if (isPresentStripeId(legacySubscriptionId)) { return legacySubscriptionId } if (line.parent?.type === 'subscription_item_details') { const subscriptionId = line.parent.subscription_item_details?.subscription - if (subscriptionId) { + if (isPresentStripeId(subscriptionId)) { return subscriptionId } } if (line.parent?.type === 'invoice_item_details') { const subscriptionId = line.parent.invoice_item_details?.subscription - if (subscriptionId) { + if (isPresentStripeId(subscriptionId)) { return subscriptionId } } @@ -102,7 +108,7 @@ const extractInvoicePayments = function extractInvoicePayments( invoice: Stripe.Invoice, ): Pick { const payments = invoice.payments - if (!payments || payments.has_more !== false || payments.data.length === 0) { + if (payments === undefined || payments.has_more !== false || payments.data.length === 0) { return { invoicePayments: [], paymentEvidence: 'unavailable' } } @@ -116,11 +122,11 @@ const extractInvoicePayments = function extractInvoicePayments( const paidAt = invoicePayment.status_transitions.paid_at if ( - !invoicePayment.id || - !paymentObjectId || - !invoicePayment.status || + invoicePayment.id === '' || + !isPresentStripeId(paymentObjectId) || + invoicePayment.status === '' || invoicePayment.amount_paid === null || - !invoicePayment.currency || + invoicePayment.currency === '' || paidAt === null ) { return { invoicePayments: [], paymentEvidence: 'unavailable' } @@ -144,7 +150,7 @@ const extractInvoiceBillingFacts = function extractInvoiceBillingFacts( event: Stripe.Event, invoice: Stripe.Invoice, ): InvoiceBillingFacts { - if (!invoice.id) { + if (!isPresentStripeId(invoice.id)) { throw new Error(`Stripe invoice event ${event.id} is missing an invoice ID`) } @@ -175,7 +181,10 @@ const extractSubscriptionBillingFacts = function extractSubscriptionBillingFacts event: Stripe.Event, subscription: Stripe.Subscription, ): SubscriptionBillingFacts { - const periodEnds = subscription.items.data.map((item) => item.current_period_end) + const periodEnds = + subscription.items.has_more === false + ? subscription.items.data.map((item) => item.current_period_end) + : [] return { cancelAtPeriodEnd: subscription.cancel_at_period_end, @@ -204,13 +213,16 @@ export const extractBillingFacts = function extractBillingFacts( case 'invoice.paid': case 'invoice.payment_failed': case 'invoice.payment_succeeded': - case 'invoice.voided': + case 'invoice.voided': { return extractInvoiceBillingFacts(event, event.data.object) + } case 'customer.subscription.created': case 'customer.subscription.deleted': - case 'customer.subscription.updated': + case 'customer.subscription.updated': { return extractSubscriptionBillingFacts(event, event.data.object) - default: + } + default: { return undefined + } } } diff --git a/src/pages/api/stripe/webhook.ts b/src/pages/api/stripe/webhook.ts index 786f440c..cc91ce82 100644 --- a/src/pages/api/stripe/webhook.ts +++ b/src/pages/api/stripe/webhook.ts @@ -86,18 +86,9 @@ const verifyWebhook = async function verifyWebhook( } } -export interface WebhookEventProcessorDependencies { - handleInvoiceEvent: typeof handleInvoiceEvent -} - -const defaultWebhookEventProcessorDependencies: WebhookEventProcessorDependencies = { - handleInvoiceEvent, -} - -export const processWebhookEvent = async function processWebhookEvent( +const processWebhookEvent = async function processWebhookEvent( event: Stripe.Event, tx: Prisma.TransactionClient, - dependencies: WebhookEventProcessorDependencies = defaultWebhookEventProcessorDependencies, ): Promise { debugLog(`Entering processWebhookEvent for event ${event.id} (${event.type})`) // Type guard to ensure event.type is one of our supported event types @@ -221,13 +212,11 @@ export const processWebhookEvent = async function processWebhookEvent( case 'invoice.paid': { const invoice = event.data.object debugLog(`Calling handleInvoiceEvent for invoice ${invoice.id} (event: ${event.type})`) - const handled = await dependencies.handleInvoiceEvent(event.data.object, tx) + const handled = await handleInvoiceEvent(event.data.object, tx) assertHandled(handled, event.type) debugLog(`Finished handleInvoiceEvent for invoice ${invoice.id} (event: ${event.type})`) break } - case 'invoice.voided': - break case 'checkout.session.completed': { const session = event.data.object debugLog(`Calling handleCheckoutCompleted for session ${session.id}`) @@ -322,9 +311,11 @@ export const createWebhookHandler = function createWebhookHandler( event.type, billingFacts, async (processorTransactionClient) => { - debugLog(`Executing processWebhookEvent for event ${event.id} (${event.type})`) - await dependencies.processWebhookEvent(event, processorTransactionClient) - debugLog(`Finished processWebhookEvent for event ${event.id} (${event.type})`) + if (event.type !== 'invoice.voided') { + debugLog(`Executing processWebhookEvent for event ${event.id} (${event.type})`) + await dependencies.processWebhookEvent(event, processorTransactionClient) + debugLog(`Finished processWebhookEvent for event ${event.id} (${event.type})`) + } }, transactionClient, ) From 13822ce0b946649f1f1215f34b23152fedcb1136 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Tue, 8 Sep 2026 08:37:53 -0400 Subject: [PATCH 3/5] fix(stripe): satisfy billing fact checks --- .../stripe/utils/idempotency.postgres.test.ts | 4 +- .../api/stripe/webhook-reliability.test.ts | 33 +++-- .../utils/__tests__/billing-facts.test.ts | 8 +- src/lib/stripe/utils/billing-facts.ts | 117 +++++++++++------- 4 files changed, 103 insertions(+), 59 deletions(-) diff --git a/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts b/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts index 69552ced..7c34fe9a 100644 --- a/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts +++ b/src/__tests__/pages/api/stripe/utils/idempotency.postgres.test.ts @@ -172,9 +172,7 @@ describePostgres('Stripe webhook PostgreSQL reliability', () => { version: 1, } satisfies BillingFacts const laterFact = { ...originalFact, status: 'canceled' } satisfies BillingFacts - const processor = async () => { - await Promise.resolve() - } + const processor = vi.fn<() => Promise>().mockResolvedValue() await withTransaction( async (tx) => diff --git a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts index a63507f0..e6a5b291 100644 --- a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts +++ b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts @@ -4,7 +4,8 @@ import { createMocks } from 'node-mocks-http' import { Stripe } from 'stripe' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { createWebhookHandler, type WebhookHandlerDependencies } from '@/pages/api/stripe/webhook' +import type { WebhookHandlerDependencies } from '@/pages/api/stripe/webhook' +import { createWebhookHandler } from '@/pages/api/stripe/webhook' const testStripe = new Stripe('sk_test_dummy') const webhookSecret = 'test-secret' @@ -275,8 +276,10 @@ describe('Stripe webhook reliability', () => { }, }) expect(createData?.processedAt).toBeInstanceOf(Date) - expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) + expect({ body: res._getJSONData(), statusCode: res.statusCode }).toStrictEqual({ + body: { processed: true, received: true }, + statusCode: 200, + }) }) it('retains an invoice fact when processing succeeds without a local subscription write', async () => { @@ -287,14 +290,22 @@ describe('Stripe webhook reliability', () => { expect(processWebhookEventMock).toHaveBeenCalledOnce() const createData = create.mock.calls[0]?.[0].data - expect(createData?.billingFacts).toMatchObject({ - kind: 'invoice', - stripeInvoiceId: 'in_no_local_row', - stripeSubscriptionId: 'sub_no_local_row', + expect({ + billingFacts: createData?.billingFacts, + eventType: createData?.eventType, + stripeEventId: createData?.stripeEventId, + }).toMatchObject({ + billingFacts: { + kind: 'invoice', + stripeInvoiceId: 'in_no_local_row', + stripeSubscriptionId: 'sub_no_local_row', + }, + eventType: 'invoice.payment_failed', + stripeEventId: 'evt_no_local_row', + }) + expect({ body: res._getJSONData(), statusCode: res.statusCode }).toStrictEqual({ + body: { processed: true, received: true }, + statusCode: 200, }) - expect(createData?.eventType).toBe('invoice.payment_failed') - expect(createData?.stripeEventId).toBe('evt_no_local_row') - expect(res.statusCode).toBe(200) - expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) }) }) diff --git a/src/lib/stripe/utils/__tests__/billing-facts.test.ts b/src/lib/stripe/utils/__tests__/billing-facts.test.ts index f468a8f6..01a5a475 100644 --- a/src/lib/stripe/utils/__tests__/billing-facts.test.ts +++ b/src/lib/stripe/utils/__tests__/billing-facts.test.ts @@ -28,7 +28,7 @@ interface SubscriptionFixtureOverrides { cancellation_details?: { reason: string } | null ended_at?: number | null items?: { - data: Array<{ current_period_end: number; id?: string }> + data: { current_period_end: number; id?: string }[] has_more?: boolean } status?: string @@ -36,7 +36,11 @@ interface SubscriptionFixtureOverrides { trial_start?: number | null } -const createEvent = function createEvent(type: string, object: object) { +interface StripeObjectFixture { + id?: string +} + +const createEvent = function createEvent(type: string, object: StripeObjectFixture) { const payload = JSON.stringify({ created: 1_750_000_000, data: { object }, diff --git a/src/lib/stripe/utils/billing-facts.ts b/src/lib/stripe/utils/billing-facts.ts index 74415003..df8d1460 100644 --- a/src/lib/stripe/utils/billing-facts.ts +++ b/src/lib/stripe/utils/billing-facts.ts @@ -1,20 +1,20 @@ import type Stripe from 'stripe' -type CommonBillingFacts = { - livemode: boolean - occurredAt: number - stripeCustomerId: string | null - version: 1 +interface CommonBillingFacts { + readonly livemode: boolean + readonly occurredAt: number + readonly stripeCustomerId: string | null + readonly version: 1 } -type InvoicePaymentFact = { - amountPaidMinor: number - currency: string - paidAt: number - paymentObjectId: string - paymentObjectType: Stripe.InvoicePayment.Payment.Type - status: string - stripeInvoicePaymentId: string +interface InvoicePaymentFact { + readonly amountPaidMinor: number + readonly currency: string + readonly paidAt: number + readonly paymentObjectId: string + readonly paymentObjectType: Stripe.InvoicePayment.Payment.Type + readonly status: string + readonly stripeInvoicePaymentId: string } export type InvoiceBillingFacts = CommonBillingFacts & { @@ -53,12 +53,40 @@ export type BillingFacts = InvoiceBillingFacts | SubscriptionBillingFacts type StripeIdReference = string | { id: string } | null | undefined +type InvoiceBillingEvent = + | Stripe.InvoiceMarkedUncollectibleEvent + | Stripe.InvoiceOverdueEvent + | Stripe.InvoicePaidEvent + | Stripe.InvoicePaymentFailedEvent + | Stripe.InvoicePaymentSucceededEvent + | Stripe.InvoiceVoidedEvent + +type SubscriptionBillingEvent = + | Stripe.CustomerSubscriptionCreatedEvent + | Stripe.CustomerSubscriptionDeletedEvent + | Stripe.CustomerSubscriptionUpdatedEvent + +const invoiceBillingEventTypes = new Set([ + 'invoice.marked_uncollectible', + 'invoice.overdue', + 'invoice.paid', + 'invoice.payment_failed', + 'invoice.payment_succeeded', + 'invoice.voided', +]) + +const subscriptionBillingEventTypes = new Set([ + 'customer.subscription.created', + 'customer.subscription.deleted', + 'customer.subscription.updated', +]) + const normalizeStripeId = function normalizeStripeId(reference: StripeIdReference): string | null { - if (typeof reference === 'string') { - return reference + if (reference instanceof Object) { + return reference.id } - return reference?.id ?? null + return reference ?? null } const isPresentStripeId = function isPresentStripeId( @@ -107,8 +135,8 @@ const findInvoiceSubscriptionId = function findInvoiceSubscriptionId( const extractInvoicePayments = function extractInvoicePayments( invoice: Stripe.Invoice, ): Pick { - const payments = invoice.payments - if (payments === undefined || payments.has_more !== false || payments.data.length === 0) { + const { payments } = invoice + if (payments === undefined || payments.has_more || payments.data.length === 0) { return { invoicePayments: [], paymentEvidence: 'unavailable' } } @@ -124,14 +152,15 @@ const extractInvoicePayments = function extractInvoicePayments( if ( invoicePayment.id === '' || !isPresentStripeId(paymentObjectId) || - invoicePayment.status === '' || - invoicePayment.amount_paid === null || - invoicePayment.currency === '' || - paidAt === null + invoicePayment.status === '' ) { return { invoicePayments: [], paymentEvidence: 'unavailable' } } + if (invoicePayment.amount_paid === null || invoicePayment.currency === '' || paidAt === null) { + return { invoicePayments: [], paymentEvidence: 'unavailable' } + } + invoicePayments.push({ amountPaidMinor: invoicePayment.amount_paid, currency: invoicePayment.currency, @@ -181,10 +210,9 @@ const extractSubscriptionBillingFacts = function extractSubscriptionBillingFacts event: Stripe.Event, subscription: Stripe.Subscription, ): SubscriptionBillingFacts { - const periodEnds = - subscription.items.has_more === false - ? subscription.items.data.map((item) => item.current_period_end) - : [] + const periodEnds = subscription.items.has_more + ? [] + : subscription.items.data.map((item) => item.current_period_end) return { cancelAtPeriodEnd: subscription.cancel_at_period_end, @@ -204,25 +232,28 @@ const extractSubscriptionBillingFacts = function extractSubscriptionBillingFacts } } +const isInvoiceBillingEvent = function isInvoiceBillingEvent( + event: Stripe.Event, +): event is InvoiceBillingEvent { + return invoiceBillingEventTypes.has(event.type) +} + +const isSubscriptionBillingEvent = function isSubscriptionBillingEvent( + event: Stripe.Event, +): event is SubscriptionBillingEvent { + return subscriptionBillingEventTypes.has(event.type) +} + export const extractBillingFacts = function extractBillingFacts( event: Stripe.Event, ): BillingFacts | undefined { - switch (event.type) { - case 'invoice.marked_uncollectible': - case 'invoice.overdue': - case 'invoice.paid': - case 'invoice.payment_failed': - case 'invoice.payment_succeeded': - case 'invoice.voided': { - return extractInvoiceBillingFacts(event, event.data.object) - } - case 'customer.subscription.created': - case 'customer.subscription.deleted': - case 'customer.subscription.updated': { - return extractSubscriptionBillingFacts(event, event.data.object) - } - default: { - return undefined - } + if (isInvoiceBillingEvent(event)) { + return extractInvoiceBillingFacts(event, event.data.object) } + + if (isSubscriptionBillingEvent(event)) { + return extractSubscriptionBillingFacts(event, event.data.object) + } + + return undefined } From 45d863b41e6767ed596238c72e6aa995742cd9fb Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Tue, 8 Sep 2026 08:41:14 -0400 Subject: [PATCH 4/5] fix(stripe): keep billing facts JSON-compatible --- .../api/stripe/webhook-reliability.test.ts | 11 ++----- src/lib/stripe/utils/billing-facts.ts | 32 +++++++++---------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts index e6a5b291..f5466ba0 100644 --- a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts +++ b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts @@ -276,10 +276,7 @@ describe('Stripe webhook reliability', () => { }, }) expect(createData?.processedAt).toBeInstanceOf(Date) - expect({ body: res._getJSONData(), statusCode: res.statusCode }).toStrictEqual({ - body: { processed: true, received: true }, - statusCode: 200, - }) + expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) }) it('retains an invoice fact when processing succeeds without a local subscription write', async () => { @@ -303,9 +300,7 @@ describe('Stripe webhook reliability', () => { eventType: 'invoice.payment_failed', stripeEventId: 'evt_no_local_row', }) - expect({ body: res._getJSONData(), statusCode: res.statusCode }).toStrictEqual({ - body: { processed: true, received: true }, - statusCode: 200, - }) + expect(res.statusCode).toBe(200) + expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) }) }) diff --git a/src/lib/stripe/utils/billing-facts.ts b/src/lib/stripe/utils/billing-facts.ts index df8d1460..4b40bac5 100644 --- a/src/lib/stripe/utils/billing-facts.ts +++ b/src/lib/stripe/utils/billing-facts.ts @@ -1,21 +1,21 @@ import type Stripe from 'stripe' -interface CommonBillingFacts { - readonly livemode: boolean - readonly occurredAt: number - readonly stripeCustomerId: string | null - readonly version: 1 -} - -interface InvoicePaymentFact { - readonly amountPaidMinor: number - readonly currency: string - readonly paidAt: number - readonly paymentObjectId: string - readonly paymentObjectType: Stripe.InvoicePayment.Payment.Type - readonly status: string - readonly stripeInvoicePaymentId: string -} +type CommonBillingFacts = Readonly<{ + livemode: boolean + occurredAt: number + stripeCustomerId: string | null + version: 1 +}> + +type InvoicePaymentFact = Readonly<{ + amountPaidMinor: number + currency: string + paidAt: number + paymentObjectId: string + paymentObjectType: Stripe.InvoicePayment.Payment.Type + status: string + stripeInvoicePaymentId: string +}> export type InvoiceBillingFacts = CommonBillingFacts & { amountDueMinor: number From aa41ad18c6f3336dd6a233766d14173a4c696632 Mon Sep 17 00:00:00 2001 From: Matt Gates Date: Tue, 8 Sep 2026 08:43:57 -0400 Subject: [PATCH 5/5] fix(stripe): keep billing fact members private --- src/lib/stripe/utils/billing-facts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/stripe/utils/billing-facts.ts b/src/lib/stripe/utils/billing-facts.ts index 4b40bac5..b0eba24e 100644 --- a/src/lib/stripe/utils/billing-facts.ts +++ b/src/lib/stripe/utils/billing-facts.ts @@ -17,7 +17,7 @@ type InvoicePaymentFact = Readonly<{ stripeInvoicePaymentId: string }> -export type InvoiceBillingFacts = CommonBillingFacts & { +type InvoiceBillingFacts = CommonBillingFacts & { amountDueMinor: number amountPaidMinor: number amountRemainingMinor: number @@ -36,7 +36,7 @@ export type InvoiceBillingFacts = CommonBillingFacts & { voidedAt: number | null } -export type SubscriptionBillingFacts = CommonBillingFacts & { +type SubscriptionBillingFacts = CommonBillingFacts & { cancelAtPeriodEnd: boolean canceledAt: number | null cancellationReason: Stripe.Subscription.CancellationDetails.Reason | null