feat: multi attach same add on twice, diff customize options

This commit is contained in:
John Yeo
2026-03-02 11:03:32 +00:00
parent eb47070375
commit ad9fb25537
17 changed files with 863 additions and 102 deletions

View File

@@ -101,7 +101,7 @@
},
"packages/autumn-js": {
"name": "autumn-js",
"version": "1.0.0-beta.5",
"version": "1.0.0-beta.6",
"dependencies": {
"query-string": "^9.2.2",
"rou3": "^0.6.1",

View File

@@ -39,9 +39,15 @@ export const updateOptionsFromStripeCheckoutSession = async ({
// Entity-scoped products use inline prices with pre-calculated amounts;
// the checkout line item quantity is not meaningful, so keep original options.
if (isCustomerProductEntityScoped(newCustomerProduct)) {
continue;
}
if (isCustomerProductEntityScoped(newCustomerProduct)) continue;
const lineItem = stripeCheckoutSessionUtils.find.lineItemByAutumnPrice({
lineItems: stripeCheckoutSession.line_items?.data ?? [],
price,
product: fullProduct,
});
if (lineItem?.metadata?.inline_price) continue;
const featureOptionsQuantity =
stripeCheckoutSessionUtils.convert.toFeatureOptionsQuantity({

View File

@@ -1,6 +1,5 @@
import type { MultiAttachBillingContext } from "@autumn/shared";
import { handleMultiAttachCurrentProductErrors } from "./handleMultiAttachCurrentProductErrors";
import { handleMultiAttachPrepaidErrors } from "./handleMultiAttachPrepaidErrors";
import { handleMultiAttachRedirectErrors } from "./handleMultiAttachRedirectErrors";
/**
@@ -17,10 +16,6 @@ export const handleMultiAttachErrors = ({
productContexts: billingContext.productContexts,
});
handleMultiAttachPrepaidErrors({
productContexts: billingContext.productContexts,
});
handleMultiAttachRedirectErrors({
redirectMode,
stripeSubscription: billingContext.stripeSubscription,

View File

@@ -1,46 +0,0 @@
import {
isPrepaidPrice,
type MultiAttachProductContext,
priceToEnt,
RecaseError,
} from "@autumn/shared";
/**
* Validates that no two plans in a multi-attach share the same prepaid feature.
* Duplicate prepaid features across plans would cause conflicting quantity tracking.
*/
export const handleMultiAttachPrepaidErrors = ({
productContexts,
}: {
productContexts: MultiAttachProductContext[];
}) => {
const seenFeatures = new Map<string, string>(); // featureId -> planId
for (const productContext of productContexts) {
const { fullProduct } = productContext;
for (const price of fullProduct.prices) {
if (!isPrepaidPrice(price)) continue;
const entitlement = priceToEnt({
price,
entitlements: fullProduct.entitlements,
errorOnNotFound: false,
});
if (!entitlement) continue;
const featureId = entitlement.feature.id;
const existingPlanId = seenFeatures.get(featureId);
if (existingPlanId) {
throw new RecaseError({
message: `Feature "${featureId}" has prepaid pricing in both plan "${existingPlanId}" and plan "${fullProduct.id}". Multi-attach does not support the same prepaid feature across multiple plans.`,
statusCode: 400,
});
}
seenFeatures.set(featureId, fullProduct.id);
}
}
};

View File

@@ -24,11 +24,13 @@ export const cusPriceToStripeItemSpec = ({
cusPrice,
cusProduct,
billingContext,
options,
}: {
ctx: AutumnContext;
cusPrice: FullCustomerPrice;
cusProduct: FullCusProduct;
billingContext?: BillingContext;
options?: { isDuplicateProductId?: boolean };
}): StripeItemSpec | null => {
const price = cusPrice.price;
@@ -54,6 +56,7 @@ export const cusPriceToStripeItemSpec = ({
spec = prepaidToStripeItemSpec({
ctx,
cusEntWithCusProduct,
options,
});
}
@@ -78,6 +81,7 @@ export const cusPriceToStripeItemSpec = ({
spec.metadata = {
autumn_price_id: price.id,
autumn_customer_price_id: cusPrice.id,
...(spec.metadata ?? {}),
};
return spec;

View File

@@ -20,14 +20,22 @@ import { cusEntToInlineStripePrice } from "./cusEntToInlineStripePrice";
export const prepaidToStripeItemSpec = ({
ctx,
cusEntWithCusProduct,
options,
}: {
ctx: AutumnContext;
cusEntWithCusProduct: FullCusEntWithFullCusProduct;
options?: { isDuplicateProductId?: boolean };
}): StripeItemSpec | null => {
const billing = cusEntToBillingObjects({ cusEnt: cusEntWithCusProduct });
if (!billing) return null;
const { cusProduct, price, product, entitlement, options } = billing;
const {
cusProduct,
price,
product,
entitlement,
options: featureOptions,
} = billing;
if (!isPrepaidPrice(price)) {
throw new InternalError({
@@ -39,7 +47,7 @@ export const prepaidToStripeItemSpec = ({
const isEntityScoped = notNullish(cusProduct.internal_entity_id);
const isTieredOneOff = priceUtils.isTieredOneOff({ price, product });
if (isEntityScoped || isTieredOneOff) {
if (isEntityScoped || isTieredOneOff || options?.isDuplicateProductId) {
const inlinePrice = cusEntToInlineStripePrice({
cusEnt: cusEntWithCusProduct,
org: ctx.org,
@@ -52,6 +60,9 @@ export const prepaidToStripeItemSpec = ({
autumnEntitlement: entitlement,
autumnProduct: product,
autumnCusEnt: cusEntWithCusProduct,
metadata: {
inline_price: "true",
},
};
}
@@ -62,7 +73,7 @@ export const prepaidToStripeItemSpec = ({
}
const quantity = featureOptionUtils.convert.toV2StripeQuantity({
featureOptions: options ?? undefined,
featureOptions: featureOptions ?? undefined,
price,
entitlement,
});

View File

@@ -1,7 +1,8 @@
import type {
BillingContext,
FullCusProduct,
StripeItemSpec,
import {
type BillingContext,
customerProductsHaveDuplicateProductId,
type FullCusProduct,
type StripeItemSpec,
} from "@autumn/shared";
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
@@ -10,6 +11,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
* Converts customer products to recurring stripe item specs.
* Deduplicates stored-price items by stripePriceId (accumulating quantities).
* Entity-scoped inline items are never deduplicated — each entity gets its own item.
* Duplicate add-on products use inline prices so each instance gets independent tier calculations.
*/
export const customerProductsToRecurringStripeItemSpecs = ({
ctx,
@@ -24,10 +26,16 @@ export const customerProductsToRecurringStripeItemSpecs = ({
const inlineSpecs: StripeItemSpec[] = [];
for (const customerProduct of customerProducts) {
const isDuplicateProductId = customerProductsHaveDuplicateProductId({
customerProducts,
productId: customerProduct.product.id,
});
const { recurringItems } = customerProductToStripeItemSpecs({
ctx,
billingContext,
customerProduct,
options: { isDuplicateProductId },
});
for (const item of recurringItems) {

View File

@@ -72,6 +72,7 @@ export const stripeItemSpecToCheckoutLineItem = ({
return {
...toPriceParam({ spec }),
quantity: spec.quantity,
...(spec.metadata && { metadata: spec.metadata }),
};
};

View File

@@ -15,10 +15,12 @@ export const customerProductToStripeItemSpecs = ({
ctx,
customerProduct,
billingContext,
options,
}: {
ctx: AutumnContext;
customerProduct: FullCusProduct;
billingContext?: BillingContext;
options?: { isDuplicateProductId?: boolean };
}): {
recurringItems: StripeItemSpec[];
oneOffItems: StripeItemSpec[];
@@ -32,6 +34,7 @@ export const customerProductToStripeItemSpecs = ({
cusPrice,
cusProduct: customerProduct,
billingContext,
options,
});
if (!spec) continue;

View File

@@ -1,5 +1,6 @@
import type { BillingContext, StripeDiscountWithCoupon } from "@autumn/shared";
import {
customerProductsHaveDuplicateProductId,
type FullCusProduct,
msToSeconds,
truncateMsToSecondPrecision,
@@ -45,10 +46,16 @@ const customerProductsToPhaseItems = ({
const inlineItems: Stripe.SubscriptionScheduleUpdateParams.Phase.Item[] = [];
for (const customerProduct of customerProducts) {
const isDuplicateProductId = customerProductsHaveDuplicateProductId({
customerProducts,
productId: customerProduct.product.id,
});
const { recurringItems } = customerProductToStripeItemSpecs({
ctx,
customerProduct,
billingContext,
options: { isDuplicateProductId },
});
for (const item of recurringItems) {

View File

@@ -0,0 +1,224 @@
/**
* Stripe Checkout Prepaid Add-On Tests
*
* Tests:
* 1. First purchase of a tiered prepaid add-on via Stripe Checkout,
* then second purchase via subscription update (direct attach with PM on file).
* 2. Checkout prepaid add-on, then update quantity.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Test 1: Checkout prepaid add-on, then attach same add-on again
//
// Scenario:
// - Main plan: Pro ($20/mo) with 100 monthly messages (pre-attached with PM)
// - Add-on: tiered prepaid messages (100 included, $10/100 units)
// - First add-on: via Stripe Checkout (no PM on customer initially)
// - Second add-on: direct attach (PM now on file from checkout)
//
// Expected:
// - Main + 2x add-on active
// - Messages balance: 100 (main) + 200 (addon 1) + 300 (addon 2) = 600
// - Stripe subscription has independent inline items for each add-on
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("stripe checkout prepaid addon: checkout first, then direct attach second")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
const prepaidAddon = products.base({
id: "prepaid-addon",
isAddOn: true,
items: [
items.tieredPrepaidMessages({
includedUsage: 100,
billingUnits: 100,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
}),
],
});
// Set up main plan with PM, but we'll do the first add-on via checkout
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "co-prepaid-addon-then-direct",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [s.billing.attach({ productId: mainPlan.id })],
});
// First add-on via checkout (redirect_mode: "always")
const checkoutResult = await autumnV1.billing.attach(
{
customer_id: customerId,
plan_id: prepaidAddon.id,
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "always",
},
{ timeout: 0 },
);
expect(checkoutResult.payment_url).toBeDefined();
expect(checkoutResult.payment_url).toContain("checkout.stripe.com");
await completeStripeCheckoutForm({ url: checkoutResult.payment_url });
await timeout(12000);
// Verify first add-on is attached
const customerAfterFirst =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerAfterFirst,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 100 (main) + 200 (addon 1) = 300
expectCustomerFeatureCorrect({
customer: customerAfterFirst,
featureId: TestFeature.Messages,
balance: 300,
});
// Second add-on: direct attach (PM now on file)
const secondAttachParams = {
customer_id: customerId,
plan_id: prepaidAddon.id,
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 300 }],
};
const preview = await autumnV1.billing.previewAttach(secondAttachParams);
// (300 - 100 included) / 100 = 2 packs @ $10 = $20
expect(preview.total).toEqual(20);
await autumnV1.billing.attach(secondAttachParams);
// Verify final state
const customerFinal = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer: customerFinal,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 100 (main) + 200 (addon 1) + 300 (addon 2) = 600
expectCustomerFeatureCorrect({
customer: customerFinal,
featureId: TestFeature.Messages,
balance: 600,
});
// Stripe subscription should have 2 separate inline items for add-ons
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// Test 2: Checkout prepaid add-on, then update quantity
//
// Scenario:
// - Main plan: Pro ($20/mo) attached with PM
// - Add-on: tiered prepaid messages (100 included, $10/100 units)
// - Attach via checkout with quantity 200 (paid = 200 - 100 = 100)
// - Update subscription to change quantity to 500 (paid = 500 - 100 = 400)
//
// Expected:
// - Messages balance reflects updated quantity
// - Stripe subscription updated correctly
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("stripe checkout prepaid addon: checkout then update quantity")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
const prepaidAddon = products.base({
id: "prepaid-addon",
isAddOn: true,
items: [
items.tieredPrepaidMessages({
includedUsage: 100,
billingUnits: 100,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
}),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "co-prepaid-addon-update-qty",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [s.billing.attach({ productId: mainPlan.id })],
});
// Attach add-on via checkout
const checkoutResult = await autumnV1.billing.attach(
{
customer_id: customerId,
plan_id: prepaidAddon.id,
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "always",
},
{ timeout: 0 },
);
expect(checkoutResult.payment_url).toBeDefined();
await completeStripeCheckoutForm({ url: checkoutResult.payment_url });
await timeout(12000);
// Verify initial state
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 300, // 100 (main) + 200 (addon)
});
// Update quantity from 200 to 500
const updateParams = {
customer_id: customerId,
product_id: prepaidAddon.id,
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 500 }],
};
const updatePreview =
await autumnV1.subscriptions.previewUpdate(updateParams);
// Old: (200 - 100) / 100 = 1 pack @ $10 = $10
// New: (500 - 100) / 100 = 4 packs @ $10 = $40
// Delta: $40 - $10 = $30
expect(updatePreview.total).toEqual(30);
await autumnV1.subscriptions.update(updateParams);
// Verify updated state
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Messages: 100 (main) + 500 (updated addon) = 600
expectCustomerFeatureCorrect({
customer: customerAfter,
featureId: TestFeature.Messages,
balance: 600,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,198 @@
/**
* Attach Prepaid Add-On Tests
*
* Tests attaching a tiered prepaid add-on product (with included usage),
* then attaching the same add-on again. The second attach should create
* a second customer product with independent tier calculations.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Test 1: Attach tiered prepaid add-on, then attach the same one again
//
// Scenario:
// - Main plan: Pro ($20/mo) with 100 monthly messages
// - Add-on: tiered prepaid messages (100 included, $10/pack graduated)
// - First attach: add-on with quantity 200 (paid = 200 - 100 = 100)
// - Second attach: same add-on with quantity 300 (paid = 300 - 100 = 200)
//
// Expected:
// - Main + 2x add-on active
// - Messages: 100 (main) + 200 (addon 1) + 300 (addon 2) = 600
// - 2 invoices for the add-on attaches (+ 1 for main = 3 total)
// - Stripe subscription has 2 separate inline items for the add-ons
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("attach prepaid addon: attach tiered prepaid add-on twice")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
// Tiered prepaid add-on: 100 included, then $10/100 units graduated
const prepaidAddon = products.base({
id: "prepaid-addon",
isAddOn: true,
items: [
items.tieredPrepaidMessages({
includedUsage: 100,
billingUnits: 100,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
}),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "attach-prepaid-addon-twice",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [
s.billing.attach({ productId: mainPlan.id }),
s.billing.attach({
productId: prepaidAddon.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
// First add-on already attached in setup. Now attach the same one again.
const attachParams = {
customer_id: customerId,
plan_id: prepaidAddon.id,
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 300 }],
};
// 1. Preview second add-on
// (300 - 100 included) / 100 = 2 packs @ $10 = $20
const preview = await autumnV1.billing.previewAttach(attachParams);
expect(preview.total).toEqual(20);
// 2. Attach second instance
await autumnV1.billing.attach(attachParams);
// 3. Verify
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 100 (main) + 200 (addon 1) + 300 (addon 2) = 600
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 600,
});
// Invoices: 1 (main) + 1 (first addon) + 1 (second addon) = 3
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 20,
});
// Stripe subscription should have separate inline items for each add-on
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
});
// ═══════════════════════════════════════════════════════════════════
// Test 2: Attach flat prepaid add-on twice, verify quantities don't stack
//
// Scenario:
// - Main plan: Pro ($20/mo) with 100 monthly messages
// - Add-on: simple flat prepaid messages ($10/100 units, no included usage)
// - First attach: 200 messages
// - Second attach: 400 messages
//
// Expected:
// - Both add-on instances active
// - Messages: 100 + 200 + 400 = 700
// - Stripe subscription has 2 inline items (not 1 merged item with qty 6)
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("attach prepaid addon: flat prepaid add-on twice")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
const prepaidAddon = products.base({
id: "flat-addon",
isAddOn: true,
items: [
items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
}),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "attach-flat-addon-twice",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [
s.billing.attach({ productId: mainPlan.id }),
s.billing.attach({
productId: prepaidAddon.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
const attachParams = {
customer_id: customerId,
plan_id: prepaidAddon.id,
feature_quantities: [{ feature_id: TestFeature.Messages, quantity: 400 }],
};
// 1. Preview: 400/100 = 4 packs @ $10 = $40
const preview = await autumnV1.billing.previewAttach(attachParams);
expect(preview.total).toEqual(40);
// 2. Attach
await autumnV1.billing.attach(attachParams);
// 3. Verify
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 100 (main) + 200 (addon 1) + 400 (addon 2) = 700
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 700,
});
// Invoices: 1 (main) + 1 (first addon) + 1 (second addon) = 3
await expectCustomerInvoiceCorrect({
customer,
count: 3,
latestTotal: 40,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,117 @@
/**
* Multi-Attach Same Add-On Basic Tests
*
* Tests attaching 2x the same add-on product in a single multi-attach call,
* with different feature_quantities for each instance.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Test 1: 2x same tiered prepaid add-on with distinct quantities
//
// Scenario:
// - Main plan: Pro ($20/mo) with 500 monthly messages
// - Add-on: tiered prepaid messages (100 included, $10/pack after)
// - Instance A: quantity 200 messages
// - Instance B: quantity 500 messages
//
// Expected:
// - Pro + 2x add-on instances active
// - Messages: 500 (main) + 300 (A: 100 included + 200 purchased) + 600 (B: 100 included + 500 purchased) = 1400
// - Invoice = $20 (pro) + tiered cost A + tiered cost B
// - Stripe subscription has 2 separate inline items (independent tier calculations)
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("multi-attach same add-on: 2x same tiered prepaid add-on with distinct quantities")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
// Add-on with tiered prepaid: 100 included, then $10/100 units, $5/100 after 500
const prepaidAddon = products.base({
id: "prepaid-addon",
isAddOn: true,
items: [
items.tieredPrepaidMessages({
includedUsage: 100,
billingUnits: 100,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
}),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "ma-same-addon-basic",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [s.billing.attach({ productId: mainPlan.id })],
});
const multiAttachParams = {
customer_id: customerId,
plans: [
{
plan_id: prepaidAddon.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: 200 },
],
},
{
plan_id: prepaidAddon.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: 500 },
],
},
],
};
// 1. Preview
// Instance A: 100 purchased / 100 billing units = 2 packs @ $10 = $10
// Instance B: 400 purchased / 100 billing units = 4 packs @ $10 = $40
// Total: $10 + $40 = $50
const preview = await autumnV1.billing.previewMultiAttach(multiAttachParams);
expect(preview.total).toEqual(50);
// 2. Attach
await autumnV1.billing.multiAttach(multiAttachParams);
// 3. Verify
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 500 (main) + 200 (addon A) + 500 (addon B) = 1200
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 1200,
});
// Invoice: $20 (pro base from initial attach) + latest should be the multi-attach invoice
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 50,
});
// Stripe subscription must have separate inline items for each add-on
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,130 @@
/**
* Multi-Attach Same Add-On Checkout Tests
*
* Tests attaching 2x the same add-on product via Stripe Checkout,
* with different feature_quantities for each instance.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Test 1: 2x same tiered prepaid add-on via checkout
//
// Scenario:
// - No payment method (forces Stripe Checkout)
// - Main plan: Pro ($20/mo)
// - Add-on: tiered prepaid messages (100 included, $10/100 units)
// - Multi-attach: main plan + 2x add-on with different quantities
//
// Expected:
// - Checkout URL returned
// - After checkout: all products active
// - Messages balance correct
// - Stripe subscription has inline items for each add-on
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("multi-attach checkout same add-on: 2x tiered prepaid add-on via checkout")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 200 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
const prepaidAddon = products.base({
id: "prepaid-addon",
isAddOn: true,
items: [
items.tieredPrepaidMessages({
includedUsage: 100,
billingUnits: 100,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
}),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "ma-co-same-addon-tiered",
setup: [
s.customer({ testClock: true }), // No payment method → checkout
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [],
});
const multiAttachParams = {
customer_id: customerId,
plans: [
{ plan_id: mainPlan.id },
{
plan_id: prepaidAddon.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: 200 },
],
},
{
plan_id: prepaidAddon.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: 300 },
],
},
],
};
// Preview:
// Pro base = $20
// A: (200 - 100 included) / 100 = 1 pack @ $10 = $10
// B: (300 - 100 included) / 100 = 2 packs @ $10 = $20
// Total: $20 + $10 + $20 = $50
const preview = await autumnV1.billing.previewMultiAttach(multiAttachParams);
expect(preview.total).toEqual(50);
const result = await autumnV1.billing.multiAttach(multiAttachParams, {
timeout: 0,
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
// Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// Verify
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 200 (main) + 200 (addon A) + 300 (addon B) = 700
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 700,
});
// Invoice: $20 (pro) + addon A cost + addon B cost
// A: (200 - 100 included) / 100 = 1 pack @ $10 = $10
// B: (300 - 100 included) / 100 = 2 packs @ $10 = $20
// Total: $20 + $10 + $20 = $50
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 50,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,128 @@
/**
* Multi-Attach Same Add-On Customize Tests
*
* Tests attaching 2x the same add-on product in a single multi-attach call,
* with different customized prepaid feature prices on each instance.
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, BillingInterval } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════
// Test 1: 2x same add-on with custom prepaid pricing per instance
//
// Scenario:
// - Main plan: Pro ($20/mo) with messages
// - Add-on: tiered prepaid messages (100 included, $10/100 units after)
// - Attach add-on twice with different custom prices:
// Instance A: custom price $15/100 units, quantity 200
// Instance B: custom price $25/100 units, quantity 300
//
// Expected:
// - Pro + 2x add-on active
// - Messages balance = 500 + 100 + 100 = 700 (200 + 300 purchased, 100 included per addon)
// - Invoice = $20 (pro base) + custom charges for each add-on
// - Stripe subscription has separate inline items for each add-on
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("multi-attach same add-on customize: 2x same add-on with different custom prepaid prices")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const mainPlan = products.pro({ id: "main", items: [messagesItem] });
// Add-on with tiered prepaid messages: 100 included, then $10/pack (100 units/pack)
const prepaidAddon = products.base({
id: "prepaid-addon",
isAddOn: true,
items: [
items.tieredPrepaidMessages({
includedUsage: 100,
billingUnits: 100,
tiers: [
{ to: 500, amount: 10 },
{ to: "inf", amount: 5 },
],
}),
],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "ma-same-addon-customize",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [mainPlan, prepaidAddon] }),
],
actions: [s.billing.attach({ productId: mainPlan.id })],
});
const multiAttachParams = {
customer_id: customerId,
plans: [
{
plan_id: prepaidAddon.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: 200 },
],
customize: {
price: {
amount: 15,
interval: BillingInterval.Month,
},
},
},
{
plan_id: prepaidAddon.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: 300 },
],
customize: {
price: {
amount: 25,
interval: BillingInterval.Month,
},
},
},
],
};
// 1. Preview
const preview = await autumnV1.billing.previewMultiAttach(multiAttachParams);
// Instance A: $15 custom price, Instance B: $25 custom price
expect(preview.total).toBeCloseTo(40, 0);
// 2. Attach
await autumnV1.billing.multiAttach(multiAttachParams);
// 3. Verify
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Both add-on instances should be active alongside main plan
await expectCustomerProducts({
customer,
active: [mainPlan.id, prepaidAddon.id],
});
// Messages: 500 (main) + 100 (addon A included) + 200 (addon A purchased) + 100 (addon B included) + 300 (addon B purchased)
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 1200,
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 40,
});
// Stripe subscription should have separate inline items for each add-on
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -101,45 +101,7 @@ test.concurrent(`${chalk.yellowBright("multi-attach error: multiple transitions
});
// ═══════════════════════════════════════════════════════════════════
// Test 3: Cannot multi-attach two plans with the same prepaid feature
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("multi-attach error: duplicate prepaid feature across plans")}`, async () => {
const planA = products.pro({
id: "plan-a",
items: [items.prepaidMessages({ includedUsage: 100, price: 5 })],
});
const planB = products.base({
id: "plan-b",
items: [
items.prepaidMessages({ includedUsage: 200, price: 10 }),
items.monthlyPrice({ price: 15 }),
],
group: "group-b",
});
const { customerId, autumnV1 } = await initScenario({
customerId: "ma-err-dup-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [planA, planB] }),
],
actions: [],
});
await expectAutumnError({
errMessage: "prepaid pricing in both plan",
func: async () => {
await autumnV1.billing.multiAttach({
customer_id: customerId,
plans: [{ plan_id: planA.id }, { plan_id: planB.id }],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════
// Test 4: redirect_mode "always" with existing subscription → error
// Test 3: redirect_mode "always" with existing subscription → error
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("multi-attach error: redirect always with existing subscription")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
@@ -181,7 +143,7 @@ test.concurrent(`${chalk.yellowBright("multi-attach error: redirect always with
});
// ═══════════════════════════════════════════════════════════════════
// Test 5: redirect_mode "always" on entity without new_billing_sub → error
// Test 4: redirect_mode "always" on entity without new_billing_sub → error
// ═══════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("multi-attach error: redirect always on entity without new_billing_sub")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });

View File

@@ -219,6 +219,19 @@ export const customerProductHasSubscription = (cusProduct?: FullCusProduct) => {
return notNullish(subId);
};
/** Returns true if multiple customer products share the given product ID. */
export const customerProductsHaveDuplicateProductId = ({
customerProducts,
productId,
}: {
customerProducts: FullCusProduct[];
productId: string;
}): boolean => {
return (
customerProducts.filter((cp) => cp.product.id === productId).length > 1
);
};
export const isCustomerProductEntityScoped = (
customerProduct?: FullCusProduct,
) => {