added integration tests

This commit is contained in:
John Yeo
2026-05-13 15:33:22 +08:00
committed by John Yeo
parent f004b18545
commit f8ecfd354e
13 changed files with 1441 additions and 53 deletions

View File

@@ -105,10 +105,12 @@ export const createProduct = async ({
free_trial: newFreeTrial,
};
await initProductInStripe({
ctx,
product: newFullProduct,
});
if (data.create_in_stripe !== false) {
await initProductInStripe({
ctx,
product: newFullProduct,
});
}
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,

View File

@@ -131,10 +131,12 @@ export const handleCreatePlan = createRoute({
free_trial: newFreeTrial,
};
await initProductInStripe({
ctx,
product: newFullProduct,
});
if (v1_2Body.create_in_stripe !== false) {
await initProductInStripe({
ctx,
product: newFullProduct,
});
}
await addTaskToQueue({
jobName: JobName.DetectBaseVariant,

View File

@@ -11,6 +11,7 @@ export const initProductsV0 = async ({
skipPrefixIds = [],
customerId,
customerIds,
createInStripe,
}: {
ctx: TestContext;
products: ProductV2[];
@@ -18,6 +19,7 @@ export const initProductsV0 = async ({
skipPrefixIds?: string[];
customerId?: string;
customerIds?: string[];
createInStripe?: boolean;
}) => {
// 1. Add prefix to products (except those in skipPrefixIds)
if (prefix) {
@@ -55,5 +57,6 @@ export const initProductsV0 = async ({
env: ctx.env,
autumn: autumn,
products,
createInStripe,
});
};

View File

@@ -0,0 +1,209 @@
/**
* Preview billing actions must not create Stripe resources.
*
* Contract under test:
* - A plan created with create_in_stripe=false stays Stripe-less after
* these preview endpoints run:
* * POST /billing.preview_attach
* * POST /billing.preview_create_schedule (multi-phase)
* * POST /billing.preview_update (with customize)
* - No is_custom prices with Stripe IDs are persisted by customize
* previews.
* - Item coverage: monthlyMessages (metered), prepaidUsers,
* consumableWords, allocatedWorkflows.
*
* Implementation surface:
* server/src/internal/billing/v2/providers/stripe/utils/common/
* initStripeResourcesForProducts.ts — early returns via
* applyPreviewStripeResourcesToBillingPlan when dryRunStripe=true.
*/
import { expect, test } from "bun:test";
import type {
AttachPreviewResponse,
CreateScheduleParamsV0Input,
UpdateSubscriptionV1ParamsInput,
} from "@autumn/shared";
import {
expectNoCustomStripePrices,
expectNoStripeResources,
} from "@tests/integration/billing/misc/utils/expectNoStripeResources";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { ProductService } from "@/internal/products/ProductService";
const buildItems = () => [
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
];
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: previewAttach against a plan created with create_in_stripe=false
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright(
"previewAttach on no-stripe plan: preview totals correct, no Stripe IDs created",
)}`, async () => {
const customerId = "preview-no-stripe-attach";
const proPlan = products.pro({
id: "pro-no-stripe-attach",
items: buildItems(),
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan], createInStripe: false }),
],
actions: [],
});
// s.products mutates proPlan.id to include the `_${customerId}` prefix.
const preview = (await autumnV2_2.billing.previewAttach({
customer_id: customerId,
plan_id: proPlan.id,
})) as AttachPreviewResponse;
expect(preview.subtotal).toBe(20);
expect(preview.total).toBe(20);
expect(preview.currency.toLowerCase()).toBe("usd");
await expectNoStripeResources({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
productId: proPlan.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: preview_create_schedule with multiple phases on no-stripe plans
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright(
"preview_create_schedule multi-phase on no-stripe plans: preview works, no Stripe IDs created",
)}`, async () => {
const customerId = "preview-no-stripe-schedule";
const proPlan = products.pro({
id: "pro-no-stripe-schedule",
items: buildItems(),
});
const premiumPlan = products.premium({
id: "premium-no-stripe-schedule",
items: [items.monthlyMessages({ includedUsage: 500 })],
});
const { autumnV1, advancedTo, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proPlan, premiumPlan], createInStripe: false }),
],
actions: [],
});
const params: CreateScheduleParamsV0Input = {
customer_id: customerId,
phases: [
{
starts_at: advancedTo,
plans: [
{
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 35 }),
},
},
],
},
{
starts_at: advancedTo + 30 * 24 * 60 * 60 * 1000,
plans: [{ plan_id: premiumPlan.id }],
},
],
};
const preview = (await autumnV1.post(
"/billing.preview_create_schedule",
params,
)) as AttachPreviewResponse;
expect(preview.total).toBe(35);
expect(preview.subtotal).toBe(35);
await expectNoStripeResources({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
productId: proPlan.id,
});
await expectNoStripeResources({
db: ctx.db,
orgId: ctx.org.id,
env: ctx.env,
productId: premiumPlan.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: previewUpdate with customize on an active no-stripe sub. The customer
// is already on a no-stripe plan attached via /billing.preview_attach +
// /billing.preview_update is impossible — instead we attach the no-stripe plan
// via attach which would normally create Stripe resources. To avoid that, we
// rely on the contract that any sub created via real attach on a no-stripe
// plan still preview-cleanly. We attach a stripe-backed plan A so the customer
// has an active sub, then previewUpdate with customize.items shapes drawn from
// every payable category; the assertion is that no is_custom prices got
// persisted with Stripe IDs by the preview.
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright(
"previewUpdate customize on active sub: no is_custom prices with Stripe IDs created",
)}`, async () => {
const customerId = "preview-no-stripe-update";
const stripeBackedPlan = products.pro({
id: "pro-with-stripe-update",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [stripeBackedPlan] }),
],
actions: [s.billing.attach({ productId: stripeBackedPlan.id })],
});
const fullBefore = await ProductService.getFull({
db: ctx.db,
idOrInternalId: stripeBackedPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const params: UpdateSubscriptionV1ParamsInput = {
customer_id: customerId,
plan_id: stripeBackedPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 30 }),
},
};
const preview = await autumnV2_2.subscriptions.previewUpdate(params);
expect(typeof preview.total).toBe("number");
await expectNoCustomStripePrices({
db: ctx.db,
internalProductId: fullBefore.internal_id,
});
});

View File

@@ -0,0 +1,607 @@
/**
* Custom plans reuse the base plan's Stripe resources where possible.
*
* When a customer attaches a plan with `customize.items` (or `customize.price`),
* a new `is_custom: true` price row is written for each affected price. The
* carry-forward path in handleNewProductItems.carryForwardStripeResources
* (which calls copyStripeResourcesToMatchingPrice) MUST populate the new row's
* `stripe_*_id` fields from the matching catalog price so we don't mint
* duplicate Stripe Price objects.
*
* Contract under test:
* - Adding an unrelated boolean entitlement (dashboard) keeps every existing
* price's Stripe IDs intact.
* - Same for the paid feature shapes prepaid / consumable / allocated.
* - Negative: swapping prepaid → consumable on the same feature does NOT
* reuse stripe_price_id (different price.config.type / billing_method).
* - Negative: changing the price amount on a prepaid item does NOT reuse
* stripe_price_id (config differs → pricesAreSame=false → reuse level
* drops below "full").
* - Negative: changing tier amounts on a tiered prepaid item does NOT reuse
* stripe_price_id.
*
* Implementation surface:
* server/src/internal/products/product-items/productItemUtils/
* handleNewProductItems.ts — calls carryForwardStripeResources before
* persisting new prices.
* shared/utils/productUtils/priceUtils/match/
* copyStripeResourcesToMatchingPrice.ts + getPriceStripeReuseLevel.ts —
* the actual matching + copy logic.
* shared/utils/productUtils/priceUtils/match/priceStripeObjectsMatch.ts —
* boolean predicate used by the test helpers.
*/
import { test } from "bun:test";
import {
type AttachParamsV1Input,
BillingInterval,
BillingMethod,
OnDecrease,
OnIncrease,
RolloverExpiryDurationType,
TierBehavior,
TierInfinite,
} from "@autumn/shared";
import {
expectAllStripeIdsReused,
expectStripePriceIdNotReused,
loadCustomerAndCatalogPrices,
} from "@tests/integration/billing/misc/utils/findCatalogAndCustomPrices";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: custom plan adds a boolean entitlement, base price reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: add boolean entitlement → base price Stripe IDs reused")}`, async () => {
const customerId = "reuse-custom-boolean";
const proPlan = products.pro({
id: "pro-reuse-boolean",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.monthlyMessages({ included: 100 }),
itemsV2.dashboard(),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: custom plan keeps prepaid/consumable/allocated items → all reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: paid feature shapes unchanged → all Stripe IDs reused")}`, async () => {
const customerId = "reuse-custom-paid";
const proPlan = products.pro({
id: "pro-reuse-paid",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.monthlyMessages({ included: 100 }),
itemsV2.prepaidUsers({ amount: 10, billingUnits: 1 }),
itemsV2.consumableWords({ amount: 0.05 }),
itemsV2.allocatedWorkflows({ amount: 10 }),
itemsV2.dashboard(),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3 (negative): swap prepaid → consumable on same feature → no reuse
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: prepaid → consumable on same feature → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-prepaid-to-consumable";
const proPlan = products.pro({
id: "pro-reuse-prepaid-to-consumable",
items: [items.prepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [itemsV2.consumableMessages({ amount: 0.5 })],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs, catalogPrices, customerPrices } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({
pairs,
featureId: TestFeature.Messages,
catalogPrices,
customerPrices,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4 (negative): change prepaid price amount → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: prepaid amount change → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-prepaid-amount";
const proPlan = products.pro({
id: "pro-reuse-prepaid-amount",
items: [items.prepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [itemsV2.prepaidMessages({ amount: 25, billingUnits: 100 })],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5 (negative): change tier amounts on tiered prepaid → not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: tier amount change → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-tier";
const proPlan = products.pro({
id: "pro-reuse-tier",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.tieredPrepaidMessages({
tiers: [
{ to: 600, amount: 20 },
{ to: TierInfinite, amount: 10 },
],
}),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6 (negative): graduated → volume tier_behavior → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: graduated → volume tier_behavior → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-tier-behavior";
const proPlan = products.pro({
id: "pro-reuse-tier-behavior",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Messages,
included: 0,
price: {
tiers: [
{ to: 500, amount: 10 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7 (negative): add flat_amount to a tier → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: add flat_amount to tier → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-flat-amount";
const proPlan = products.pro({
id: "pro-reuse-flat-amount",
items: [items.volumePrepaidMessages({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Messages,
included: 0,
price: {
tiers: [
{ to: 500, amount: 10, flat_amount: 100 },
{ to: TierInfinite, amount: 5 },
],
tier_behavior: TierBehavior.VolumeBased,
interval: BillingInterval.Month,
billing_method: BillingMethod.Prepaid,
billing_units: 100,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 8 (negative): change proration_config on allocated → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: change proration_config on allocated → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-proration";
const proPlan = products.pro({
id: "pro-reuse-proration",
items: [items.allocatedWorkflows({ includedUsage: 0 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Workflows,
included: 0,
price: {
amount: 10,
interval: BillingInterval.Month,
billing_method: BillingMethod.UsageBased,
billing_units: 1,
},
proration: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.Prorate,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Workflows });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 9 (negative): change billing_units → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: change prepaid billing_units → stripe_price_id NOT reused")}`, async () => {
const customerId = "reuse-custom-billing-units";
const proPlan = products.pro({
id: "pro-reuse-billing-units",
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [itemsV2.prepaidMessages({ amount: 10, billingUnits: 50 })],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectStripePriceIdNotReused({ pairs, featureId: TestFeature.Messages });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 10 (positive): change rollover config on ent (base price unaffected)
// Rollover lives on the entitlement. monthlyMessagesWithRollover has no price,
// so the only paid line on this plan is the $20 base — which has no paired ent
// and thus is unaffected by ent rollover diffs. Asserts the base price still
// reuses all Stripe IDs across the customize.
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: change rollover config → base price Stripe IDs still reused")}`, async () => {
const customerId = "reuse-custom-rollover";
const proPlan = products.pro({
id: "pro-reuse-rollover",
items: [
items.monthlyMessagesWithRollover({
includedUsage: 200,
rolloverConfig: {
max: 100,
length: 0,
duration: RolloverExpiryDurationType.Forever,
},
}),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
{
feature_id: TestFeature.Messages,
included: 200,
rollover: {
max: 500,
expiry_duration_type: RolloverExpiryDurationType.Forever,
expiry_duration_length: 0,
},
},
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 11 (positive): prepaid + consumable pair on same feature → both reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("custom plan: prepaid + consumable pair on same feature → both Stripe IDs reused")}`, async () => {
const customerId = "reuse-custom-pair";
const proPlan = products.pro({
id: "pro-reuse-pair",
items: [
items.prepaidMessages({ includedUsage: 0, billingUnits: 100 }),
items.consumableMessages({ includedUsage: 0, price: 0.5 }),
],
});
const { autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: proPlan.id,
customize: {
price: itemsV2.monthlyPrice({ amount: 20 }),
items: [
itemsV2.prepaidMessages({ amount: 10, billingUnits: 100 }),
itemsV2.consumableMessages({ amount: 0.5 }),
itemsV2.dashboard(),
],
},
};
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const { pairs } = await loadCustomerAndCatalogPrices({
ctx,
customerId,
catalogProductId: proPlan.id,
});
expectAllStripeIdsReused({ pairs });
});

View File

@@ -0,0 +1,270 @@
/**
* Plan versioning reuses the previous version's Stripe resources where possible.
*
* When a plan with existing customers is updated with a new items list, the
* V1 update handler (handleUpdatePlanV1) auto-creates a new product version via
* handleVersionProductV2. That path calls handleNewProductItems with
* { curPrices: latestProduct.prices, newVersion: true }. The carry-forward
* inside handleNewProductItems must copy the previous version's
* `stripe_*_id` fields onto each new-version price whose config still matches.
*
* Contract under test:
* - Versioning a plan with the same paid items (just adding a boolean entitlement)
* keeps every paid price's Stripe IDs intact on the new version.
* - Same for paid feature shapes (prepaid / consumable / allocated).
* - Negative: versioning with a changed item (price amount, tier behavior,
* billing_units) does NOT reuse stripe_price_id for that item.
*
* Implementation surface:
* server/src/internal/products/handlers/handleVersionProduct.ts — versioning entry.
* server/src/internal/products/handlers/handleUpdatePlan/handleUpdatePlanV1.ts — triggers versioning when customers exist + items differ.
* server/src/internal/products/product-items/productItemUtils/handleNewProductItems.ts — calls carryForwardStripeResources.
*/
import { expect, test } from "bun:test";
import {
type ApiPlanItemV1,
type CreatePlanItemParamsV1,
BillingInterval,
BillingMethod,
type Price,
priceStripeObjectsMatch,
TierInfinite,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { ProductService } from "@/internal/products/ProductService";
const collectStripeIdsByFeatureKey = (
prices: Price[],
): Map<string, Record<string, string | null>> => {
const map = new Map<string, Record<string, string | null>>();
for (const price of prices) {
const config = price.config as Record<string, unknown>;
const featureId = (config.feature_id as string | undefined) ?? "__fixed__";
const billWhen = (config.bill_when as string | undefined) ?? "__none__";
const key = `${featureId}|${billWhen}`;
map.set(key, {
stripe_product_id: (config.stripe_product_id as string | null) ?? null,
stripe_price_id: (config.stripe_price_id as string | null) ?? null,
stripe_empty_price_id:
(config.stripe_empty_price_id as string | null) ?? null,
stripe_meter_id: (config.stripe_meter_id as string | null) ?? null,
stripe_prepaid_price_v2_id:
(config.stripe_prepaid_price_v2_id as string | null) ?? null,
stripe_placeholder_price_id:
(config.stripe_placeholder_price_id as string | null) ?? null,
});
}
return map;
};
const findPriceForFeature = (
prices: Price[],
featureId: string,
): Price | undefined =>
prices.find(
(price) =>
(price.config as Record<string, unknown>).feature_id === featureId,
);
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: version a plan with same paid items + new boolean → all reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("versioning: add boolean entitlement → all paid Stripe IDs reused on new version")}`, async () => {
const customerId = "reuse-version-add-bool";
const proPlan = products.pro({
id: "pro-version-add-bool",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [s.billing.attach({ productId: proPlan.id })],
});
const beforeProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const beforeIds = collectStripeIdsByFeatureKey(beforeProduct.prices);
const updatedItems = [
items.monthlyPrice({ price: 20 }),
items.monthlyMessages({ includedUsage: 100 }),
items.prepaidUsers({ billingUnits: 1 }),
items.consumableWords({ includedUsage: 0 }),
items.allocatedWorkflows({ includedUsage: 0 }),
items.dashboard(),
];
await autumnV1.products.update(proPlan.id, { items: updatedItems });
const afterProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
expect(afterProduct.version).toBe(beforeProduct.version + 1);
const afterIds = collectStripeIdsByFeatureKey(afterProduct.prices);
for (const [key, before] of beforeIds.entries()) {
const after = afterIds.get(key);
expect(after).toBeDefined();
if (!after) continue;
expect(before.stripe_price_id).not.toBeNull();
expect(after.stripe_product_id).toBe(before.stripe_product_id);
expect(after.stripe_price_id).toBe(before.stripe_price_id);
expect(after.stripe_empty_price_id).toBe(before.stripe_empty_price_id);
expect(after.stripe_meter_id).toBe(before.stripe_meter_id);
expect(after.stripe_prepaid_price_v2_id).toBe(
before.stripe_prepaid_price_v2_id,
);
expect(after.stripe_placeholder_price_id).toBe(
before.stripe_placeholder_price_id,
);
}
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2 (negative): versioning with prepaid amount change → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("versioning: prepaid amount change → stripe_price_id NOT reused on new version")}`, async () => {
const customerId = "reuse-version-amount-change";
const proPlan = products.pro({
id: "pro-version-amount-change",
items: [items.prepaidMessages({ includedUsage: 0, billingUnits: 100 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [s.billing.attach({ productId: proPlan.id })],
});
const beforeProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const beforeMessages = findPriceForFeature(
beforeProduct.prices,
TestFeature.Messages,
);
expect(beforeMessages).toBeDefined();
const updatedItems = [
items.monthlyPrice({ price: 20 }),
items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 25 }),
];
await autumnV1.products.update(proPlan.id, { items: updatedItems });
const afterProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
expect(afterProduct.version).toBe(beforeProduct.version + 1);
const afterMessages = findPriceForFeature(
afterProduct.prices,
TestFeature.Messages,
);
expect(afterMessages).toBeDefined();
if (!afterMessages || !beforeMessages) return;
const beforeConfig = beforeMessages.config as Record<string, unknown>;
const afterConfig = afterMessages.config as Record<string, unknown>;
expect(beforeConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id);
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3 (negative): versioning with tier_behavior change → stripe_price_id not reused
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("versioning: graduated → volume tier_behavior → stripe_price_id NOT reused on new version")}`, async () => {
const customerId = "reuse-version-tier-behavior";
const proPlan = products.pro({
id: "pro-version-tier-behavior",
items: [items.tieredPrepaidMessages({ includedUsage: 0 })],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [proPlan] }),
],
actions: [s.billing.attach({ productId: proPlan.id })],
});
const beforeProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
const beforeMessages = findPriceForFeature(
beforeProduct.prices,
TestFeature.Messages,
);
const updatedItems = [
items.monthlyPrice({ price: 20 }),
items.volumePrepaidMessages({ includedUsage: 0 }),
];
await autumnV1.products.update(proPlan.id, { items: updatedItems });
const afterProduct = await ProductService.getFull({
db: ctx.db,
idOrInternalId: proPlan.id,
orgId: ctx.org.id,
env: ctx.env,
});
expect(afterProduct.version).toBe(beforeProduct.version + 1);
const afterMessages = findPriceForFeature(
afterProduct.prices,
TestFeature.Messages,
);
expect(afterMessages).toBeDefined();
if (!afterMessages || !beforeMessages) return;
const beforeConfig = beforeMessages.config as Record<string, unknown>;
const afterConfig = afterMessages.config as Record<string, unknown>;
expect(beforeConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id ?? null).not.toBeNull();
expect(afterConfig.stripe_price_id).not.toBe(beforeConfig.stripe_price_id);
});

View File

@@ -0,0 +1,70 @@
import { expect } from "bun:test";
import { type AppEnv, type Price, prices } from "@autumn/shared";
import { eq } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
import { ProductService } from "@/internal/products/ProductService";
const stripeIdFields = [
"stripe_price_id",
"stripe_product_id",
"stripe_empty_price_id",
"stripe_meter_id",
"stripe_prepaid_price_v2_id",
] as const;
const expectPriceHasNoStripeIds = (price: Price) => {
const config = price.config as Record<string, unknown>;
for (const field of stripeIdFields) {
expect(config[field] ?? null).toBeNull();
}
};
export const expectNoStripeResources = async ({
db,
orgId,
env,
productId,
}: {
db: DrizzleCli;
orgId: string;
env: AppEnv;
productId: string;
}) => {
const fullProduct = await ProductService.getFull({
db,
idOrInternalId: productId,
orgId,
env,
});
expect(fullProduct.processor?.id ?? null).toBeNull();
for (const price of fullProduct.prices) {
expectPriceHasNoStripeIds(price);
}
const allPrices = (await db.query.prices.findMany({
where: eq(prices.internal_product_id, fullProduct.internal_id),
})) as Price[];
for (const price of allPrices) {
expectPriceHasNoStripeIds(price);
}
};
export const expectNoCustomStripePrices = async ({
db,
internalProductId,
}: {
db: DrizzleCli;
internalProductId: string;
}) => {
const customPrices = (await db.query.prices.findMany({
where: eq(prices.internal_product_id, internalProductId),
})) as Price[];
for (const price of customPrices) {
if (!price.is_custom) continue;
expectPriceHasNoStripeIds(price);
}
};

View File

@@ -0,0 +1,181 @@
import { expect } from "bun:test";
import {
type AppEnv,
type FullCusProduct,
type Price,
type UsagePriceConfig,
diffPriceStripeObjects,
isFixedPrice,
priceStripeObjectsMatch,
} from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusService } from "@/internal/customers/CusService";
import { ProductService } from "@/internal/products/ProductService";
const priceFeatureId = (price: Price): string | null => {
if (isFixedPrice(price)) return null;
const config = price.config as UsagePriceConfig;
return config.feature_id ?? null;
};
const priceMatchKey = (price: Price): string => {
if (isFixedPrice(price)) return "__fixed__";
const featureId = priceFeatureId(price);
const config = price.config as UsagePriceConfig;
const billWhen = config.bill_when ?? "<missing>";
return `feature:${featureId ?? "<missing>"}|bill_when:${billWhen}`;
};
/**
* Attach a customer's primary FullCusProduct (custom or otherwise) to the
* matching catalog plan's prices via feature_id (or "fixed" for base prices).
* Returns matched pairs and the catalog plan for further assertions.
*/
export const loadCustomerAndCatalogPrices = async ({
ctx,
customerId,
catalogProductId,
}: {
ctx: AutumnContext;
customerId: string;
catalogProductId: string;
}): Promise<{
catalogPrices: Price[];
customerPrices: Price[];
pairs: { catalog: Price; customer: Price }[];
cusProduct: FullCusProduct;
}> => {
const fullCustomer = await CusService.getFull({
ctx,
idOrInternalId: customerId,
});
const cusProduct = fullCustomer.customer_products[0];
if (!cusProduct) {
throw new Error(`Customer ${customerId} has no customer_products`);
}
const fullCatalog = await ProductService.getFull({
db: ctx.db,
idOrInternalId: catalogProductId,
orgId: ctx.org.id,
env: ctx.env,
});
const customerPrices = cusProduct.customer_prices.map(
(customerPrice) => customerPrice.price,
);
const pairs: { catalog: Price; customer: Price }[] = [];
for (const customerPrice of customerPrices) {
const key = priceMatchKey(customerPrice);
const catalogMatch = fullCatalog.prices.find(
(price) => priceMatchKey(price) === key,
);
if (!catalogMatch) continue;
pairs.push({ catalog: catalogMatch, customer: customerPrice });
}
return {
catalogPrices: fullCatalog.prices,
customerPrices,
pairs,
cusProduct,
};
};
const formatDiff = (catalog: Price, customer: Price): string => {
const diffs = diffPriceStripeObjects({
priceA: catalog,
priceB: customer,
});
return diffs
.map((diff) => `${diff.field}: catalog=${diff.a ?? "null"}, customer=${diff.b ?? "null"}`)
.join("\n ");
};
/**
* Assert every (catalog, customer) price pair shares all Stripe-object IDs.
* Each catalog price must also have a non-null stripe_price_id so the
* assertion is meaningful (verifies real reuse, not "both empty").
*/
export const expectAllStripeIdsReused = ({
pairs,
}: {
pairs: { catalog: Price; customer: Price }[];
}) => {
expect(pairs.length).toBeGreaterThan(0);
for (const { catalog, customer } of pairs) {
const catalogConfig = catalog.config as Record<string, unknown>;
expect(catalogConfig.stripe_price_id ?? null).not.toBeNull();
const matches = priceStripeObjectsMatch({
priceA: catalog,
priceB: customer,
});
if (!matches) {
throw new Error(
`Expected stripe-object reuse for ${priceMatchKey(catalog)} but got diffs:\n ${formatDiff(catalog, customer)}`,
);
}
}
};
/**
* Assert that the customer price keyed by `featureId` (or fixed base when
* `featureId` is null) does NOT reuse stripe_price_id from the catalog.
* Both prices must have non-null stripe_price_id values for the assertion
* to be meaningful. Falls back to feature-id-only matching when the strict
* (feature + bill_when) pairing misses (e.g. prepaid → consumable swap).
*/
export const expectStripePriceIdNotReused = ({
pairs,
featureId,
catalogPrices,
customerPrices,
}: {
pairs: { catalog: Price; customer: Price }[];
featureId: string | null;
catalogPrices?: Price[];
customerPrices?: Price[];
}) => {
let catalogPrice: Price | undefined;
let customerPrice: Price | undefined;
if (featureId === null) {
const pair = pairs.find(({ catalog }) => isFixedPrice(catalog));
catalogPrice = pair?.catalog;
customerPrice = pair?.customer;
} else {
const pair = pairs.find(
({ catalog }) => priceFeatureId(catalog) === featureId,
);
if (pair) {
catalogPrice = pair.catalog;
customerPrice = pair.customer;
} else if (catalogPrices && customerPrices) {
catalogPrice = catalogPrices.find(
(price) => priceFeatureId(price) === featureId,
);
customerPrice = customerPrices.find(
(price) => priceFeatureId(price) === featureId,
);
}
}
expect(catalogPrice).toBeDefined();
expect(customerPrice).toBeDefined();
if (!catalogPrice || !customerPrice) return;
const catalogConfig = catalogPrice.config as Record<string, unknown>;
const customerConfig = customerPrice.config as Record<string, unknown>;
expect(catalogConfig.stripe_price_id ?? null).not.toBeNull();
expect(customerConfig.stripe_price_id ?? null).not.toBeNull();
expect(customerConfig.stripe_price_id).not.toBe(
catalogConfig.stripe_price_id,
);
};
export { priceMatchKey };
// Re-export for callers
export type { DrizzleCli, AppEnv };

View File

@@ -52,6 +52,7 @@ export * from "./productUtils/priceUtils/index";
// Price match utils
export * from "./productUtils/priceUtils/match/copyStripeResourcesToMatchingPrice";
export * from "./productUtils/priceUtils/match/getPriceStripeReuseLevel";
export * from "./productUtils/priceUtils/match/priceStripeObjectsMatch";
export * from "./productV2Utils/mapToProductV2";
export * from "./productV2Utils/productItemUtils/classifyItemUtils";
export * from "./productV2Utils/productItemUtils/getItemType";

View File

@@ -36,7 +36,7 @@ export const entsAreSame = (ent1: Entitlement, ent2: Entitlement) => {
ent1.allowance_type !== AllowanceType.Unlimited &&
ent1.allowance != ent2.allowance,
carryFromPrevious: ent1.carry_from_previous != ent2.carry_from_previous,
entityFeatureId: ent1.entity_feature_id !== ent2.entity_feature_id,
entityFeatureId: ent1.entity_feature_id != ent2.entity_feature_id,
usageLimit: ent1.usage_limit != ent2.usage_limit,
rollover: !rolloversAreSame({
rollover1: ent1.rollover,

View File

@@ -49,6 +49,7 @@ export const copyStripeResourcesToMatchingPrice = ({
candidateEntitlements,
}: {
targetPrice: Price;
// Stripe IDs are copied FROM the best-matching candidate TO targetPrice.
candidatePrices: Price[];
targetEntitlements: Entitlement[];
candidateEntitlements: Entitlement[];

View File

@@ -0,0 +1,69 @@
import type { Price } from "@autumn/shared";
const stripeResourceFields = [
"stripe_product_id",
"stripe_price_id",
"stripe_empty_price_id",
"stripe_placeholder_price_id",
"stripe_prepaid_price_v2_id",
"stripe_meter_id",
"stripe_event_name",
] as const;
export type PriceStripeObjectField = (typeof stripeResourceFields)[number];
const readField = (price: Price, field: PriceStripeObjectField): string | null => {
const config = price.config as Partial<Record<PriceStripeObjectField, string | null>>;
return config[field] ?? null;
};
/**
* True iff every Stripe-resource field that initStripeResourcesForBillingPlan
* cares about (`stripe_product_id`, `stripe_price_id`, `stripe_empty_price_id`,
* `stripe_placeholder_price_id`, `stripe_prepaid_price_v2_id`,
* `stripe_meter_id`, `stripe_event_name`) is identical between the two prices.
*
* Used by stripe-reuse coverage to assert that a versioned / custom price
* carried the original plan's Stripe resources forward instead of minting
* fresh ones.
*/
export const priceStripeObjectsMatch = ({
priceA,
priceB,
}: {
priceA: Price;
priceB: Price;
}): boolean => {
for (const field of stripeResourceFields) {
if (readField(priceA, field) !== readField(priceB, field)) return false;
}
return true;
};
/**
* Returns the list of Stripe-resource fields whose values differ between
* `priceA` and `priceB`. Useful for surfacing why a reuse assertion failed.
*/
export const diffPriceStripeObjects = ({
priceA,
priceB,
}: {
priceA: Price;
priceB: Price;
}): {
field: PriceStripeObjectField;
a: string | null;
b: string | null;
}[] => {
const diffs: {
field: PriceStripeObjectField;
a: string | null;
b: string | null;
}[] = [];
for (const field of stripeResourceFields) {
const a = readField(priceA, field);
const b = readField(priceB, field);
if (a !== b) diffs.push({ field, a, b });
}
return diffs;
};

View File

@@ -1,10 +1,5 @@
import type { ReactNode } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useAdmin } from "@/views/admin/hooks/useAdmin";
import { AdminHover } from "@/components/general/AdminHover";
export type AdminPlanIds = {
stripe_price_id?: string | null;
@@ -12,22 +7,6 @@ export type AdminPlanIds = {
internal_product_id?: string | null;
};
const Row = ({ label, value }: { label: string; value: string | null | undefined }) => {
if (!value) return null;
return (
<div className="flex flex-col gap-0.5">
<span className="text-[10px] uppercase tracking-wide text-t4">
{label}
</span>
<code className="text-xs font-mono text-t1 break-all">{value}</code>
</div>
);
};
/**
* Wraps a child with an admin-only hover tooltip showing identifying IDs
* for the displayed plan/price. No-op for non-admin users.
*/
export const AdminPlanIdsTooltip = ({
children,
ids,
@@ -35,28 +14,22 @@ export const AdminPlanIdsTooltip = ({
children: ReactNode;
ids: AdminPlanIds;
}) => {
const { isAdmin } = useAdmin();
const texts = [
ids.stripe_price_id && {
key: "Stripe price id",
value: ids.stripe_price_id,
},
ids.stripe_product_id && {
key: "Stripe product id",
value: ids.stripe_product_id,
},
ids.internal_product_id && {
key: "Autumn internal id",
value: ids.internal_product_id,
},
].filter(Boolean) as { key: string; value: string }[];
const hasAnyId = Boolean(
ids.stripe_price_id || ids.stripe_product_id || ids.internal_product_id,
);
if (texts.length === 0) return <>{children}</>;
if (!isAdmin || !hasAnyId) {
return <>{children}</>;
}
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="bottom"
align="start"
className="flex flex-col gap-2 max-w-sm"
>
<Row label="Stripe price id" value={ids.stripe_price_id} />
<Row label="Stripe product id" value={ids.stripe_product_id} />
<Row label="Autumn internal id" value={ids.internal_product_id} />
</TooltipContent>
</Tooltip>
);
return <AdminHover texts={texts}>{children}</AdminHover>;
};