fix: duplicate stripe customer IDs
This commit is contained in:
@@ -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
2
ai
Submodule ai updated: eb7800f5f7...678c8ed7e9
@@ -42,6 +42,7 @@ export async function attach({
|
||||
ctx,
|
||||
params,
|
||||
contextOverride,
|
||||
preview,
|
||||
});
|
||||
|
||||
logAttachContext({ ctx, billingContext });
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -27,6 +27,7 @@ export const previewCreateScheduleWithContext = async ({
|
||||
const billingContext = await setupCreateScheduleBillingContext({
|
||||
ctx,
|
||||
params,
|
||||
preview: true,
|
||||
});
|
||||
|
||||
await handleCreateScheduleErrors({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function multiAttach({
|
||||
const billingContext = await setupMultiAttachBillingContext({
|
||||
ctx,
|
||||
params,
|
||||
preview,
|
||||
});
|
||||
|
||||
// 2. Errors
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -44,6 +44,7 @@ export async function updateSubscription({
|
||||
ctx,
|
||||
params,
|
||||
contextOverride,
|
||||
preview,
|
||||
});
|
||||
|
||||
logUpdateSubscriptionContext({ ctx, billingContext });
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user