From 0e3e2416461a0ce2c0ac445421e11d980239d552 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:15:29 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=F0=9F=90=9B=20trials=5Fused=20not?= =?UTF-8?q?=20properly=20bypassing=20cache?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiCusUtils/getApiCustomerExpand.ts | 28 ++++---- .../cusResponseUtils/getCusTrialsUsed.ts | 65 +++++++++++++++++++ 2 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 4c5cf1e54..352125613 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -11,6 +11,7 @@ import { CusService } from "../../CusService.js"; import { getCusPaymentMethodRes } from "../cusResponseUtils/getCusPaymentMethodRes.js"; import { getCusReferrals } from "../cusResponseUtils/getCusReferrals.js"; import { getCusRewards } from "../cusResponseUtils/getCusRewards.js"; +import { getCusTrialsUsed } from "../cusResponseUtils/getCusTrialsUsed.js"; export const getApiCustomerExpand = async ({ ctx, @@ -21,7 +22,7 @@ export const getApiCustomerExpand = async ({ customerId?: string; fullCus?: FullCustomer; }): Promise => { - const { org, env, db, logger, expand } = ctx; + const { org, env, db, expand } = ctx; // Filter out balances.feature and subscriptions.plan const filteredExpand = filterExpand({ @@ -45,19 +46,6 @@ export const getApiCustomerExpand = async ({ }); } - const getCusTrialsUsed = () => { - if (expand.includes(CustomerExpand.TrialsUsed)) { - return ( - fullCus.trials_used?.map((t) => ({ - plan_id: t.product_id, - customer_id: t.customer_id, - fingerprint: t.fingerprint, - })) ?? [] - ); - } - return undefined; - }; - const getApiCusEntities = () => { if (expand.includes(CustomerExpand.Entities)) { return fullCus.entities.map((e) => ApiBaseEntitySchema.parse(e)); @@ -67,7 +55,7 @@ export const getApiCustomerExpand = async ({ const cusExpand = expand as CustomerExpand[]; - const [rewards, referrals, paymentMethod] = await Promise.all([ + const [rewards, referrals, paymentMethod, trialsUsed] = await Promise.all([ getCusRewards({ org, env, @@ -77,7 +65,6 @@ export const getApiCustomerExpand = async ({ ), expand: cusExpand, }), - getCusReferrals({ db, fullCus, @@ -89,10 +76,17 @@ export const getApiCustomerExpand = async ({ fullCus, expand: cusExpand, }), + getCusTrialsUsed({ + db, + fullCus, + orgId: org.id, + env, + expand: cusExpand, + }), ]); return { - trials_used: getCusTrialsUsed() ?? undefined, + trials_used: trialsUsed ?? undefined, entities: getApiCusEntities() ?? undefined, rewards: rewards ?? undefined, // upcoming_invoice: upcomingInvoice, diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts new file mode 100644 index 000000000..b6713cb13 --- /dev/null +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts @@ -0,0 +1,65 @@ +import { + type AppEnv, + CustomerExpand, + customerProducts, + customers, + type FullCustomer, + products, +} from "@autumn/shared"; +import { and, eq, isNotNull, or } from "drizzle-orm"; +import type { DrizzleCli } from "@/db/initDrizzle.js"; + +export const getCusTrialsUsed = async ({ + db, + fullCus, + orgId, + env, + expand, +}: { + db: DrizzleCli; + fullCus: FullCustomer; + orgId: string; + env: AppEnv; + expand?: CustomerExpand[]; +}) => { + if (!expand?.includes(CustomerExpand.TrialsUsed)) { + return undefined; + } + + const rows = await db + .select({ + plan_id: products.id, + customer_id: customers.id, + fingerprint: customers.fingerprint, + }) + .from(customerProducts) + .innerJoin( + products, + eq(customerProducts.internal_product_id, products.internal_id), + ) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + or( + eq(customers.id, fullCus.id ?? ""), + fullCus.fingerprint + ? eq(customers.fingerprint, fullCus.fingerprint) + : undefined, + ), + eq(products.org_id, orgId), + eq(products.env, env), + isNotNull(customerProducts.free_trial_id), + ), + ); + + return rows + .filter((r) => r.customer_id !== null) + .map((r) => ({ + plan_id: r.plan_id, + customer_id: r.customer_id as string, + fingerprint: r.fingerprint, + })); +}; From e20043c6e7e1bfdfe833bee5ac1ce42bcdf0fcc4 Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:44:12 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20move=20db=20call=20t?= =?UTF-8?q?o=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repos/fetchCustomerProductFreeTrials.ts | 56 ++++++++++++++++++ .../customers/cusProducts/repos/index.ts | 2 + .../apiCusUtils/getApiCustomerExpand.ts | 4 +- .../cusResponseUtils/getCusTrialsUsed.ts | 58 ++----------------- 4 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts diff --git a/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts b/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts new file mode 100644 index 000000000..a307f67d2 --- /dev/null +++ b/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts @@ -0,0 +1,56 @@ +import { + customerProducts, + customers, + type FullCustomer, + products, +} from "@autumn/shared"; +import { and, eq, isNotNull, or } from "drizzle-orm"; +import type { RepoContext } from "@/db/repoContext.js"; + +/** Fetch all customer_products rows with a free trial for a given customer (or matching fingerprint). */ +export const fetchCustomerProductFreeTrials = async ({ + ctx, + fullCus, +}: { + ctx: RepoContext; + fullCus: FullCustomer; +}) => { + const { db, org, env } = ctx; + + const rows = await db + .select({ + plan_id: products.id, + customer_id: customers.id, + fingerprint: customers.fingerprint, + }) + .from(customerProducts) + .innerJoin( + products, + eq(customerProducts.internal_product_id, products.internal_id), + ) + .innerJoin( + customers, + eq(customerProducts.internal_customer_id, customers.internal_id), + ) + .where( + and( + or( + eq(customers.id, fullCus.id ?? ""), + fullCus.fingerprint + ? eq(customers.fingerprint, fullCus.fingerprint) + : undefined, + ), + eq(products.org_id, org.id), + eq(products.env, env), + isNotNull(customerProducts.trial_ends_at), + ), + ); + + return rows + .filter((r) => r.customer_id !== null) + .map((r) => ({ + plan_id: r.plan_id, + customer_id: r.customer_id as string, + fingerprint: r.fingerprint, + })); +}; diff --git a/server/src/internal/customers/cusProducts/repos/index.ts b/server/src/internal/customers/cusProducts/repos/index.ts index 338b6637b..967be5935 100644 --- a/server/src/internal/customers/cusProducts/repos/index.ts +++ b/server/src/internal/customers/cusProducts/repos/index.ts @@ -1,5 +1,7 @@ import { batchUpdateCustomerProducts } from "./batchUpdateCustomerProducts"; +import { fetchCustomerProductFreeTrials } from "./fetchCustomerProductFreeTrials"; export const customerProductRepo = { batchUpdate: batchUpdateCustomerProducts, + fetchFreeTrials: fetchCustomerProductFreeTrials, }; diff --git a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts index 352125613..909f6a4cd 100644 --- a/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts +++ b/server/src/internal/customers/cusUtils/apiCusUtils/getApiCustomerExpand.ts @@ -77,10 +77,8 @@ export const getApiCustomerExpand = async ({ expand: cusExpand, }), getCusTrialsUsed({ - db, + ctx, fullCus, - orgId: org.id, - env, expand: cusExpand, }), ]); diff --git a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts index b6713cb13..383dc1f7e 100644 --- a/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts +++ b/server/src/internal/customers/cusUtils/cusResponseUtils/getCusTrialsUsed.ts @@ -1,65 +1,19 @@ -import { - type AppEnv, - CustomerExpand, - customerProducts, - customers, - type FullCustomer, - products, -} from "@autumn/shared"; -import { and, eq, isNotNull, or } from "drizzle-orm"; -import type { DrizzleCli } from "@/db/initDrizzle.js"; +import { CustomerExpand, type FullCustomer } from "@autumn/shared"; +import type { RepoContext } from "@/db/repoContext.js"; +import { customerProductRepo } from "../../cusProducts/repos/index.js"; export const getCusTrialsUsed = async ({ - db, + ctx, fullCus, - orgId, - env, expand, }: { - db: DrizzleCli; + ctx: RepoContext; fullCus: FullCustomer; - orgId: string; - env: AppEnv; expand?: CustomerExpand[]; }) => { if (!expand?.includes(CustomerExpand.TrialsUsed)) { return undefined; } - const rows = await db - .select({ - plan_id: products.id, - customer_id: customers.id, - fingerprint: customers.fingerprint, - }) - .from(customerProducts) - .innerJoin( - products, - eq(customerProducts.internal_product_id, products.internal_id), - ) - .innerJoin( - customers, - eq(customerProducts.internal_customer_id, customers.internal_id), - ) - .where( - and( - or( - eq(customers.id, fullCus.id ?? ""), - fullCus.fingerprint - ? eq(customers.fingerprint, fullCus.fingerprint) - : undefined, - ), - eq(products.org_id, orgId), - eq(products.env, env), - isNotNull(customerProducts.free_trial_id), - ), - ); - - return rows - .filter((r) => r.customer_id !== null) - .map((r) => ({ - plan_id: r.plan_id, - customer_id: r.customer_id as string, - fingerprint: r.fingerprint, - })); + return customerProductRepo.fetchFreeTrials({ ctx, fullCus }); }; From cfc4097e6ebbdc1e2f65a9d9bac771c322dc25ca Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Mon, 2 Mar 2026 15:51:35 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=F0=9F=90=9B=20a=20bug=20where=20upg?= =?UTF-8?q?rade=20webhooks=20weren't=20being=20sent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handleCheckoutSessionMetadataV2.ts | 8 + .../free-to-pro-upgrade-v1-webhook.test.ts | 164 +++++++++++++++++ .../free-to-pro-upgrade-v2-webhook.test.ts | 165 ++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v1-webhook.test.ts create mode 100644 server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v2-webhook.test.ts diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts index 2cc889296..d506a6a93 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts @@ -8,6 +8,7 @@ import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext"; import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan"; import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan"; +import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated"; import { MetadataService } from "@/internal/metadata/MetadataService"; import { workflows } from "@/queue/workflows"; import { addToExtraLogs } from "@/utils/logging/addToExtraLogs"; @@ -62,6 +63,13 @@ export const handleCheckoutSessionMetadataV2 = async ({ autumnBillingPlan: updatedDeferredData.billingPlan.autumn, }); + // Queue customer.products.updated webhook (mirrors executeBillingPlan) + await billingPlanToSendProductsUpdated({ + ctx, + autumnBillingPlan: updatedDeferredData.billingPlan.autumn, + billingContext: updatedDeferredData.billingContext, + }); + // Delete metadata after successful execution await MetadataService.delete({ db: ctx.db, id: metadata.id }); diff --git a/server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v1-webhook.test.ts b/server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v1-webhook.test.ts new file mode 100644 index 000000000..318ee2570 --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v1-webhook.test.ts @@ -0,0 +1,164 @@ +/** + * Integration test: Free → Pro upgrade via Stripe Checkout session (Attach V1), + * verifying the customer.products.updated webhook fires with the + * correct scenario values. + * + * Does NOT use a pre-attached payment method. Instead, the upgrade + * goes through the full Stripe Checkout flow via completeStripeCheckoutFormV2. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiProduct } from "@autumn/shared"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + generatePlayToken, + getPlayWebhookUrl, + waitForWebhook, +} from "./utils/svixPlayClient.js"; +import { + createTestEndpoint, + deleteTestEndpoint, +} from "./utils/svixTestEndpoint.js"; + +type CustomerProductsUpdatedPayload = { + type: string; + data: { + scenario: string; + customer: ApiCustomerV3; + updated_product: ApiProduct; + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// SVIX PLAY SETUP +// ═══════════════════════════════════════════════════════════════════════════════ + +let playToken: string; +let endpointId: string; + +beforeAll(async () => { + playToken = await generatePlayToken(); + console.log(`Generated Svix Play token: ${playToken}`); + + const svixAppId = ctx.org.svix_config?.sandbox_app_id; + if (!svixAppId) { + throw new Error( + "Test org does not have svix_config.sandbox_app_id configured. " + + "Cannot run webhook integration tests without Svix app.", + ); + } + + const playUrl = getPlayWebhookUrl(playToken); + console.log(`Creating Svix endpoint: ${playUrl}`); + endpointId = await createTestEndpoint({ appId: svixAppId, playUrl }); + console.log(`Created Svix endpoint: ${endpointId}`); +}); + +afterAll(async () => { + const svixAppId = ctx.org.svix_config?.sandbox_app_id; + if (svixAppId && endpointId) { + await deleteTestEndpoint({ appId: svixAppId, endpointId }); + console.log(`Deleted Svix endpoint: ${endpointId}`); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST: Free (default) → Pro via Stripe Checkout (V1) +// ═══════════════════════════════════════════════════════════════════════════════ + +test( + `${chalk.yellowBright("webhook v1 checkout: free default → pro upgrade via stripe checkout - scenario: new")}`, + async () => { + const customerId = "webhook-v1-checkout-free-to-pro"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + // Setup: customer with NO payment method and a free default product + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, skipWebhooks: true }), // no payment method + s.products({ list: [free, pro] }), + ], + actions: [], + }); + + // Step 1: Attach free default product via v1 (no checkout needed, it's free) + await autumnV1.attach({ + customer_id: customerId, + product_id: free.id, + }); + + // Wait for the "new" webhook for the free product attachment + const freeWebhook = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "new" && + payload.data?.updated_product?.id === free.id, + timeoutMs: 15000, + }); + + expect(freeWebhook).not.toBeNull(); + expect(freeWebhook?.payload.data.scenario).toBe("new"); + expect(freeWebhook?.payload.data.updated_product.id).toBe(free.id); + + // Verify free is active + let customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: free.id }); + + // Step 2: Attempt upgrade to Pro via v1 — no payment method, so returns checkout_url + const upgradeResult = await autumnV1.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(upgradeResult.checkout_url).toBeDefined(); + console.log(`Checkout URL: ${upgradeResult.checkout_url}`); + + // Step 3: Complete the Stripe Checkout form via browser automation + await completeStripeCheckoutFormV2({ url: upgradeResult.checkout_url }); + + // Wait for Stripe webhook to be processed by the server + await timeout(12000); + + // Step 4: Assert upgrade webhook received + const upgradeWebhook = await waitForWebhook( + { + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.updated_product?.id === pro.id, + timeoutMs: 20000, + }, + ); + + expect(upgradeWebhook).not.toBeNull(); + expect(upgradeWebhook?.payload.type).toBe("customer.products.updated"); + + const { data } = upgradeWebhook!.payload; + console.log(`Upgrade webhook scenario: ${data.scenario}`); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer.id).toBe(customerId); + + // Step 5: Verify Pro is now active + customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); + }, + { timeout: 120000 }, +); diff --git a/server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v2-webhook.test.ts b/server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v2-webhook.test.ts new file mode 100644 index 000000000..620d28deb --- /dev/null +++ b/server/tests/integration/billing/autumn-webhooks/free-to-pro-upgrade-v2-webhook.test.ts @@ -0,0 +1,165 @@ +/** + * Integration test: Free → Pro upgrade via Stripe Checkout session, + * verifying the customer.products.updated webhook fires with the + * correct scenario values. + * + * Does NOT use a pre-attached payment method. Instead, the upgrade + * goes through the full Stripe Checkout flow via completeStripeCheckoutFormV2. + */ + +import { afterAll, beforeAll, expect, test } from "bun:test"; +import type { ApiCustomerV3, ApiProduct } from "@autumn/shared"; +import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect"; +import { completeStripeCheckoutFormV2 } from "@tests/utils/browserPool/completeStripeCheckoutFormV2.js"; +import { items } from "@tests/utils/fixtures/items.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { timeout } from "@tests/utils/genUtils.js"; +import ctx from "@tests/utils/testInitUtils/createTestContext.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import { + generatePlayToken, + getPlayWebhookUrl, + waitForWebhook, +} from "./utils/svixPlayClient.js"; +import { + createTestEndpoint, + deleteTestEndpoint, +} from "./utils/svixTestEndpoint.js"; + +type CustomerProductsUpdatedPayload = { + type: string; + data: { + scenario: string; + customer: ApiCustomerV3; + updated_product: ApiProduct; + }; +}; + +// ═══════════════════════════════════════════════════════════════════════════════ +// SVIX PLAY SETUP +// ═══════════════════════════════════════════════════════════════════════════════ + +let playToken: string; +let endpointId: string; + +beforeAll(async () => { + playToken = await generatePlayToken(); + console.log(`Generated Svix Play token: ${playToken}`); + + const svixAppId = ctx.org.svix_config?.sandbox_app_id; + if (!svixAppId) { + throw new Error( + "Test org does not have svix_config.sandbox_app_id configured. " + + "Cannot run webhook integration tests without Svix app.", + ); + } + + const playUrl = getPlayWebhookUrl(playToken); + console.log(`Creating Svix endpoint: ${playUrl}`); + endpointId = await createTestEndpoint({ appId: svixAppId, playUrl }); + console.log(`Created Svix endpoint: ${endpointId}`); +}); + +afterAll(async () => { + const svixAppId = ctx.org.svix_config?.sandbox_app_id; + if (svixAppId && endpointId) { + await deleteTestEndpoint({ appId: svixAppId, endpointId }); + console.log(`Deleted Svix endpoint: ${endpointId}`); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST: Free (default) → Pro via Stripe Checkout +// ═══════════════════════════════════════════════════════════════════════════════ + +test( + `${chalk.yellowBright("webhook checkout: free default → pro upgrade via stripe checkout - scenario: new")}`, + async () => { + const customerId = "webhook-checkout-free-to-pro"; + + const messagesItem = items.monthlyMessages({ includedUsage: 100 }); + const free = products.base({ + id: "free", + items: [messagesItem], + isDefault: true, + }); + const pro = products.pro({ id: "pro", items: [messagesItem] }); + + // Setup: customer with NO payment method and a free default product + const { autumnV1 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: true, skipWebhooks: true }), // no payment method + s.products({ list: [free, pro] }), + ], + actions: [], + }); + + // Step 1: Attach free default product (no checkout needed, it's free) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: free.id, + }); + + // Wait for the "new" webhook for the free product attachment + const freeWebhook = await waitForWebhook({ + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.scenario === "new" && + payload.data?.updated_product?.id === free.id, + timeoutMs: 15000, + }); + + expect(freeWebhook).not.toBeNull(); + expect(freeWebhook?.payload.data.scenario).toBe("new"); + expect(freeWebhook?.payload.data.updated_product.id).toBe(free.id); + + // Verify free is active + let customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: free.id }); + + // Step 2: Attempt upgrade to Pro — no payment method, so returns payment_url + const upgradeResult = await autumnV1.billing.attach({ + customer_id: customerId, + product_id: pro.id, + }); + + expect(upgradeResult.payment_url).toBeDefined(); + console.log(`Checkout URL: ${upgradeResult.payment_url}`); + + // Step 3: Complete the Stripe Checkout form via browser automation + await completeStripeCheckoutFormV2({ url: upgradeResult.payment_url }); + + // Wait for Stripe webhook to be processed by the server + await timeout(12000); + + // Step 4: Assert upgrade webhook received with scenario "new" + // (free → pro upgrade goes through checkout session completed path) + const upgradeWebhook = await waitForWebhook( + { + token: playToken, + predicate: (payload) => + payload.type === "customer.products.updated" && + payload.data?.customer?.id === customerId && + payload.data?.updated_product?.id === pro.id, + timeoutMs: 20000, + }, + ); + + expect(upgradeWebhook).not.toBeNull(); + expect(upgradeWebhook?.payload.type).toBe("customer.products.updated"); + + const { data } = upgradeWebhook!.payload; + console.log(`Upgrade webhook scenario: ${data.scenario}`); + expect(data.updated_product.id).toBe(pro.id); + expect(data.customer.id).toBe(customerId); + + // Step 5: Verify Pro is now active + customer = await autumnV1.customers.get(customerId); + await expectProductActive({ customer, productId: pro.id }); + }, + { timeout: 120000 }, +); From bba2b035c139ec266e8699db1751a176ca28db74 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 3 Mar 2026 10:14:50 +0000 Subject: [PATCH 4/4] updated free trial matching condition --- .../cusProducts/repos/fetchCustomerProductFreeTrials.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts b/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts index a307f67d2..1fa95ccac 100644 --- a/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts +++ b/server/src/internal/customers/cusProducts/repos/fetchCustomerProductFreeTrials.ts @@ -35,7 +35,7 @@ export const fetchCustomerProductFreeTrials = async ({ .where( and( or( - eq(customers.id, fullCus.id ?? ""), + eq(customers.internal_id, fullCus.internal_id), fullCus.fingerprint ? eq(customers.fingerprint, fullCus.fingerprint) : undefined,