From f9e97341ac15dae4dc30c3eec9faf14f78522a6c Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Thu, 30 Apr 2026 16:47:09 +0100 Subject: [PATCH 1/3] implement auto-sync for paid feature mapping in subscription.created webhook --- .../setupStripeSubscriptionCreatedContext.ts | 13 ++-- ...executeStripeSubscriptionScheduleAction.ts | 12 ++- ...ub-created-auto-sync-paid-features.test.ts | 76 +++++-------------- .../sub-created-auto-sync.test.ts | 71 +++++++++-------- .../subscriptionCreatedTestUtils.ts | 34 --------- 5 files changed, 68 insertions(+), 138 deletions(-) diff --git a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts index 2a25f5f5e..64e59ee3b 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/setupStripeSubscriptionCreatedContext.ts @@ -22,20 +22,19 @@ export const setupStripeSubscriptionCreatedContext = async ({ if (!fullCustomer) return undefined; // Skip if Autumn already linked this sub (e.g. created via attach flow). - const alreadyLinked = fullCustomer.customer_products?.some((customerProduct) => - customerProduct.subscription_ids?.includes(stripeObject.id), + const alreadyLinked = fullCustomer.customer_products?.some( + (customerProduct) => + customerProduct.subscription_ids?.includes(stripeObject.id), ); if (alreadyLinked) return undefined; + // Race guard: webhook can arrive before attach writes `subscription_ids`. + if (stripeObject.metadata?.autumn_managed === "true") return undefined; + const [subscription, candidateProducts] = await Promise.all([ getFullStripeSub({ stripeCli, stripeId: stripeObject.id }), ProductService.listFull({ db, orgId: org.id, env }), ]); - // Skip subs Autumn created itself — guards against the race where the - // attach flow has not yet written `subscription_ids` on the customerProduct - // when sub.created arrives. - if (subscription.metadata?.autumn_managed === "true") return undefined; - return { subscription, fullCustomer, candidateProducts }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index 0031d6511..86255704b 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -10,14 +10,18 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe /** * Maps update phase format to create phase format (strips start_date). - * Preserves inline `price_data` items so standalone schedule creation - * can carry entity-scoped recurring prices forward correctly. + * + * Phase metadata carries `autumn_managed: "true"` because Stripe's + * `default_settings` has no metadata field — phase metadata is the only + * vehicle to propagate the flag onto the spawned sub. */ const toCreatePhase = ( phase: Stripe.SubscriptionScheduleUpdateParams.Phase, ): Stripe.SubscriptionScheduleCreateParams.Phase => ({ items: phase.items?.map((item) => ({ - ...(item.price_data ? { price_data: item.price_data } : { price: item.price }), + ...(item.price_data + ? { price_data: item.price_data } + : { price: item.price }), quantity: item.quantity, ...(item.metadata && { metadata: item.metadata }), })), @@ -25,6 +29,7 @@ const toCreatePhase = ( discounts: phase.discounts as | Stripe.SubscriptionScheduleCreateParams.Phase.Discount[] | undefined, + metadata: { ...(phase.metadata ?? {}), autumn_managed: "true" }, }); /** @@ -169,7 +174,6 @@ export const executeStripeSubscriptionScheduleAction = async ({ }); } - // No subscription - create standalone schedule return await stripeCli.subscriptionSchedules.create({ customer: billingContext.stripeCustomer?.id ?? "none", phases: params.phases?.map(toCreatePhase) ?? [], diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts index 7e4dc6353..a83d9bb44 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync-paid-features.test.ts @@ -9,15 +9,13 @@ import type { TestContext } from "@tests/utils/testInitUtils/createTestContext"; import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; import chalk from "chalk"; import type Stripe from "stripe"; -import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated"; import { CusService } from "@/internal/customers/CusService"; import { ProductService } from "@/internal/products/ProductService"; -import { - getStripeSandboxContext, - makeSubCreatedWebhookContext, -} from "./subscriptionCreatedTestUtils.js"; +import { timeout } from "@/utils/genUtils"; +import { getStripeSandboxContext } from "./subscriptionCreatedTestUtils.js"; const testRunId = Date.now().toString(36); +const WEBHOOK_TIMEOUT_MS = 8000; const getFullProduct = async ({ ctx, @@ -107,25 +105,9 @@ const createStripeSubscription = async ({ }); }; -const runSubscriptionCreatedAutoSync = async ({ - ctx, - customerId, - stripeSubscription, -}: { - ctx: TestContext; - customerId: string; - stripeSubscription: Stripe.Subscription; -}) => { - await handleStripeSubscriptionCreated({ - ctx: await makeSubCreatedWebhookContext({ - ctx, - customerId, - stripeSubscription, - }), - }); -}; +const waitForWebhookToProcess = () => timeout(WEBHOOK_TIMEOUT_MS); -test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: imports prepaid Stripe quantity")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: imports prepaid Stripe quantity")}`, async () => { const ctx = await getStripeSandboxContext(); const customerId = `sub-created-auto-sync-prepaid-quantity-${testRunId}`; @@ -158,17 +140,13 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature price: messagesPrice, }); - const stripeSubscription = await createStripeSubscription({ + await createStripeSubscription({ ctx, customerId, subscriptionItems: [{ price: messagesStripePriceId, quantity: 5 }], }); - await runSubscriptionCreatedAutoSync({ - ctx, - customerId, - stripeSubscription, - }); + await waitForWebhookToProcess(); const customer = await autumnV1.customers.get(customerId); await expectProductActive({ customer, productId: pro.id }); @@ -180,7 +158,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature }); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: missing prepaid item initializes to zero quantity")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: missing prepaid item initializes to zero quantity")}`, async () => { const ctx = await getStripeSandboxContext(); const customerId = `sub-created-auto-sync-missing-prepaid-${testRunId}`; @@ -217,17 +195,13 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature price: messagesPrice, }); - const stripeSubscription = await createStripeSubscription({ + await createStripeSubscription({ ctx, customerId, subscriptionItems: [{ price: messagesStripePriceId, quantity: 3 }], }); - await runSubscriptionCreatedAutoSync({ - ctx, - customerId, - stripeSubscription, - }); + await waitForWebhookToProcess(); const customer = await autumnV1.customers.get(customerId); await expectProductActive({ customer, productId: pro.id }); @@ -245,7 +219,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature }); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: keeps consumable feature while seeding prepaid")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: keeps consumable feature while seeding prepaid")}`, async () => { const ctx = await getStripeSandboxContext(); const customerId = `sub-created-auto-sync-mixed-consumable-${testRunId}`; @@ -284,7 +258,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature }), }); - const stripeSubscription = await createStripeSubscription({ + await createStripeSubscription({ ctx, customerId, subscriptionItems: [ @@ -293,11 +267,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature ], }); - await runSubscriptionCreatedAutoSync({ - ctx, - customerId, - stripeSubscription, - }); + await waitForWebhookToProcess(); const customer = await autumnV1.customers.get(customerId); await expectProductActive({ customer, productId: pro.id }); @@ -316,7 +286,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature }); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: skips allocated prices cleanly")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: skips allocated prices cleanly")}`, async () => { const ctx = await getStripeSandboxContext(); const customerId = `sub-created-auto-sync-allocated-skip-${testRunId}`; @@ -349,17 +319,13 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature }), }); - const stripeSubscription = await createStripeSubscription({ + await createStripeSubscription({ ctx, customerId, subscriptionItems: [{ price: messagesStripePriceId, quantity: 1 }], }); - await runSubscriptionCreatedAutoSync({ - ctx, - customerId, - stripeSubscription, - }); + await waitForWebhookToProcess(); const customer = await autumnV1.customers.get(customerId); await expectProductActive({ customer, productId: pro.id }); @@ -371,7 +337,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature }); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: ignores extra unmapped Stripe items")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync paid features: ignores extra unmapped Stripe items")}`, async () => { const ctx = await getStripeSandboxContext(); const customerId = `sub-created-auto-sync-extra-stripe-item-${testRunId}`; @@ -406,7 +372,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature name: "Sub Created Auto Sync Extra Item", }); - const stripeSubscription = await createStripeSubscription({ + await createStripeSubscription({ ctx, customerId, subscriptionItems: [ @@ -422,11 +388,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync paid feature ], }); - await runSubscriptionCreatedAutoSync({ - ctx, - customerId, - stripeSubscription, - }); + await waitForWebhookToProcess(); const customer = await autumnV1.customers.get(customerId); await expectProductActive({ customer, productId: pro.id }); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts index 06cd86682..d30ec1a8f 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts @@ -17,10 +17,11 @@ import chalk from "chalk"; import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { CusService } from "@/internal/customers/CusService"; -import { - getStripeSandboxContext, - makeSubCreatedWebhookContext, -} from "./subscriptionCreatedTestUtils.js"; +import { timeout } from "@/utils/genUtils"; +import { getStripeSandboxContext } from "./subscriptionCreatedTestUtils.js"; + +const testRunId = Date.now().toString(36); +const WEBHOOK_TIMEOUT_MS = 8000; const makeFullCustomer = ({ subscriptionIds = [], @@ -41,11 +42,13 @@ const makeGuardrailContext = ({ orgId = "org_123", orgSlug = "org-slug", subscriptionId = "sub_stripe_external", + metadata, }: { fullCustomer?: FullCustomer; orgId?: string; orgSlug?: string; subscriptionId?: string; + metadata?: Record; }): { ctx: StripeWebhookContext; retrieveCalls: string[]; @@ -78,6 +81,7 @@ const makeGuardrailContext = ({ object: { id: subscriptionId, customer: "cus_stripe_external", + ...(metadata && { metadata }), }, }, }, @@ -100,9 +104,9 @@ const withNodeEnv = async (nodeEnv: string, callback: () => Promise) => { } }; -test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync external Stripe sandbox sub")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: sync external Stripe sandbox sub")}`, async () => { const ctx = await getStripeSandboxContext(); - const customerId = "sub-created-auto-sync"; + const customerId = `sub-created-auto-sync-${testRunId}`; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); const pro = products.pro({ @@ -128,13 +132,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync extern expect(stripeSubscription.id).toBeDefined(); expect(stripeSubscription.status).toBe("active"); - await handleStripeSubscriptionCreated({ - ctx: await makeSubCreatedWebhookContext({ - ctx, - customerId, - stripeSubscription, - }), - }); + await timeout(WEBHOOK_TIMEOUT_MS); const customer = await autumnV1.customers.get(customerId); await expectProductActive({ customer, productId: pro.id }); @@ -158,7 +156,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: sync extern expect(ctx.env).toBe(AppEnv.Sandbox); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips unknown Autumn customer")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: skips unknown Autumn customer")}`, async () => { const { ctx, retrieveCalls } = makeGuardrailContext({}); await handleStripeSubscriptionCreated({ ctx }); @@ -166,7 +164,7 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips unkno expect(retrieveCalls).toEqual([]); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips already-linked subscription")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: skips already-linked subscription")}`, async () => { const subscriptionId = "sub_already_linked"; const { ctx, retrieveCalls } = makeGuardrailContext({ subscriptionId, @@ -178,6 +176,19 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips alrea expect(retrieveCalls).toEqual([]); }); +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: skips subs created by Autumn (autumn_managed metadata)")}`, async () => { + const { ctx, retrieveCalls } = makeGuardrailContext({ + fullCustomer: makeFullCustomer(), + metadata: { autumn_managed: "true" }, + }); + + await handleStripeSubscriptionCreated({ ctx }); + + expect(retrieveCalls).toEqual([]); +}); + +// Serial: mutates process.env.NODE_ENV; concurrent peers calling +// handleStripeSubscriptionCreated would observe the wrong value mid-flight. test(`${chalk.yellowBright("customer.subscription.created auto-sync: production gate skips disabled org")}`, async () => { const { ctx, retrieveCalls } = makeGuardrailContext({ orgId: "org_sub_created_auto_sync_disabled", @@ -192,9 +203,9 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: production expect(retrieveCalls).toEqual([]); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub with no product match")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub with no product match")}`, async () => { const ctx = await getStripeSandboxContext(); - const customerId = "sub-created-auto-sync-no-match"; + const customerId = `sub-created-auto-sync-no-match-${testRunId}`; const messagesItem = items.monthlyMessages({ includedUsage: 100 }); const pro = products.pro({ @@ -222,9 +233,9 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Strip } const stripeProduct = await ctx.stripeCli.products.create({ - name: "Sub Created Auto Sync No Match", + name: `Sub Created Auto Sync No Match ${testRunId}`, }); - const stripeSubscription = await ctx.stripeCli.subscriptions.create({ + await ctx.stripeCli.subscriptions.create({ customer: stripeCustomerId, items: [ { @@ -238,21 +249,15 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Strip ], }); - await handleStripeSubscriptionCreated({ - ctx: await makeSubCreatedWebhookContext({ - ctx, - customerId, - stripeSubscription, - }), - }); + await timeout(WEBHOOK_TIMEOUT_MS); const customer = await autumnV1.customers.get(customerId); await expectProductNotPresent({ customer, productId: pro.id }); }); -test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub matching multiple products")}`, async () => { +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub matching multiple products")}`, async () => { const ctx = await getStripeSandboxContext(); - const customerId = "sub-created-auto-sync-multi-match"; + const customerId = `sub-created-auto-sync-multi-match-${testRunId}`; const pro = products.pro({ id: "pro", @@ -273,19 +278,13 @@ test(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Strip actions: [], }); - const stripeSubscription = await createStripeSubscriptionFromProducts({ + await createStripeSubscriptionFromProducts({ ctx, customerId, productIds: [pro.id, premium.id], }); - await handleStripeSubscriptionCreated({ - ctx: await makeSubCreatedWebhookContext({ - ctx, - customerId, - stripeSubscription, - }), - }); + await timeout(WEBHOOK_TIMEOUT_MS); const customer = await autumnV1.customers.get(customerId); await expectProductNotPresent({ customer, productId: pro.id }); diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts index 7b43c9c3a..1a27df001 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts @@ -4,10 +4,7 @@ import { type TestContext, } from "@tests/utils/testInitUtils/createTestContext"; import { eq } from "drizzle-orm"; -import type Stripe from "stripe"; import { initDrizzle } from "@/db/initDrizzle"; -import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; -import { CusService } from "@/internal/customers/CusService"; import { OrgService } from "@/internal/orgs/OrgService"; import { clearOrgCache } from "@/internal/orgs/orgUtils/clearOrgCache"; import { encryptData } from "@/utils/encryptUtils"; @@ -60,34 +57,3 @@ export const getStripeSandboxContext = () => { stripeSandboxContext ??= ensureTestOrgUsesStripeSandboxKey(); return stripeSandboxContext; }; - -export const makeSubCreatedWebhookContext = async ({ - ctx, - customerId, - stripeSubscription, -}: { - ctx: TestContext; - customerId: string; - stripeSubscription: Stripe.Subscription; -}): Promise => { - const fullCustomer = await CusService.getFull({ - ctx, - idOrInternalId: customerId, - withSubs: true, - withEntities: true, - }); - - return { - ...ctx, - fullCustomer, - stripeEvent: { - type: "customer.subscription.created", - data: { - object: { - id: stripeSubscription.id, - customer: fullCustomer.processor?.id, - }, - }, - } as Stripe.Event, - }; -}; From e30469c752f332c8af93a2f3a3a0d007c6bfce14 Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Thu, 30 Apr 2026 18:42:01 +0100 Subject: [PATCH 2/3] add test for auto-sync matching via stripe_price_id when product ID mismatches --- .../sub-created-auto-sync.test.ts | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts index d30ec1a8f..7c8f5a876 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/sub-created-auto-sync.test.ts @@ -17,6 +17,7 @@ import chalk from "chalk"; import { handleStripeSubscriptionCreated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionCreated/handleStripeSubscriptionCreated"; import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { CusService } from "@/internal/customers/CusService"; +import { ProductService } from "@/internal/products/ProductService"; import { timeout } from "@/utils/genUtils"; import { getStripeSandboxContext } from "./subscriptionCreatedTestUtils.js"; @@ -255,6 +256,65 @@ test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: await expectProductNotPresent({ customer, productId: pro.id }); }); +test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: links via stripe_price_id when stripe_product_id mismatches")}`, async () => { + const ctx = await getStripeSandboxContext(); + const customerId = `sub-created-auto-sync-price-id-match-${testRunId}`; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const pro = products.pro({ + id: "pro", + items: [messagesItem], + }); + + const { autumnV1 } = await initScenario({ + customerId, + ctx, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + // Force product-id mismatch so the only viable match path is price-id. + const fullProduct = await ProductService.getFull({ + db: ctx.db, + idOrInternalId: pro.id, + orgId: ctx.org.id, + env: ctx.env, + }); + await ProductService.updateByInternalId({ + db: ctx.db, + internalId: fullProduct.internal_id, + update: { + processor: { type: "stripe", id: `prod_fake_${testRunId}` }, + }, + }); + + // Sub items still carry the real stripe_price_id (and Stripe-bound product), + // so price-id matches the Autumn price even though product-id won't match. + const stripeSubscription = await createStripeSubscriptionFromProduct({ + ctx, + customerId, + productId: pro.id, + }); + + await timeout(WEBHOOK_TIMEOUT_MS); + + const customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + withSubs: true, + }); + const customerProduct = fullCustomer.customer_products.find( + (product) => product.product_id === pro.id, + ); + expect(customerProduct?.subscription_ids).toContain(stripeSubscription.id); +}); + test.concurrent(`${chalk.yellowBright("customer.subscription.created auto-sync: skips Stripe sandbox sub matching multiple products")}`, async () => { const ctx = await getStripeSandboxContext(); const customerId = `sub-created-auto-sync-multi-match-${testRunId}`; From 05e34cfe38411c20f9d0010ebb30b9419929af8f Mon Sep 17 00:00:00 2001 From: Owen Greenhalgh Date: Thu, 30 Apr 2026 19:15:53 +0100 Subject: [PATCH 3/3] ensure stripe sandbox webhook secret is configured in test utils --- .../subscription-created/subscriptionCreatedTestUtils.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts index 1a27df001..ec3ca356f 100644 --- a/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts +++ b/server/tests/integration/billing/stripe-webhooks/subscription-created/subscriptionCreatedTestUtils.ts @@ -17,6 +17,13 @@ const ensureTestOrgUsesStripeSandboxKey = async (): Promise => { ); } + const sandboxWebhookSecret = process.env.STRIPE_SANDBOX_WEBHOOK_SECRET; + if (!sandboxWebhookSecret?.startsWith("whsec_")) { + throw new Error( + "STRIPE_SANDBOX_WEBHOOK_SECRET must be set; the webhook handler reads this off the org's stripe_config to verify signatures", + ); + } + const { db } = initDrizzle(); const organizationSlug = process.env.TESTS_ORG; if (!organizationSlug) { @@ -38,6 +45,7 @@ const ensureTestOrgUsesStripeSandboxKey = async (): Promise => { stripe_config: { ...(organization.stripe_config || {}), test_api_key: encryptData(sandboxSecretKey), + test_webhook_secret: encryptData(sandboxWebhookSecret), }, test_stripe_connect: {}, })