fix: duplicate stripe customer IDs

This commit is contained in:
John Yeo
2026-05-12 12:12:29 +08:00
parent 04af464ef8
commit d009e580c8
14 changed files with 134 additions and 8 deletions

View File

@@ -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"
}
}
},

2
ai

Submodule ai updated: eb7800f5f7...678c8ed7e9

View File

@@ -42,6 +42,7 @@ export async function attach({
ctx,
params,
contextOverride,
preview,
});
logAttachContext({ ctx, billingContext });

View File

@@ -39,10 +39,12 @@ export const setupAttachBillingContext = async ({
ctx,
params,
contextOverride = {},
preview = false,
}: {
ctx: AutumnContext;
params: AttachParamsV1;
contextOverride?: BillingContextOverride;
preview?: boolean;
}): Promise<AttachBillingContext> => {
const { fullCustomer: fullCustomerOverride } = contextOverride;
@@ -139,6 +141,7 @@ export const setupAttachBillingContext = async ({
params,
newBillingSubscription: shouldForceNewSubscription,
skipBillingFetching,
createStripeCustomerIfMissing: !preview,
});
const featureQuantities = setupFeatureQuantitiesContext({

View File

@@ -106,9 +106,11 @@ const setupImmediateMultiProductTrialContext = async ({
export const setupImmediateMultiProductBillingContext = async ({
ctx,
params,
preview = false,
}: {
ctx: AutumnContext;
params: MultiAttachParamsV0;
preview?: boolean;
}): Promise<MultiAttachBillingContext> => {
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 });

View File

@@ -27,6 +27,7 @@ export const previewCreateScheduleWithContext = async ({
const billingContext = await setupCreateScheduleBillingContext({
ctx,
params,
preview: true,
});
await handleCreateScheduleErrors({

View File

@@ -70,9 +70,11 @@ const setupCreateScheduleCheckoutMode = ({
export const setupCreateScheduleBillingContext = async ({
ctx,
params,
preview = false,
}: {
ctx: AutumnContext;
params: CreateScheduleParamsV0;
preview?: boolean;
}): Promise<CreateScheduleBillingContext> => {
const normalizedPhases = normalizeCreateSchedulePhases({
phases: params.phases,
@@ -99,6 +101,7 @@ export const setupCreateScheduleBillingContext = async ({
const billingContext = await setupImmediateMultiProductBillingContext({
ctx,
params: immediateParams,
preview,
});
validateCreateSchedulePhasePlans({

View File

@@ -34,6 +34,7 @@ export async function multiAttach({
const billingContext = await setupMultiAttachBillingContext({
ctx,
params,
preview,
});
// 2. Errors

View File

@@ -11,11 +11,14 @@ import { setupImmediateMultiProductBillingContext } from "../../common/immediate
export const setupMultiAttachBillingContext = async ({
ctx,
params,
preview = false,
}: {
ctx: AutumnContext;
params: MultiAttachParamsV0;
preview?: boolean;
}): Promise<MultiAttachBillingContext> =>
setupImmediateMultiProductBillingContext({
ctx,
params,
preview,
});

View File

@@ -41,10 +41,12 @@ export const setupUpdateSubscriptionBillingContext = async ({
ctx,
params,
contextOverride = {},
preview = false,
}: {
ctx: AutumnContext;
params: UpdateSubscriptionV1Params;
contextOverride?: UpdateSubscriptionBillingContextOverride;
preview?: boolean;
}): Promise<UpdateSubscriptionBillingContext> => {
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();

View File

@@ -44,6 +44,7 @@ export async function updateSubscription({
ctx,
params,
contextOverride,
preview,
});
logUpdateSubscriptionContext({ ctx, billingContext });

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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,
);