From f8dd0706a4e568461ad3a605f13436546692cad3 Mon Sep 17 00:00:00 2001 From: Brandon Shutter Date: Tue, 4 Aug 2026 11:55:55 -0600 Subject: [PATCH 1/2] fix(component): treat `canceled` as terminal in handleSubscriptionUpdated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `handleSubscriptionUpdated` fetches the existing subscription row and then ignores it, patching `status` unconditionally. Stripe does not guarantee webhook ordering and retries failed deliveries for up to three days, so an older `customer.subscription.updated` carrying `status: "active"` can be delivered after `customer.subscription.deleted` and rewrite the row back to active. The row then contradicts Stripe indefinitely — nothing corrects it until some later event happens to arrive — so an application gating access on `listSubscriptionsByUserId` keeps serving a customer who has cancelled. Reproducible against real infrastructure with `stripe events resend` on an `updated` event after the subscription is deleted, not only in tests. `canceled` is already terminal in Stripe's own model: a cancelled subscription never returns to an active state, because resubscribing mints a new subscription id. So ignoring a non-canceled update to a canceled row does not change semantics — it stops the component's cache disagreeing with Stripe. Deliberately narrow. Only resurrection is blocked; a redelivered `canceled` update still refreshes the row's other fields, which the second test asserts. `handleSubscriptionDeleted` is unchanged, since forcing `canceled` is always correct. Co-Authored-By: Claude Opus 5 (1M context) --- src/component/private.ts | 9 ++++ src/component/public.test.ts | 82 ++++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/component/private.ts b/src/component/private.ts index ad12091..33a6947 100644 --- a/src/component/private.ts +++ b/src/component/private.ts @@ -252,6 +252,15 @@ export const handleSubscriptionUpdated = mutation({ deriveCancelAtPeriodEnd(args.cancelAt, args.currentPeriodEnd); if (subscription) { + // `canceled` is terminal in Stripe: a cancelled subscription never + // returns to an active state — resubscribing mints a new subscription id. + // Stripe does not guarantee webhook ordering and retries for up to three + // days, so an older `customer.subscription.updated` can arrive after + // `customer.subscription.deleted` and would otherwise patch this row back + // to `active`, silently restoring access to somebody who cancelled. + if (subscription.status === "canceled" && args.status !== "canceled") { + return null; + } await ctx.db.patch("subscriptions", subscription._id, { status: args.status, currentPeriodEnd: args.currentPeriodEnd, diff --git a/src/component/public.test.ts b/src/component/public.test.ts index e313787..1bff80a 100644 --- a/src/component/public.test.ts +++ b/src/component/public.test.ts @@ -814,3 +814,85 @@ test("handlePaymentIntentSucceeded updates existing payment with customer", asyn expect(payment?.stripeCustomerId).toBe("cus_idempotent"); }); + +test("a stale subscription update cannot resurrect a cancelled subscription", async () => { + const t = convexTest(schema, modules); + + await t.mutation(api.private.handleCustomerCreated, { + stripeCustomerId: "cus_reorder", + email: "reorder@example.com", + }); + + await t.mutation(api.private.handleSubscriptionCreated, { + stripeSubscriptionId: "sub_reorder", + stripeCustomerId: "cus_reorder", + status: "active", + currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, + cancelAtPeriodEnd: false, + priceId: "price_reorder", + }); + + await t.mutation(api.private.handleSubscriptionDeleted, { + stripeSubscriptionId: "sub_reorder", + }); + + // Stripe does not guarantee webhook ordering and retries failed deliveries + // for up to three days, so an older `customer.subscription.updated` can be + // delivered after `customer.subscription.deleted`. Applying it would restore + // access to somebody who has cancelled. + await t.mutation(api.private.handleSubscriptionUpdated, { + stripeSubscriptionId: "sub_reorder", + stripeCustomerId: "cus_reorder", + status: "active", + currentPeriodEnd: Date.now() + 30 * 24 * 60 * 60 * 1000, + cancelAtPeriodEnd: false, + priceId: "price_reorder", + }); + + const subscription = await t.query(api.public.getSubscription, { + stripeSubscriptionId: "sub_reorder", + }); + + expect(subscription?.status).toBe("canceled"); +}); + +test("a cancelled subscription still accepts a canceled update", async () => { + const t = convexTest(schema, modules); + + await t.mutation(api.private.handleCustomerCreated, { + stripeCustomerId: "cus_recancel", + email: "recancel@example.com", + }); + + await t.mutation(api.private.handleSubscriptionCreated, { + stripeSubscriptionId: "sub_recancel", + stripeCustomerId: "cus_recancel", + status: "active", + currentPeriodEnd: Date.now(), + cancelAtPeriodEnd: false, + priceId: "price_recancel", + }); + + await t.mutation(api.private.handleSubscriptionDeleted, { + stripeSubscriptionId: "sub_recancel", + }); + + // The guard is on resurrection only: a redelivered `canceled` update must + // still be able to refresh the row's other fields. + await t.mutation(api.private.handleSubscriptionUpdated, { + stripeSubscriptionId: "sub_recancel", + stripeCustomerId: "cus_recancel", + status: "canceled", + currentPeriodEnd: Date.now(), + cancelAtPeriodEnd: true, + quantity: 3, + priceId: "price_recancel", + }); + + const subscription = await t.query(api.public.getSubscription, { + stripeSubscriptionId: "sub_recancel", + }); + + expect(subscription?.status).toBe("canceled"); + expect(subscription?.quantity).toBe(3); +}); From 115a872f9d29a26f74527e0e72722b6f15d215f5 Mon Sep 17 00:00:00 2001 From: Michael Shimeles Date: Tue, 3 Nov 2026 16:42:54 -0500 Subject: [PATCH 2/2] Fix webhook payment detection, error handling, and logging - payment_intent.succeeded finds the invoice through the Invoice Payments API. PaymentIntent.invoice no longer exists from API version 2025-03-31.basil, so subscription renewals were being recorded as one-time payments. - Failures while syncing a checkout's invoice or checking a payment's invoice now propagate, so the route returns 500 and Stripe redelivers instead of getting a 200 for an incomplete sync. - Webhook logs use fixed messages plus the event ID and type. Raw errors could include the payload, signature header, or customer fields (#76). A new onError option hands the error to the app. - Invoices without a customer are skipped instead of failing validation, and expanded Stripe objects in payloads are read by ID. Co-authored-by: Cursor --- CHANGELOG.md | 21 +++ README.md | 15 ++ src/client/index.test.ts | 370 ++++++++++++++++++++++++++++++++++++--- src/client/index.ts | 225 ++++++++++++++---------- src/client/types.ts | 22 +++ 5 files changed, 535 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1cf12..48c089c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 0.1.8 (unreleased) + +- Fix subscription renewals being recorded as one-time payments. Stripe removed + `PaymentIntent.invoice` in API version `2025-03-31.basil`, so + `payment_intent.succeeded` now finds the invoice through the Invoice Payments + API. Apps on earlier releases may have renewal charges in the `payments` + table; they are the rows whose payment intent belongs to a subscription + invoice. +- An older `customer.subscription.updated` can no longer revive a canceled + subscription (thanks @milehighideas, #68). +- Webhook processing failures now return 500 so Stripe redelivers the event, + including failures while syncing a checkout's invoice or checking whether a + payment belongs to a subscription. Previously these were logged and + acknowledged with 200. +- Webhook logs no longer include raw errors, which could contain the payload, + signature header, or customer fields. Add the new `onError` option to + `registerRoutes` to report the underlying error yourself. +- Invoices without a customer are skipped instead of failing validation, and + expanded customer, subscription, and invoice objects in event payloads are + read correctly. + ## 0.1.7 - Adds a /test entrypoint for testing, compatibile with convex-test's new diff --git a/README.md b/README.md index f78dbd5..d961917 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,21 @@ registerRoutes(http, components.stripe, { export default http; ``` +If processing an event fails, the route responds with a 500 so Stripe retries +the delivery. The component only logs a fixed message plus the event ID and +type, because errors can contain the raw payload, the signature header, or +customer fields. To see the underlying error, pass `onError` and send it to +your own logging or monitoring: + +```typescript +registerRoutes(http, components.stripe, { + onError: async (ctx, error, { stage, event }) => { + // stage is "signature" or "processing"; event is set once verified + reportToMonitoring(error, { stage, eventId: event?.id }); + }, +}); +``` + ## Database Schema The component creates these tables in its namespace: diff --git a/src/client/index.test.ts b/src/client/index.test.ts index 8a4092e..cbf9879 100644 --- a/src/client/index.test.ts +++ b/src/client/index.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test, vi } from "vitest"; import StripeSDK from "stripe"; +import type { HttpRouter } from "convex/server"; import * as clientModule from "./index.js"; -import { StripeSubscriptions, registerRoutes } from "./index.js"; +import { + StripeSubscriptions, + registerRoutes, + type RegisterRoutesConfig, +} from "./index.js"; import { components } from "./setup.test.js"; const stripeMocks = vi.hoisted(() => ({ @@ -10,11 +15,15 @@ const stripeMocks = vi.hoisted(() => ({ cancelSubscription: vi.fn(), updateSubscriptionItem: vi.fn(), createCheckoutSession: vi.fn(), + constructEvent: vi.fn(), })); vi.mock("stripe", () => ({ default: vi.fn().mockImplementation(function StripeMock() { return { + webhooks: { + constructEventAsync: stripeMocks.constructEvent, + }, subscriptions: { retrieve: stripeMocks.retrieveSubscription, update: stripeMocks.updateSubscription, @@ -425,51 +434,193 @@ describe("StripeSubscriptions client", () => { }); }); +function getWebhookHandler(config: RegisterRoutesConfig) { + const route = vi.fn(); + registerRoutes({ route } as unknown as HttpRouter, components.stripe, config); + return route.mock.calls[0][0].handler._handler as ( + ctx: unknown, + request: Request, + ) => Promise; +} + +function webhookRequest() { + return new Request("https://example.convex.site/stripe/webhook", { + method: "POST", + headers: { "stripe-signature": "t=1,v1=sig_leak" }, + body: JSON.stringify({ email: "leak@example.com" }), + }); +} + +function captureConsole() { + const spies = (["error", "warn", "log"] as const).map((method) => + vi.spyOn(console, method).mockImplementation(() => {}), + ); + return { + stop: () => { + const output = spies + .flatMap((spy) => spy.mock.calls.flat()) + .map((arg) => + arg instanceof Error + ? `${arg.message} ${arg.stack} ${JSON.stringify(arg)}` + : typeof arg === "string" + ? arg + : JSON.stringify(arg), + ) + .join("\n"); + spies.forEach((spy) => spy.mockRestore()); + return output; + }, + }; +} + describe("registerRoutes", () => { test("registerRoutes function should be exported", () => { expect(typeof registerRoutes).toBe("function"); }); + + test("signature failures log a fixed message and reach onError", async () => { + const signatureError = Object.assign(new Error("bad signature"), { + payload: '{"email":"leak@example.com"}', + header: "t=1,v1=sig_leak", + }); + stripeMocks.constructEvent.mockRejectedValueOnce(signatureError); + const onError = vi.fn(); + const handler = getWebhookHandler({ + STRIPE_WEBHOOK_SECRET: "whsec_test", + STRIPE_SECRET_KEY: "sk_test_123", + onError, + }); + const logs = captureConsole(); + + const response = await handler({}, webhookRequest()); + + const output = logs.stop(); + expect(response.status).toBe(400); + expect(await response.text()).toBe("Webhook signature verification failed"); + expect(output).toContain("Webhook signature verification failed"); + expect(output).not.toMatch(/leak@example\.com|sig_leak|bad signature/); + expect(onError).toHaveBeenCalledWith({}, signatureError, { + stage: "signature", + }); + }); + + test("processing failures return 500 and log only the event ID and type", async () => { + const event = { + id: "evt_leak", + type: "customer.created", + data: { + object: { id: "cus_leak", email: "leak@example.com", metadata: {} }, + }, + }; + stripeMocks.constructEvent.mockResolvedValueOnce(event); + const mutationError = Object.assign( + new Error("Object: {email: leak@example.com}"), + { raw: { headers: { "request-id": "req_leak" } } }, + ); + const ctx = { runMutation: vi.fn().mockRejectedValue(mutationError) }; + const onError = vi.fn(); + const onEvent = vi.fn(); + const handler = getWebhookHandler({ + STRIPE_WEBHOOK_SECRET: "whsec_test", + STRIPE_SECRET_KEY: "sk_test_123", + onError, + onEvent, + }); + const logs = captureConsole(); + + const response = await handler(ctx, webhookRequest()); + + const output = logs.stop(); + expect(response.status).toBe(500); + expect(output).toContain("evt_leak"); + expect(output).toContain("customer.created"); + expect(output).not.toMatch(/leak@example\.com|req_leak|sig_leak/); + expect(onEvent).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith(ctx, mutationError, { + stage: "processing", + event, + }); + }); + + test("a throwing onError handler does not change the response", async () => { + stripeMocks.constructEvent.mockRejectedValueOnce( + new Error("bad signature"), + ); + const handler = getWebhookHandler({ + STRIPE_WEBHOOK_SECRET: "whsec_test", + STRIPE_SECRET_KEY: "sk_test_123", + onError: () => { + throw new Error("reporter down"); + }, + }); + const logs = captureConsole(); + + const response = await handler({}, webhookRequest()); + + expect(logs.stop()).not.toContain("reporter down"); + expect(response.status).toBe(400); + }); }); +function paymentIntentEvent(overrides: Record = {}) { + return { + type: "payment_intent.succeeded", + data: { + object: { + id: "pi_test", + customer: "cus_test", + amount: 4900, + currency: "usd", + status: "succeeded", + created: 1_700_000_123, + metadata: {}, + ...overrides, + }, + }, + }; +} + +function mockStripeForInvoiceLookup(options: { + invoicePayments?: Array<{ invoice: unknown }>; + invoice?: Record; +}) { + return { + invoicePayments: { + list: vi.fn().mockResolvedValue({ data: options.invoicePayments ?? [] }), + }, + invoices: { + retrieve: vi.fn().mockResolvedValue(options.invoice), + }, + }; +} + describe("processEvent", () => { test("records one-time payments even when the customer recently subscribed", async () => { const processEvent = (clientModule as any).processEvent; const ctx = { runMutation: vi.fn().mockResolvedValue(null), - runQuery: vi.fn().mockResolvedValue([ - { - _creationTime: Date.now(), - stripeSubscriptionId: "sub_recent", - stripeCustomerId: "cus_recent", - status: "active", - }, - ]), + runQuery: vi.fn(), }; + const stripe = mockStripeForInvoiceLookup({}); await processEvent( ctx, components.stripe, - { - type: "payment_intent.succeeded", - data: { - object: { - id: "pi_one_time_after_sub", - customer: "cus_recent", - amount: 4900, - currency: "usd", - status: "succeeded", - created: 1_700_000_123, - metadata: { userId: "user_recent" }, - }, - }, - }, - { - invoices: { - retrieve: vi.fn(), - }, - }, + paymentIntentEvent({ + id: "pi_one_time_after_sub", + customer: "cus_recent", + metadata: { userId: "user_recent" }, + }), + stripe, ); + expect(stripe.invoicePayments.list).toHaveBeenCalledWith({ + payment: { + type: "payment_intent", + payment_intent: "pi_one_time_after_sub", + }, + limit: 1, + }); expect(ctx.runMutation).toHaveBeenCalledWith( components.stripe.private.handlePaymentIntentSucceeded, { @@ -537,4 +688,167 @@ describe("processEvent", () => { }, ); }); + + test("skips subscription payments linked through invoice payments", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { runMutation: vi.fn(), runQuery: vi.fn() }; + const stripe = mockStripeForInvoiceLookup({ + invoicePayments: [{ invoice: "in_renewal" }], + invoice: { + id: "in_renewal", + parent: { subscription_details: { subscription: "sub_renewal" } }, + }, + }); + + await processEvent(ctx, components.stripe, paymentIntentEvent(), stripe); + + expect(stripe.invoices.retrieve).toHaveBeenCalledWith("in_renewal"); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("records payments for invoices that are not part of a subscription", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { runMutation: vi.fn(), runQuery: vi.fn() }; + const stripe = mockStripeForInvoiceLookup({ + invoicePayments: [{ invoice: { id: "in_one_off", parent: null } }], + }); + + await processEvent(ctx, components.stripe, paymentIntentEvent(), stripe); + + expect(stripe.invoices.retrieve).not.toHaveBeenCalled(); + expect(ctx.runMutation).toHaveBeenCalledWith( + components.stripe.private.handlePaymentIntentSucceeded, + expect.objectContaining({ stripePaymentIntentId: "pi_test" }), + ); + }); + + test("uses the legacy PaymentIntent.invoice field when the payload has it", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { runMutation: vi.fn(), runQuery: vi.fn() }; + const stripe = mockStripeForInvoiceLookup({ + invoice: { id: "in_legacy", subscription: "sub_legacy" }, + }); + + await processEvent( + ctx, + components.stripe, + paymentIntentEvent({ invoice: "in_legacy" }), + stripe, + ); + + expect(stripe.invoicePayments.list).not.toHaveBeenCalled(); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("propagates invoice lookup failures instead of recording the payment", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { runMutation: vi.fn(), runQuery: vi.fn() }; + const stripe = mockStripeForInvoiceLookup({}); + stripe.invoicePayments.list.mockRejectedValue(new Error("Stripe is down")); + + await expect( + processEvent(ctx, components.stripe, paymentIntentEvent(), stripe), + ).rejects.toThrow("Stripe is down"); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("propagates checkout invoice sync failures so Stripe retries", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { + runMutation: vi.fn().mockResolvedValue(null), + runQuery: vi.fn(), + }; + const stripe = { + subscriptions: { + retrieve: vi.fn().mockRejectedValue(new Error("invalid api key")), + }, + }; + + await expect( + processEvent( + ctx, + components.stripe, + { + type: "checkout.session.completed", + data: { + object: { + id: "cs_sub", + customer: "cus_sub", + mode: "subscription", + subscription: "sub_checkout", + metadata: {}, + }, + }, + }, + stripe, + ), + ).rejects.toThrow("invalid api key"); + }); + + test("skips invoices that have no customer", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { runMutation: vi.fn(), runQuery: vi.fn() }; + const logs = captureConsole(); + + await processEvent( + ctx, + components.stripe, + { + type: "invoice.finalized", + data: { + object: { + id: "in_no_customer", + customer: null, + status: "open", + amount_due: 100, + amount_paid: 0, + created: 1_700_000_002, + metadata: {}, + }, + }, + }, + {}, + ); + + expect(logs.stop()).toContain("in_no_customer"); + expect(ctx.runMutation).not.toHaveBeenCalled(); + }); + + test("reads expanded customer objects on subscription events", async () => { + const processEvent = (clientModule as any).processEvent; + const ctx = { runMutation: vi.fn(), runQuery: vi.fn() }; + + await processEvent( + ctx, + components.stripe, + { + type: "customer.subscription.created", + data: { + object: { + id: "sub_expanded", + customer: { id: "cus_expanded", object: "customer" }, + status: "active", + cancel_at_period_end: false, + cancel_at: null, + metadata: {}, + items: { + data: [ + { + current_period_end: 1_800_000_000, + quantity: 1, + price: { id: "price_expanded" }, + }, + ], + }, + }, + }, + }, + {}, + ); + + expect(ctx.runMutation.mock.calls[0][1]).toMatchObject({ + stripeSubscriptionId: "sub_expanded", + stripeCustomerId: "cus_expanded", + }); + }); }); diff --git a/src/client/index.ts b/src/client/index.ts index b07165e..1c21117 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,4 +1,8 @@ -import { httpActionGeneric } from "convex/server"; +import { + httpActionGeneric, + type GenericActionCtx, + type GenericDataModel, +} from "convex/server"; import StripeSDK from "stripe"; import type { MutationCtx, @@ -7,6 +11,7 @@ import type { RegisterRoutesConfig, StripeEventHandlers, StripeApiVersion, + StripeWebhookErrorDetails, } from "./types.js"; import type { ComponentApi } from "../component/_generated/component.js"; @@ -20,7 +25,11 @@ type StripeClientConfigWithApiVersion = Omit< export type StripeComponent = ComponentApi; -export type { RegisterRoutesConfig, StripeEventHandlers }; +export type { + RegisterRoutesConfig, + StripeEventHandlers, + StripeWebhookErrorDetails, +}; /** * Stripe Component Client @@ -481,7 +490,6 @@ export function registerRoutes( : undefined, ); - // Verify webhook signature let event: StripeSDK.Event; try { event = await stripe.webhooks.constructEventAsync( @@ -489,15 +497,14 @@ export function registerRoutes( signature, webhookSecret, ); - } catch (err) { - console.error("❌ Webhook signature verification failed:", err); - return new Response( - `Webhook signature verification failed: ${err instanceof Error ? err.message : String(err)}`, - { status: 400 }, - ); + } catch (error) { + console.error("❌ Webhook signature verification failed"); + await reportWebhookError(ctx, config, error, { stage: "signature" }); + return new Response("Webhook signature verification failed", { + status: 400, + }); } - // Process the event with default handlers try { await processEvent(ctx, component, event, stripe); @@ -515,7 +522,13 @@ export function registerRoutes( await customHandler(ctx, event); } } catch (error) { - console.error("❌ Error processing webhook:", error); + console.error( + `❌ Error processing webhook event ${event.id} (${event.type})`, + ); + await reportWebhookError(ctx, config, error, { + stage: "processing", + event, + }); return new Response("Error processing webhook", { status: 500 }); } @@ -527,6 +540,20 @@ export function registerRoutes( }); } +async function reportWebhookError( + ctx: GenericActionCtx, + config: RegisterRoutesConfig | undefined, + error: unknown, + details: StripeWebhookErrorDetails, +) { + if (!config?.onError) return; + try { + await config.onError(ctx, error, details); + } catch { + console.error("❌ Stripe webhook onError handler threw"); + } +} + /** * Internal method to process Stripe webhook events with default handling. * This handles the database syncing for all supported event types. @@ -569,7 +596,7 @@ export async function processEvent( await ctx.runMutation(component.private.handleSubscriptionCreated, { stripeSubscriptionId: subscription.id, - stripeCustomerId: subscription.customer as string, + stripeCustomerId: getStripeObjectId(subscription.customer), status: subscription.status, currentPeriodEnd: item?.current_period_end || 0, cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, @@ -587,7 +614,7 @@ export async function processEvent( await ctx.runMutation(component.private.handleSubscriptionUpdated, { stripeSubscriptionId: subscription.id, - stripeCustomerId: subscription.customer as string, + stripeCustomerId: getStripeObjectId(subscription.customer), status: subscription.status, currentPeriodEnd: item?.current_period_end || 0, cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false, @@ -613,50 +640,37 @@ export async function processEvent( case "checkout.session.completed": { const session = event.data.object as StripeSDK.Checkout.Session; + const stripeCustomerId = getStripeObjectId(session.customer); await ctx.runMutation(component.private.handleCheckoutSessionCompleted, { stripeCheckoutSessionId: session.id, - stripeCustomerId: session.customer - ? (session.customer as string) - : undefined, + stripeCustomerId, mode: session.mode || "payment", metadata: session.metadata || undefined, }); - // For payment mode, link the payment to the customer if we have both + const stripePaymentIntentId = getStripeObjectId(session.payment_intent); if ( session.mode === "payment" && - session.customer && - session.payment_intent + stripeCustomerId && + stripePaymentIntentId ) { await ctx.runMutation(component.private.updatePaymentCustomer, { - stripePaymentIntentId: session.payment_intent as string, - stripeCustomerId: session.customer as string, + stripePaymentIntentId, + stripeCustomerId, }); } - // For subscription mode, fetch and store the latest invoice - if (session.mode === "subscription" && session.subscription) { - try { - const subscription = await stripe.subscriptions.retrieve( - session.subscription as string, - ); - if (subscription.latest_invoice) { - const invoice = await stripe.invoices.retrieve( - subscription.latest_invoice as string, - ); - await ctx.runMutation(component.private.handleInvoiceCreated, { - stripeInvoiceId: invoice.id, - stripeCustomerId: invoice.customer as string, - stripeSubscriptionId: subscription.id, - status: invoice.status || "paid", - amountDue: invoice.amount_due, - amountPaid: invoice.amount_paid, - created: invoice.created, - metadata: getInvoiceMetadata(invoice), - }); - } - } catch (err) { - console.error("Error fetching invoice for subscription:", err); + const stripeSubscriptionId = getStripeObjectId(session.subscription); + if (session.mode === "subscription" && stripeSubscriptionId) { + const subscription = + await stripe.subscriptions.retrieve(stripeSubscriptionId); + const latestInvoiceId = getStripeObjectId(subscription.latest_invoice); + if (latestInvoiceId) { + const invoice = await stripe.invoices.retrieve(latestInvoiceId); + await syncInvoice(ctx, component, invoice, { + fallbackStatus: "paid", + stripeSubscriptionId: subscription.id, + }); } } break; @@ -668,15 +682,9 @@ export async function processEvent( case "invoice.paid": case "invoice.payment_succeeded": { const invoice = event.data.object as StripeSDK.Invoice; - await ctx.runMutation(component.private.handleInvoiceCreated, { - stripeInvoiceId: invoice.id, - stripeCustomerId: invoice.customer as string, + await syncInvoice(ctx, component, invoice, { + fallbackStatus: "open", stripeSubscriptionId: getInvoiceSubscriptionId(invoice), - status: invoice.status || "open", - amountDue: invoice.amount_due, - amountPaid: invoice.amount_paid, - created: invoice.created, - metadata: getInvoiceMetadata(invoice), }); break; } @@ -690,30 +698,18 @@ export async function processEvent( } case "payment_intent.succeeded": { - const paymentIntent = event.data.object as any; - - // Check if this is a subscription payment - if (paymentIntent.invoice) { - try { - const invoice = await stripe.invoices.retrieve( - paymentIntent.invoice as string, - ); - if (getInvoiceSubscriptionId(invoice)) { - console.log( - "⏭️ Skipping payment_intent.succeeded - subscription payment", - ); - break; - } - } catch (err) { - console.error("Error checking invoice:", err); - } + const paymentIntent = event.data.object as StripeSDK.PaymentIntent; + + if (await getPaymentIntentSubscriptionId(stripe, paymentIntent)) { + console.log( + "⏭️ Skipping payment_intent.succeeded - subscription payment", + ); + break; } await ctx.runMutation(component.private.handlePaymentIntentSucceeded, { stripePaymentIntentId: paymentIntent.id, - stripeCustomerId: paymentIntent.customer - ? (paymentIntent.customer as string) - : undefined, + stripeCustomerId: getStripeObjectId(paymentIntent.customer), amount: paymentIntent.amount, currency: paymentIntent.currency, status: paymentIntent.status, @@ -728,30 +724,79 @@ export async function processEvent( } } -function getInvoiceSubscriptionId(invoice: StripeSDK.Invoice) { - const parentSubscription = invoice.parent?.subscription_details?.subscription; - if (typeof parentSubscription === "string") { - return parentSubscription; - } - if (parentSubscription && "id" in parentSubscription) { - return parentSubscription.id; +async function syncInvoice( + ctx: MutationCtx | ActionCtx, + component: ComponentApi, + invoice: StripeSDK.Invoice, + options: { fallbackStatus: string; stripeSubscriptionId?: string }, +) { + const stripeCustomerId = getStripeObjectId(invoice.customer); + if (!stripeCustomerId) { + console.log(`ℹ️ Skipping invoice ${invoice.id} without a customer`); + return; } + await ctx.runMutation(component.private.handleInvoiceCreated, { + stripeInvoiceId: invoice.id, + stripeCustomerId, + stripeSubscriptionId: options.stripeSubscriptionId, + status: invoice.status || options.fallbackStatus, + amountDue: invoice.amount_due, + amountPaid: invoice.amount_paid, + created: invoice.created, + metadata: getInvoiceMetadata(invoice), + }); +} - const legacySubscription = ( - invoice as StripeSDK.Invoice & { - subscription?: string | StripeSDK.Subscription | null; +/** + * API versions from 2025-03-31.basil onward no longer include + * `PaymentIntent.invoice`; the link only exists through Invoice Payments. + */ +async function getPaymentIntentSubscriptionId( + stripe: StripeSDK, + paymentIntent: StripeSDK.PaymentIntent, +) { + let invoice = ( + paymentIntent as StripeSDK.PaymentIntent & { + invoice?: string | StripeSDK.Invoice | null; } - ).subscription; - if (typeof legacySubscription === "string") { - return legacySubscription; - } - if (legacySubscription && "id" in legacySubscription) { - return legacySubscription.id; + ).invoice; + if (!invoice) { + const invoicePayments = await stripe.invoicePayments.list({ + payment: { type: "payment_intent", payment_intent: paymentIntent.id }, + limit: 1, + }); + const linkedInvoice = invoicePayments.data[0]?.invoice; + if ( + !linkedInvoice || + (typeof linkedInvoice !== "string" && linkedInvoice.deleted) + ) { + return undefined; + } + invoice = linkedInvoice; } - return undefined; + const resolvedInvoice = + typeof invoice === "string" + ? await stripe.invoices.retrieve(invoice) + : invoice; + return getInvoiceSubscriptionId(resolvedInvoice); +} + +function getInvoiceSubscriptionId(invoice: StripeSDK.Invoice) { + const legacyInvoice = invoice as StripeSDK.Invoice & { + subscription?: string | StripeSDK.Subscription | null; + }; + return ( + getStripeObjectId(invoice.parent?.subscription_details?.subscription) ?? + getStripeObjectId(legacyInvoice.subscription) + ); } -function getStripeObjectId(object: string | { id: string }) { +function getStripeObjectId(object: string | { id: string }): string; +function getStripeObjectId( + object: string | { id: string } | null | undefined, +): string | undefined; +function getStripeObjectId(object: string | { id: string } | null | undefined) { + if (!object) return undefined; return typeof object === "string" ? object : object.id; } diff --git a/src/client/types.ts b/src/client/types.ts index fb9e27e..c848dd0 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -46,6 +46,15 @@ export type StripeEventHandlers = { [K in Stripe.Event.Type]?: StripeEventHandler; }; +/** + * Where a webhook request failed. `event` is only present once the request's + * signature has been verified. + */ +export type StripeWebhookErrorDetails = { + stage: "signature" | "processing"; + event?: Stripe.Event; +}; + /** * Configuration for webhook registration. */ @@ -67,6 +76,19 @@ export type RegisterRoutesConfig = { * This runs after default processing and before specific event handlers. */ onEvent?: StripeEventHandler; + + /** + * Optional callback for webhook failures. The component only logs a fixed + * message (plus the event ID and type once verified) because errors can + * carry the raw payload, signature header, or customer fields. Use this to + * send the underlying error to your own logging or monitoring. + */ + onError?: ( + ctx: GenericActionCtx, + error: unknown, + details: StripeWebhookErrorDetails, + ) => void | Promise; + /** * Stripe webhook secret for signature verification. * Defaults to process.env.STRIPE_WEBHOOK_SECRET