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..7c34fe9a 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 = vi.fn<() => Promise>().mockResolvedValue() + + 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..9f3849eb 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,44 @@ 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: 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 02b30cdd..f5466ba0 100644 --- a/src/__tests__/pages/api/stripe/webhook-reliability.test.ts +++ b/src/__tests__/pages/api/stripe/webhook-reliability.test.ts @@ -7,33 +7,105 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { WebhookHandlerDependencies } from '@/pages/api/stripe/webhook' import { createWebhookHandler } 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: 2500, + amount_paid: 0, + amount_remaining: 2500, + 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: 2500, + amount_paid: 0, + amount_remaining: 2500, + 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 createRequestResponse = (method: 'GET' | 'POST' = 'POST') => createMocks({ method, @@ -43,17 +115,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 +136,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 +150,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 +163,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 +176,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 +203,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 +218,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 +233,73 @@ 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 { req, res } = createRequestResponse() + + await handler(req, res) + + expect(processWebhookEventMock).not.toHaveBeenCalled() + const createData = create.mock.calls[0]?.[0].data + expect(create).toHaveBeenCalledWith({ + data: { + billingFacts: { + amountDueMinor: 2500, + amountPaidMinor: 0, + amountRemainingMinor: 2500, + 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: createData?.processedAt, + stripeEventId: 'evt_voided', + }, + }) + expect(createData?.processedAt).toBeInstanceOf(Date) + expect(res._getJSONData()).toStrictEqual({ processed: true, received: true }) + }) + + it('retains an invoice fact when processing succeeds without a local subscription write', async () => { + verifyWebhook.mockResolvedValue({ event: noLocalRowEvent }) + const { req, res } = createRequestResponse() + + await handler(req, res) + + expect(processWebhookEventMock).toHaveBeenCalledOnce() + const createData = create.mock.calls[0]?.[0].data + 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(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..01a5a475 --- /dev/null +++ b/src/lib/stripe/utils/__tests__/billing-facts.test.ts @@ -0,0 +1,406 @@ +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' + +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: { current_period_end: number; id?: string }[] + has_more?: boolean + } + status?: string + trial_end?: number | null + trial_start?: number | null +} + +interface StripeObjectFixture { + id?: string +} + +const createEvent = function createEvent(type: string, object: StripeObjectFixture) { + 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: InvoiceFixtureOverrides = {}) { + return { + amount_due: 2500, + amount_paid: 2500, + 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: 1500, + 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: 1000, + 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 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, + 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: 2500, + amountPaidMinor: 2500, + amountRemainingMinor: 0, + attemptCount: 2, + billingReason: 'subscription_cycle', + collectionMethod: 'charge_automatically', + currency: 'usd', + invoicePayments: [ + { + amountPaidMinor: 1500, + currency: 'usd', + paidAt: 1_750_000_100, + paymentObjectId: 'ch_paid', + paymentObjectType: 'charge', + status: 'paid', + stripeInvoicePaymentId: 'inpay_charge', + }, + { + amountPaidMinor: 1000, + 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, invoiceWithoutPayments({ 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', + invoiceWithoutPayments({ lines: { data: lines }, parent: null }), + ), + ) + + expect(fact).toMatchObject({ stripeSubscriptionId }) + }, + ) + + it('keeps an invoice fact when subscription linkage is unresolved', () => { + const fact = extractBillingFacts( + createEvent( + 'invoice.payment_failed', + 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([ + { 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 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', () => { + 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'])( + '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.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 })), + ) + + 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..b0eba24e --- /dev/null +++ b/src/lib/stripe/utils/billing-facts.ts @@ -0,0 +1,259 @@ +import type Stripe from 'stripe' + +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 +}> + +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 +} + +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 + +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 (reference instanceof Object) { + return reference.id + } + + return reference ?? 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 { + if (invoice.parent?.type === 'subscription_details') { + const parentSubscriptionId = normalizeStripeId( + invoice.parent.subscription_details?.subscription, + ) + + if (isPresentStripeId(parentSubscriptionId)) { + return parentSubscriptionId + } + } + + for (const line of invoice.lines.data) { + const legacySubscriptionId = normalizeStripeId(line.subscription) + if (isPresentStripeId(legacySubscriptionId)) { + return legacySubscriptionId + } + + if (line.parent?.type === 'subscription_item_details') { + const subscriptionId = line.parent.subscription_item_details?.subscription + if (isPresentStripeId(subscriptionId)) { + return subscriptionId + } + } + + if (line.parent?.type === 'invoice_item_details') { + const subscriptionId = line.parent.invoice_item_details?.subscription + if (isPresentStripeId(subscriptionId)) { + return subscriptionId + } + } + } + + return null +} + +const extractInvoicePayments = function extractInvoicePayments( + invoice: Stripe.Invoice, +): Pick { + const { payments } = invoice + if (payments === undefined || payments.has_more || 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 === '' || + !isPresentStripeId(paymentObjectId) || + 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, + 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 (!isPresentStripeId(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.has_more + ? [] + : 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, + } +} + +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 { + if (isInvoiceBillingEvent(event)) { + return extractInvoiceBillingFacts(event, event.data.object) + } + + if (isSubscriptionBillingEvent(event)) { + return extractSubscriptionBillingFacts(event, event.data.object) + } + + 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..cc91ce82 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', @@ -300,16 +302,20 @@ 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) - 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, ) 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;