feat: multi attach same add on twice, diff customize options
This commit is contained in:
2
bun.lock
2
bun.lock
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -72,6 +72,7 @@ export const stripeItemSpecToCheckoutLineItem = ({
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
quantity: spec.quantity,
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
) => {
|
||||
|
||||
Reference in New Issue
Block a user