diff --git a/.opencode/opencode.json b/.opencode/opencode.json index 14dbb803e..958f8071c 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -40,7 +40,12 @@ "url": "https://mcp.plain.com/mcp", "oauth": {} }, - "autumn-internal": { + "slack": { + "type": "remote", + "url": "https://mcp.slack.com/mcp", + "oauth": {} + }, + "autumn_prod_internal": { "type": "local", "command": [ "sh", @@ -48,7 +53,20 @@ "cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=prod --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\"" ], "env": { - "AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud" + "AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud", + "AUTUMN_MCP_ENV": "prod" + } + }, + "autumn_dev_internal": { + "type": "local", + "command": [ + "sh", + "-c", + "cd \"/Users/johnyeocx/Autumn/autumn-cloud\" && exec infisical run --env=dev --recursive -- bun run \"/Users/johnyeocx/Autumn/autumn-cloud/ai/src/mcp/index.ts\"" + ], + "env": { + "AUTUMN_CLOUD_ROOT": "/Users/johnyeocx/Autumn/autumn-cloud", + "AUTUMN_MCP_ENV": "dev" } } }, diff --git a/ai b/ai index eb7800f5f..678c8ed7e 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit eb7800f5f7f0481769c2a946078c05d23b609ef5 +Subproject commit 678c8ed7e94f1f3b02eb9a4fad5fcb396ea99c89 diff --git a/server/src/internal/billing/v2/actions/attach/attach.ts b/server/src/internal/billing/v2/actions/attach/attach.ts index ffb08ea8d..33283e9e4 100644 --- a/server/src/internal/billing/v2/actions/attach/attach.ts +++ b/server/src/internal/billing/v2/actions/attach/attach.ts @@ -42,6 +42,7 @@ export async function attach({ ctx, params, contextOverride, + preview, }); logAttachContext({ ctx, billingContext }); diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index eae0df69e..d25a8c920 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -39,10 +39,12 @@ export const setupAttachBillingContext = async ({ ctx, params, contextOverride = {}, + preview = false, }: { ctx: AutumnContext; params: AttachParamsV1; contextOverride?: BillingContextOverride; + preview?: boolean; }): Promise => { const { fullCustomer: fullCustomerOverride } = contextOverride; @@ -139,6 +141,7 @@ export const setupAttachBillingContext = async ({ params, newBillingSubscription: shouldForceNewSubscription, skipBillingFetching, + createStripeCustomerIfMissing: !preview, }); const featureQuantities = setupFeatureQuantitiesContext({ diff --git a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts index 746bccc85..00cf28d7b 100644 --- a/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts +++ b/server/src/internal/billing/v2/actions/common/immediateMultiProduct/setupImmediateMultiProductBillingContext.ts @@ -106,9 +106,11 @@ const setupImmediateMultiProductTrialContext = async ({ export const setupImmediateMultiProductBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: MultiAttachParamsV0; + preview?: boolean; }): Promise => { const fullCustomer = await setupFullCustomerContext({ ctx, @@ -179,6 +181,7 @@ export const setupImmediateMultiProductBillingContext = async ({ params, skipSubscriptionFetching: fullProducts.every(isOneOffProduct), newBillingSubscription: params.new_billing_subscription || undefined, + createStripeCustomerIfMissing: !preview, }); const invoiceMode = setupInvoiceModeContext({ params }); diff --git a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts index d219c1ca0..481cfbf9c 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/previewCreateSchedule.ts @@ -27,6 +27,7 @@ export const previewCreateScheduleWithContext = async ({ const billingContext = await setupCreateScheduleBillingContext({ ctx, params, + preview: true, }); await handleCreateScheduleErrors({ diff --git a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts index e71a1c5e5..3d38fea39 100644 --- a/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts +++ b/server/src/internal/billing/v2/actions/createSchedule/setup/setupCreateScheduleBillingContext.ts @@ -70,9 +70,11 @@ const setupCreateScheduleCheckoutMode = ({ export const setupCreateScheduleBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: CreateScheduleParamsV0; + preview?: boolean; }): Promise => { const normalizedPhases = normalizeCreateSchedulePhases({ phases: params.phases, @@ -99,6 +101,7 @@ export const setupCreateScheduleBillingContext = async ({ const billingContext = await setupImmediateMultiProductBillingContext({ ctx, params: immediateParams, + preview, }); validateCreateSchedulePhasePlans({ diff --git a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts index ee74b5f79..1ef79e545 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/multiAttach.ts @@ -34,6 +34,7 @@ export async function multiAttach({ const billingContext = await setupMultiAttachBillingContext({ ctx, params, + preview, }); // 2. Errors diff --git a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts index 6f10040d0..664a8b7d7 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/setup/setupMultiAttachBillingContext.ts @@ -11,11 +11,14 @@ import { setupImmediateMultiProductBillingContext } from "../../common/immediate export const setupMultiAttachBillingContext = async ({ ctx, params, + preview = false, }: { ctx: AutumnContext; params: MultiAttachParamsV0; + preview?: boolean; }): Promise => setupImmediateMultiProductBillingContext({ ctx, params, + preview, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index 8e168cd45..cabe15335 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -41,10 +41,12 @@ export const setupUpdateSubscriptionBillingContext = async ({ ctx, params, contextOverride = {}, + preview = false, }: { ctx: AutumnContext; params: UpdateSubscriptionV1Params; contextOverride?: UpdateSubscriptionBillingContextOverride; + preview?: boolean; }): Promise => { const fullCustomer = await setupFullCustomerContext({ ctx, @@ -105,6 +107,7 @@ export const setupUpdateSubscriptionBillingContext = async ({ skipBillingFetching, product: fullProduct, skipSubscriptionFetching: isUpdatingFreeCustomerProduct, + createStripeCustomerIfMissing: !preview, }); const currentEpochMs = testClockFrozenTime ?? Date.now(); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts index 70c19d47f..067f82846 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/updateSubscription.ts @@ -44,6 +44,7 @@ export async function updateSubscription({ ctx, params, contextOverride, + preview, }); logUpdateSubscriptionContext({ ctx, billingContext }); diff --git a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts index 07de64a11..91292d98c 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/fetchStripeCustomerForBilling.ts @@ -1,6 +1,9 @@ import type { FullCustomer } from "@autumn/shared"; import { createStripeCli } from "@server/external/connect/createStripeCli"; -import { getOrCreateStripeCustomer } from "@server/external/stripe/customers"; +import { + getExpandedStripeCustomer, + getOrCreateStripeCustomer, +} from "@server/external/stripe/customers"; import { listCusPaymentMethods } from "@server/external/stripe/stripeCusUtils"; import type { AutumnContext } from "@server/honoUtils/HonoEnv"; import type Stripe from "stripe"; @@ -8,17 +11,24 @@ import type Stripe from "stripe"; export const fetchStripeCustomerForBilling = async ({ ctx, fullCus, + createIfMissing = true, }: { ctx: AutumnContext; fullCus: FullCustomer; + createIfMissing?: boolean; }) => { const { org, env } = ctx; const stripeCli = createStripeCli({ org, env }); - const stripeCus = await getOrCreateStripeCustomer({ - ctx, - customer: fullCus, - }); + const stripeCus = createIfMissing + ? await getOrCreateStripeCustomer({ + ctx, + customer: fullCus, + }) + : await getExpandedStripeCustomer({ + ctx, + stripeCustomerId: fullCus.processor?.id, + }); if (!stripeCus) { return { diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts index 15a6e8f15..f229caf72 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.ts @@ -26,6 +26,7 @@ export const setupStripeBillingContext = async ({ newBillingSubscription, skipBillingFetching, skipSubscriptionFetching, + createStripeCustomerIfMissing = true, }: { ctx: AutumnContext; fullCustomer: FullCustomer; @@ -36,6 +37,7 @@ export const setupStripeBillingContext = async ({ newBillingSubscription?: boolean; skipBillingFetching?: boolean; skipSubscriptionFetching?: boolean; + createStripeCustomerIfMissing?: boolean; }) => { const { stripeBillingContext } = contextOverride; @@ -66,6 +68,7 @@ export const setupStripeBillingContext = async ({ return fetchStripeCustomerForBilling({ ctx, fullCus: fullCustomer, + createIfMissing: createStripeCustomerIfMissing, }); }, async stripeSubscription() { diff --git a/server/tests/_temp/preview-attach-no-stripe-customer-create.test.ts b/server/tests/_temp/preview-attach-no-stripe-customer-create.test.ts new file mode 100644 index 000000000..ae1f37f4f --- /dev/null +++ b/server/tests/_temp/preview-attach-no-stripe-customer-create.test.ts @@ -0,0 +1,76 @@ +/** + * Regression: `billing.preview_attach` was provisioning a brand-new Stripe + * customer (via getOrCreateStripeCustomer) on every call whenever the Autumn + * customer had no `processor.id`. Because preview_attach is not in the + * refresh-cache allowlist, the FullCustomer Redis cache stayed populated with + * processor.id=null after each call, so subsequent preview_attach calls kept + * minting fresh Stripe customers. Customers reported 5+ Stripe customers for + * a single Autumn customer (athena, popfly tickets on 11 May 2026). + * + * Red-failure mode (current behavior): + * - After clearing the Autumn customer's processor and calling + * billing.preview_attach twice, the customer's processor.id is non-null + * (preview wrote a fresh stripe customer to the DB). + * + * Green-success criteria (after fix): + * - preview_attach must not call createStripeCustomer; the Autumn customer's + * processor.id remains null after multiple preview_attach calls. + */ + +import { expect, test } from "bun:test"; +import chalk from "chalk"; +import { items } from "@tests/utils/fixtures/items"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import { CusService } from "@/internal/customers/CusService"; +import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer"; + +test( + `${chalk.yellowBright("preview_attach with no stripe customer: does not create one")}`, + async () => { + const customerId = "preview-no-stripe-cus-create"; + + const pro = products.pro({ + id: "pro-no-stripe-cus", + items: [items.monthlyMessages({ includedUsage: 100 })], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false }), + s.products({ list: [pro] }), + ], + actions: [], + }); + + await CusService.update({ + ctx, + idOrInternalId: customerId, + update: { processor: null as unknown as undefined }, + }); + await deleteCachedFullCustomer({ + ctx, + customerId, + source: "test-clear-processor", + skipGuard: true, + }); + + await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro-no-stripe-cus_${customerId}`, + }); + await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro-no-stripe-cus_${customerId}`, + }); + + const fullCustomer = await CusService.getFull({ + ctx, + idOrInternalId: customerId, + }); + + expect(fullCustomer.processor?.id ?? null).toBeNull(); + }, + 300_000, +);