From 850fca8cd2deda21f183d9eb76726499c0118ca1 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Thu, 11 Jun 2026 11:30:53 +0100 Subject: [PATCH] chore: make webhook failures more robust --- .../external/stripe/stripeWebhookRouter.ts | 4 +- .../stripeConnectSeederMiddleware.ts | 15 ++- .../webhooks/stripe-connect-seeder.test.ts | 121 ++++++++++++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 server/tests/unit/webhooks/stripe-connect-seeder.test.ts diff --git a/server/src/external/stripe/stripeWebhookRouter.ts b/server/src/external/stripe/stripeWebhookRouter.ts index eea22bb3f..38d34d3a9 100644 --- a/server/src/external/stripe/stripeWebhookRouter.ts +++ b/server/src/external/stripe/stripeWebhookRouter.ts @@ -17,11 +17,11 @@ export const stripeWebhookRouter = new Hono(); stripeWebhookRouter.post( "/webhooks/stripe/:orgId/:env", stripeLegacySeederMiddleware, + stripeToAutumnCustomerMiddleware, stripeIdempotencyMiddleware, stripeWebhookEarlyAckMiddleware, stripeWebhookRefreshMiddleware, stripeSyncMiddleware, - stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, traceEnrichMiddleware, handleStripeWebhookEvent, @@ -31,11 +31,11 @@ stripeWebhookRouter.post( stripeWebhookRouter.post( "/webhooks/connect/:env", stripeConnectSeederMiddleware, + stripeToAutumnCustomerMiddleware, stripeIdempotencyMiddleware, stripeWebhookEarlyAckMiddleware, stripeWebhookRefreshMiddleware, stripeSyncMiddleware, - stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, traceEnrichMiddleware, handleStripeWebhookEvent, diff --git a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts index a747c3326..94ccf332d 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.ts @@ -1,6 +1,7 @@ import { type AppEnv, AuthType, + ErrCode, type Feature, type Organization, } from "@autumn/shared"; @@ -11,6 +12,7 @@ import { initMasterStripe, } from "@/external/connect/initStripeCli.js"; import { OrgService } from "@/internal/orgs/OrgService.js"; +import RecaseError from "@/utils/errorUtils.js"; import { createStripeCli } from "../../connect/createStripeCli.js"; import type { StripeWebhookContext, @@ -100,7 +102,18 @@ export const stripeConnectSeederMiddleware = async ( }); org = data.org; features = data.features; - } catch { + } catch (error) { + // Only ack accounts genuinely not linked to an org; any other failure + // (e.g. DB outage) must 500 so Stripe retries instead of dropping the event. + const isOrgNotFound = + error instanceof RecaseError && error.code === ErrCode.OrgNotFound; + if (!isOrgNotFound) { + logger.error( + `Failed to resolve org for Stripe account ${accountId}, returning 500 for Stripe to retry: ${error}`, + ); + return c.json({ error: "Failed to resolve org for Stripe webhook" }, 500); + } + if (process.env.NODE_ENV !== "development") { logger.error( `Account ID ${accountId} not linked to any org, skipping Stripe webhook`, diff --git a/server/tests/unit/webhooks/stripe-connect-seeder.test.ts b/server/tests/unit/webhooks/stripe-connect-seeder.test.ts new file mode 100644 index 000000000..e5a22bd94 --- /dev/null +++ b/server/tests/unit/webhooks/stripe-connect-seeder.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { ErrCode } from "@autumn/shared"; +import { Hono } from "hono"; +import RecaseError from "@/utils/errorUtils.js"; + +const mockState = { + getByAccountId: undefined as (() => Promise) | undefined, +}; + +mock.module("@/internal/orgs/OrgService.js", () => ({ + OrgService: { + getByAccountId: async () => { + if (!mockState.getByAccountId) throw new Error("not configured"); + return mockState.getByAccountId(); + }, + }, +})); + +mock.module("@/external/connect/initStripeCli.js", () => ({ + initMasterStripe: () => ({}), + getStripeWebhookSecret: async () => "whsec_test", +})); + +mock.module("@/external/connect/createStripeCli.js", () => ({ + createStripeCli: () => ({}), +})); + +const { stripeConnectSeederMiddleware } = await import( + "@/external/stripe/webhookMiddlewares/stripeConnectSeederMiddleware.js" +); + +const originalSkipVerify = process.env.STRIPE_WEBHOOK_SKIP_VERIFY; + +type TestEnv = { Variables: { ctx: unknown } }; + +const createApp = () => { + const app = new Hono(); + + app.use("*", async (c, next) => { + c.set("ctx", { + db: {}, + logger: { error: () => {}, warn: () => {}, info: () => {} }, + }); + await next(); + }); + + let handlerRan = false; + app.post( + "/webhooks/connect/:env", + stripeConnectSeederMiddleware as never, + (c) => { + handlerRan = true; + return c.json({ processed: true }, 200); + }, + ); + + return { app, didHandlerRun: () => handlerRan }; +}; + +const postEvent = (app: Hono) => + app.request("/webhooks/connect/live", { + method: "POST", + body: JSON.stringify({ + id: "evt_test", + type: "customer.subscription.deleted", + account: "acct_test", + data: { object: {} }, + }), + }); + +describe("stripeConnectSeederMiddleware org resolution", () => { + beforeEach(() => { + process.env.STRIPE_WEBHOOK_SKIP_VERIFY = "true"; + mockState.getByAccountId = undefined; + }); + + afterAll(() => { + process.env.STRIPE_WEBHOOK_SKIP_VERIFY = originalSkipVerify; + }); + + test("returns 200 and skips processing when the account is genuinely unlinked", async () => { + mockState.getByAccountId = async () => { + throw new RecaseError({ + message: "Organization not found", + code: ErrCode.OrgNotFound, + statusCode: 404, + }); + }; + + const { app, didHandlerRun } = createApp(); + const response = await postEvent(app); + + expect(response.status).toBe(200); + expect(didHandlerRun()).toBe(false); + }); + + test("returns 500 so Stripe retries when org lookup fails for any other reason", async () => { + mockState.getByAccountId = async () => { + throw new Error("no more connections allowed (max_client_conn)"); + }; + + const { app, didHandlerRun } = createApp(); + const response = await postEvent(app); + + expect(response.status).toBe(500); + expect(didHandlerRun()).toBe(false); + }); + + test("runs the handler when the org resolves", async () => { + mockState.getByAccountId = async () => ({ + org: { id: "org_test", slug: "test-org", config: {} }, + features: [], + }); + + const { app, didHandlerRun } = createApp(); + const response = await postEvent(app); + + expect(response.status).toBe(200); + expect(didHandlerRun()).toBe(true); + }); +});