From 6ae9e048438fca4f30802a6076762aebe5c9a29f Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 28 Apr 2026 17:24:59 +0100 Subject: [PATCH 1/5] chore: add early ack on webhooks --- scripts/setup/link-test-stripe-account.ts | 169 ++++++++++++++++++ .../stripeWebhookEarlyAckMiddleware.ts | 25 +++ .../webhooks/stripe-webhook-early-ack.test.ts | 97 ++++++++++ 3 files changed, 291 insertions(+) create mode 100644 scripts/setup/link-test-stripe-account.ts create mode 100644 server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts create mode 100644 server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts diff --git a/scripts/setup/link-test-stripe-account.ts b/scripts/setup/link-test-stripe-account.ts new file mode 100644 index 000000000..4d8a9ee8f --- /dev/null +++ b/scripts/setup/link-test-stripe-account.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +import { AppEnv, organizations } from "@autumn/shared"; +import { eq } from "drizzle-orm"; +import { initDrizzle } from "@server/db/initDrizzle.js"; +import { createStripeCli } from "@server/external/connect/createStripeCli.js"; +import { initMasterStripe } from "@server/external/connect/initStripeCli.js"; +import { OrgService } from "@server/internal/orgs/OrgService.js"; +import { clearOrgCache } from "@server/internal/orgs/orgUtils/clearOrgCache.js"; +import { loadLocalEnv } from "@server/utils/envUtils.js"; + +loadLocalEnv(); + +const args = process.argv.slice(2); + +const readFlag = (name: string) => { + const inline = args.find((arg) => arg.startsWith(`${name}=`)); + if (inline) return inline.slice(name.length + 1); + + const idx = args.indexOf(name); + return idx === -1 ? undefined : args[idx + 1]; +}; + +const hasFlag = (name: string) => args.includes(name); + +const usage = () => { + console.log(`Usage: + bun stripe:link-test -- --account-id=acct_... + bun stripe:link-test -- --latest --email=unit-test-org@test.com + bun stripe:link-test -- --list --email=unit-test-org@test.com + +Options: + --org= Autumn org to update. Defaults to TESTS_ORG. + --env= Stripe environment. Defaults to sandbox. + --account-id= Connected Stripe account ID to link. + --email= Filter Stripe connected accounts by email. + --latest Link the newest connected account matching --email. + --clear-secret-key Clear the org's direct Stripe key for this env so Connect is used. + --list Print matching connected accounts without updating. +`); +}; + +if (hasFlag("--help") || hasFlag("-h")) { + usage(); + process.exit(0); +} + +const env = + (readFlag("--env") || "sandbox").toLowerCase() === "live" + ? AppEnv.Live + : AppEnv.Sandbox; +const orgRef = readFlag("--org") || process.env.TESTS_ORG; +const email = readFlag("--email"); +const accountIdArg = readFlag("--account-id"); + +if (!orgRef) { + throw new Error("Missing org. Pass --org= or set TESTS_ORG."); +} + +const { db, client } = initDrizzle(); + +const getOrg = async () => { + const bySlug = await OrgService.getBySlug({ db, slug: orgRef }); + if (bySlug) return bySlug; + + return await OrgService.get({ db, orgId: orgRef }); +}; + +const listAccounts = async () => { + const stripe = initMasterStripe({ env, skipInstrumentation: true }); + const accounts = await stripe.accounts.list({ limit: 100 }); + + return accounts.data + .filter((account) => !email || account.email === email) + .sort((a, b) => b.created - a.created); +}; + +try { + const org = await getOrg(); + const accounts = await listAccounts(); + + if (hasFlag("--list")) { + console.log( + JSON.stringify( + accounts.map((account) => ({ + id: account.id, + email: account.email, + created: new Date(account.created * 1000).toISOString(), + charges_enabled: account.charges_enabled, + details_submitted: account.details_submitted, + })), + null, + 2, + ), + ); + process.exit(0); + } + + const accountId = + accountIdArg || (hasFlag("--latest") ? accounts[0]?.id : undefined); + + if (!accountId) { + throw new Error( + "Missing account. Pass --account-id=acct_... or use --latest with --email=...", + ); + } + + const directKeyField = + env === AppEnv.Sandbox ? "test_api_key" : "live_api_key"; + const directWebhookSecretField = + env === AppEnv.Sandbox ? "test_webhook_secret" : "live_webhook_secret"; + const hasDirectKey = Boolean(org.stripe_config?.[directKeyField]); + + if (hasDirectKey && !hasFlag("--clear-secret-key")) { + throw new Error( + `${org.slug} has stripe_config.${directKeyField}; createStripeCli will prefer that over Connect. Re-run with --clear-secret-key to use the OAuth account.`, + ); + } + + const stripe = initMasterStripe({ env, accountId, skipInstrumentation: true }); + await stripe.accounts.retrieve(); + + await OrgService.updateStripeConnect({ + db, + orgId: org.id, + accountId, + env, + }); + + if (hasDirectKey) { + await db + .update(organizations) + .set({ + stripe_config: { + ...(org.stripe_config || {}), + [directKeyField]: null, + [directWebhookSecretField]: null, + }, + }) + .where(eq(organizations.id, org.id)); + await clearOrgCache({ db, orgId: org.id }); + } + + const updatedOrg = await OrgService.get({ db, orgId: org.id }); + const resolvedStripe = createStripeCli({ + org: updatedOrg, + env, + skipInstrumentation: true, + }); + const resolvedAccount = await resolvedStripe.accounts.retrieve(); + + console.log( + JSON.stringify( + { + org: { id: updatedOrg.id, slug: updatedOrg.slug }, + env, + linked_account_id: accountId, + resolved_account_id: resolvedAccount.id, + test_stripe_connect: updatedOrg.test_stripe_connect, + live_stripe_connect: updatedOrg.live_stripe_connect, + }, + null, + 2, + ), + ); +} finally { + await client.end(); +} + +process.exit(0); diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts new file mode 100644 index 000000000..f791d8ea2 --- /dev/null +++ b/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts @@ -0,0 +1,25 @@ +import type { Context, Next } from "hono"; +import type { StripeWebhookHonoEnv } from "./stripeWebhookContext"; + +export const stripeWebhookEarlyAckMiddleware = async ( + c: Context, + next: Next, +) => { + const ctx = c.get("ctx"); + const runWebhook = () => + Promise.resolve() + .then(next) + .catch((error) => { + ctx.logger.error(`Stripe webhook background processing failed: ${error}`, { + error, + }); + }); + + try { + c.executionCtx.waitUntil(runWebhook()); + } catch { + setImmediate(() => void runWebhook()); + } + + return c.json({ received: true }, 200); +}; diff --git a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts new file mode 100644 index 000000000..929d56f59 --- /dev/null +++ b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { stripeWebhookEarlyAckMiddleware } from "@/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware"; + +const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +const createApp = () => { + const app = new Hono(); + + app.use("*", async (c, next) => { + (c as any).set("ctx", { + logger: { + error: () => {}, + }, + }); + await next(); + }); + + return app; +}; + +describe("stripeWebhookEarlyAckMiddleware", () => { + test("uses executionCtx.waitUntil when the runtime provides it", async () => { + const waits: Promise[] = []; + let processed = false; + const response = await stripeWebhookEarlyAckMiddleware( + { + get: () => ({ + logger: { error: () => {} }, + }), + json: (body: unknown, status: number) => + new Response(JSON.stringify(body), { status }), + executionCtx: { + waitUntil: (promise: Promise) => waits.push(promise), + }, + } as never, + async () => { + processed = true; + }, + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ received: true }); + expect(waits).toHaveLength(1); + + await waits[0]; + expect(processed).toBe(true); + }); + + test("returns 200 before downstream webhook processing completes", async () => { + const app = createApp(); + let resolveProcessing!: () => void; + let processed = false; + const processing = new Promise((resolve) => { + resolveProcessing = resolve; + }); + + app.post( + "/webhook", + stripeWebhookEarlyAckMiddleware as never, + async (c) => { + await processing; + processed = true; + return c.json({ processed: true }, 200); + }, + ); + + const response = await app.request("/webhook", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ received: true }); + expect(processed).toBe(false); + + resolveProcessing(); + await wait(5); + expect(processed).toBe(true); + }); + + test("does not run downstream webhook processing before returning", async () => { + const app = createApp(); + let started = false; + + app.post("/webhook", stripeWebhookEarlyAckMiddleware as never, (c) => { + started = true; + return c.json({ processed: true }, 200); + }); + + const response = await app.request("/webhook", { method: "POST" }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ received: true }); + expect(started).toBe(false); + + await wait(5); + expect(started).toBe(true); + }); +}); From ea9f827f171d9661b9dbbd7f8fcd30082051dcbe Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 28 Apr 2026 17:26:54 +0100 Subject: [PATCH 2/5] chore: hook ack middleware up --- server/src/external/stripe/stripeWebhookRouter.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/src/external/stripe/stripeWebhookRouter.ts b/server/src/external/stripe/stripeWebhookRouter.ts index 434f6b232..eea22bb3f 100644 --- a/server/src/external/stripe/stripeWebhookRouter.ts +++ b/server/src/external/stripe/stripeWebhookRouter.ts @@ -8,6 +8,7 @@ import { stripeLegacySeederMiddleware } from "./webhookMiddlewares/stripeLegacyS import { stripeSyncMiddleware } from "./webhookMiddlewares/stripeSyncMiddleware.js"; import { stripeToAutumnCustomerMiddleware } from "./webhookMiddlewares/stripeToAutumnCustomerMiddleware.js"; import type { StripeWebhookHonoEnv } from "./webhookMiddlewares/stripeWebhookContext.js"; +import { stripeWebhookEarlyAckMiddleware } from "./webhookMiddlewares/stripeWebhookEarlyAckMiddleware.js"; import { stripeWebhookRefreshMiddleware } from "./webhookMiddlewares/stripeWebhookRefreshMiddleware.js"; export const stripeWebhookRouter = new Hono(); @@ -16,12 +17,13 @@ export const stripeWebhookRouter = new Hono(); stripeWebhookRouter.post( "/webhooks/stripe/:orgId/:env", stripeLegacySeederMiddleware, + stripeIdempotencyMiddleware, + stripeWebhookEarlyAckMiddleware, stripeWebhookRefreshMiddleware, stripeSyncMiddleware, stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, traceEnrichMiddleware, - stripeIdempotencyMiddleware, handleStripeWebhookEvent, ); @@ -29,11 +31,12 @@ stripeWebhookRouter.post( stripeWebhookRouter.post( "/webhooks/connect/:env", stripeConnectSeederMiddleware, + stripeIdempotencyMiddleware, + stripeWebhookEarlyAckMiddleware, stripeWebhookRefreshMiddleware, stripeSyncMiddleware, stripeToAutumnCustomerMiddleware, stripeLoggerMiddleware, traceEnrichMiddleware, - stripeIdempotencyMiddleware, handleStripeWebhookEvent, ); From 5275638cd48895aca0eb19da23c7fc9091896815 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 28 Apr 2026 17:31:50 +0100 Subject: [PATCH 3/5] chore: code review comments --- knip.json | 1 + package.json | 1 + scripts/setup/STRIPE_TEST_OAUTH.md | 47 +++++++++++++++++++ .../webhooks/stripe-webhook-early-ack.test.ts | 6 +-- 4 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 scripts/setup/STRIPE_TEST_OAUTH.md diff --git a/knip.json b/knip.json index d1163be75..d3f560166 100644 --- a/knip.json +++ b/knip.json @@ -11,6 +11,7 @@ "enumMembers", "duplicates" ], + "ignore": ["ai/**"], "ignoreWorkspaces": [ "packages/atmn", "packages/autumn-js", diff --git a/package.json b/package.json index bba7cf07f..737dcd053 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "setup": "node scripts/setup/setup.js", "setup:s3-admin": "bun scripts/setup/setupS3Admin.ts", "setup:test": "infisical run --env=dev --recursive -- bun scripts/setup/setup-test.ts", + "stripe:link-test": "ENV_FILE=.env infisical run --env=dev --recursive -- bun scripts/setup/link-test-stripe-account.ts", "agent:bootstrap": "bash scripts/setup/agent-bootstrap.sh", "dev:agent": "bash scripts/setup/devAgent.sh", "migrate-functions": "infisical run --env=dev --recursive -- bun scripts/migrations/migrate-functions.ts", diff --git a/scripts/setup/STRIPE_TEST_OAUTH.md b/scripts/setup/STRIPE_TEST_OAUTH.md new file mode 100644 index 000000000..5d90110f9 --- /dev/null +++ b/scripts/setup/STRIPE_TEST_OAUTH.md @@ -0,0 +1,47 @@ +# Stripe Test OAuth Linking + +Use this when local tests say the test org has no linked Stripe account, or when Stripe Connect webhooks are visible in Stripe but Autumn cannot map events back to `unit-test-org`. + +The Connect webhook destination should be: + +```txt +https://c.autumn.ngrok.app/webhooks/connect/sandbox +``` + +OAuth still needs the Autumn org row to store the connected account ID: + +```json +{ "test_stripe_connect": { "account_id": "acct_..." } } +``` + +## Commands + +List recent connected accounts for the test org email: + +```sh +bun stripe:link-test -- --list --email=unit-test-org@test.com +``` + +Link an explicit account: + +```sh +bun stripe:link-test -- --account-id=acct_... +``` + +Link the newest account matching the test org email: + +```sh +bun stripe:link-test -- --latest --email=unit-test-org@test.com +``` + +If the org has a direct Stripe secret key, `createStripeCli` will prefer that over OAuth Connect. To force the OAuth account for sandbox tests: + +```sh +bun stripe:link-test -- --account-id=acct_... --clear-secret-key +``` + +After linking, rerun a focused checkout test before the full suite: + +```sh +ENV_FILE=.env infisical run --env=dev --recursive -- bun test --timeout 0 server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-basic.test.ts +``` diff --git a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts index 929d56f59..22c4955c8 100644 --- a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts +++ b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { stripeWebhookEarlyAckMiddleware } from "@/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware"; -const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const waitForImmediate = () => new Promise((resolve) => setImmediate(resolve)); const createApp = () => { const app = new Hono(); @@ -72,7 +72,7 @@ describe("stripeWebhookEarlyAckMiddleware", () => { expect(processed).toBe(false); resolveProcessing(); - await wait(5); + await waitForImmediate(); expect(processed).toBe(true); }); @@ -91,7 +91,7 @@ describe("stripeWebhookEarlyAckMiddleware", () => { expect(await response.json()).toEqual({ received: true }); expect(started).toBe(false); - await wait(5); + await waitForImmediate(); expect(started).toBe(true); }); }); From fb2f7fbab0c30e420569fd4a2050725f4d30c869 Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 28 Apr 2026 17:35:26 +0100 Subject: [PATCH 4/5] chore: cubic comments --- .../stripeWebhookEarlyAckMiddleware.ts | 19 +++++++++++-- .../webhooks/stripe-webhook-early-ack.test.ts | 28 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts b/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts index f791d8ea2..64ac187fc 100644 --- a/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts +++ b/server/src/external/stripe/webhookMiddlewares/stripeWebhookEarlyAckMiddleware.ts @@ -1,6 +1,14 @@ import type { Context, Next } from "hono"; import type { StripeWebhookHonoEnv } from "./stripeWebhookContext"; +const getWaitUntil = (c: Context) => { + try { + return c.executionCtx.waitUntil.bind(c.executionCtx); + } catch { + return undefined; + } +}; + export const stripeWebhookEarlyAckMiddleware = async ( c: Context, next: Next, @@ -15,9 +23,14 @@ export const stripeWebhookEarlyAckMiddleware = async ( }); }); - try { - c.executionCtx.waitUntil(runWebhook()); - } catch { + const waitUntil = getWaitUntil(c); + if (waitUntil) { + try { + waitUntil(runWebhook()); + } catch (error) { + ctx.logger.error(`Stripe webhook waitUntil failed: ${error}`, { error }); + } + } else { setImmediate(() => void runWebhook()); } diff --git a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts index 22c4955c8..1ffaf7c46 100644 --- a/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts +++ b/server/tests/unit/webhooks/stripe-webhook-early-ack.test.ts @@ -47,6 +47,34 @@ describe("stripeWebhookEarlyAckMiddleware", () => { expect(processed).toBe(true); }); + test("does not run downstream twice when waitUntil throws", async () => { + const errors: unknown[] = []; + let runs = 0; + const response = await stripeWebhookEarlyAckMiddleware( + { + get: () => ({ + logger: { error: (_message: string, meta: unknown) => errors.push(meta) }, + }), + json: (body: unknown, status: number) => + new Response(JSON.stringify(body), { status }), + executionCtx: { + waitUntil: () => { + throw new Error("waitUntil failed"); + }, + }, + } as never, + async () => { + runs++; + }, + ); + + expect(response.status).toBe(200); + await Promise.resolve(); + await waitForImmediate(); + expect(runs).toBe(1); + expect(errors).toHaveLength(1); + }); + test("returns 200 before downstream webhook processing completes", async () => { const app = createApp(); let resolveProcessing!: () => void; From 067e0fb521dbc10ef6dbb9c7f41a5754d5c718fc Mon Sep 17 00:00:00 2001 From: Charlie Lamb Date: Tue, 28 Apr 2026 17:50:48 +0100 Subject: [PATCH 5/5] chore: remove unneeded expand --- .../stripe/setup/fetchStripeSubscriptionForBilling.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts index 2cddbdbea..581bb5ed3 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeSubscriptionForBilling.ts @@ -56,10 +56,7 @@ export const fetchStripeSubscriptionForBilling = async ({ if (!subId) return undefined; const sub = await stripeCli.subscriptions.retrieve(subId, { - expand: [ - "discounts.source.coupon.applies_to", - "latest_invoice.lines.data.discount_amounts", - ], + expand: ["discounts.source.coupon.applies_to"], }); if (!sub) {