fix: attach no billing changes

This commit is contained in:
John Yeo
2026-05-17 10:37:13 +01:00
parent 091ce4605f
commit 42410f2a12
7 changed files with 306 additions and 179 deletions

4
.gitignore vendored
View File

@@ -152,4 +152,6 @@ server/.turbo
./AGENTS.md
./CLAUDE.md
./CLAUDE.md
notes.txt

2
ai

Submodule ai updated: 761b842553...ade6ce69f0

386
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,7 @@
"@hatchet-dev/typescript-sdk": "1.14.0",
"@hono-rate-limiter/redis": "^0.1.4",
"@hono/node-server": "^1.19.5",
"@hono/otel": "^1.1.1",
"@hono/otel": "1.1.1",
"@hono/zod-validator": "^0.7.3",
"@hyperbrowser/sdk": "^0.54.0",
"@infisical/sdk": "^4.0.6",

View File

@@ -122,11 +122,15 @@ export const setupAttachBillingContext = async ({
isTransitionFromFree &&
hasPaidRecurringSubscription);
const skipBillingFetching =
orgDisableStripeWrites({ ctx }) || params.no_billing_changes === true;
// no_billing_changes blocks WRITES but should still allow reading the
// existing Stripe sub when one is linked — needed so the new cusProduct
// inherits subscription_ids and the paid-product guard doesn't misfire.
const skipBillingFetching = orgDisableStripeWrites({ ctx });
const skipBillingChangesBase =
skipBillingFetching || params.processor_subscription_id !== undefined;
skipBillingFetching ||
params.no_billing_changes === true ||
params.processor_subscription_id !== undefined;
const {
stripeSubscription,
@@ -144,7 +148,8 @@ export const setupAttachBillingContext = async ({
params,
newBillingSubscription: shouldForceNewSubscription,
skipBillingFetching,
createStripeCustomerIfMissing: !preview,
createStripeCustomerIfMissing:
!preview && params.no_billing_changes !== true,
});
const featureQuantities = setupFeatureQuantitiesContext({

View File

@@ -1,5 +1,9 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, AttachParamsV0Input } from "@autumn/shared";
import type {
ApiCustomerV3,
AttachParamsV0Input,
AttachParamsV1Input,
} from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
@@ -73,3 +77,70 @@ test.concurrent(`${chalk.yellowBright("no_billing_changes: attach with no_billin
env: ctx.env,
});
});
/**
* Regression: attaching with no_billing_changes:true on a customer whose
* current paid product is linked to a Stripe subscription used to fail with
* "paid but no stripe subscription is linked to it", because skipBillingFetching
* short-circuited setupStripeBillingContext and the guard read the (undefined)
* runtime-fetched stripeSubscription. Fix decouples no_billing_changes from
* skipBillingFetching: writes are still suppressed, but the sub is read so its
* id carries over to the new cusProduct.
*/
test.concurrent(`${chalk.yellowBright("no_billing_changes: carries subscription_ids forward when current paid product is linked")}`, async () => {
const customerId = "no-billing-changes-paid-current";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro-nbc-paid-current",
items: [messagesItem],
});
const premium = products.premium({
id: "premium-nbc-paid-current",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const beforeFullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const beforeProCusProduct = beforeFullCustomer.customer_products.find(
(cp) => cp.product.id === pro.id,
);
const expectedSubscriptionIds = beforeProCusProduct?.subscription_ids ?? [];
expect(expectedSubscriptionIds.length).toBeGreaterThan(0);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
no_billing_changes: true,
});
const afterCustomer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: afterCustomer,
active: [premium.id],
notPresent: [pro.id],
});
const afterFullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const afterPremiumCusProduct = afterFullCustomer.customer_products.find(
(cp) => cp.product.id === premium.id,
);
expect(afterPremiumCusProduct?.subscription_ids).toEqual(
expectedSubscriptionIds,
);
});

View File

@@ -44,10 +44,7 @@
"drizzle-orm/*": ["../node_modules/drizzle-orm/*"],
"@better-auth/core": ["../node_modules/@better-auth/core"],
"@better-auth/core/*": ["../node_modules/@better-auth/core/*"],
"hono": ["../node_modules/hono"],
"hono/*": ["../node_modules/hono/*"]
"@better-auth/core/*": ["../node_modules/@better-auth/core/*"]
}
},
"include": ["src", "tests", "scripts", "experiments", "perf"],