diff --git a/bun.lock b/bun.lock index ed2a8d7dc..fc7274a82 100644 --- a/bun.lock +++ b/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", diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts index c569ed023..71fd13abd 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateOptionsFromStripeCheckoutSession.ts @@ -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({ diff --git a/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts b/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts index a141a625d..aaa0c1d87 100644 --- a/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts +++ b/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachErrors.ts @@ -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, diff --git a/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachPrepaidErrors.ts b/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachPrepaidErrors.ts deleted file mode 100644 index 08dc33d08..000000000 --- a/server/src/internal/billing/v2/actions/multiAttach/errors/handleMultiAttachPrepaidErrors.ts +++ /dev/null @@ -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(); // 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); - } - } -}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts index 61a88964d..fd7f6b409 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/cusPriceToStripeItemSpec.ts @@ -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; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts index 5a60e4ba8..be7a5fbda 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/prepaidToStripeItemSpec.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts index 935d18b86..bd039b557 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/customerProductsToRecurringStripeItemSpecs.ts @@ -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) { diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts index 7b34d99e6..21aee37f3 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts @@ -72,6 +72,7 @@ export const stripeItemSpecToCheckoutLineItem = ({ return { ...toPriceParam({ spec }), quantity: spec.quantity, + ...(spec.metadata && { metadata: spec.metadata }), }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts index 7518a0d40..20ee46df8 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts @@ -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; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts index 894978533..313d9c237 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts @@ -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) { diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-addon.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-addon.test.ts new file mode 100644 index 000000000..26471353f --- /dev/null +++ b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-addon.test.ts @@ -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(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(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(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(customerId); + + // Messages: 100 (main) + 500 (updated addon) = 600 + expectCustomerFeatureCorrect({ + customer: customerAfter, + featureId: TestFeature.Messages, + balance: 600, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-addon.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-addon.test.ts new file mode 100644 index 000000000..f27140f70 --- /dev/null +++ b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-addon.test.ts @@ -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(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(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 }); +}); diff --git a/server/tests/integration/billing/multi-attach/basic/multi-attach-same-addons.test.ts b/server/tests/integration/billing/multi-attach/basic/multi-attach-same-addons.test.ts new file mode 100644 index 000000000..9eedeb89d --- /dev/null +++ b/server/tests/integration/billing/multi-attach/basic/multi-attach-same-addons.test.ts @@ -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(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 }); +}); diff --git a/server/tests/integration/billing/multi-attach/checkout/multi-attach-checkout-same-addons.test.ts b/server/tests/integration/billing/multi-attach/checkout/multi-attach-checkout-same-addons.test.ts new file mode 100644 index 000000000..0b79a7b28 --- /dev/null +++ b/server/tests/integration/billing/multi-attach/checkout/multi-attach-checkout-same-addons.test.ts @@ -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(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 }); +}); diff --git a/server/tests/integration/billing/multi-attach/customize/multi-attach-customize-addons.test.ts b/server/tests/integration/billing/multi-attach/customize/multi-attach-customize-addons.test.ts new file mode 100644 index 000000000..3ca148cee --- /dev/null +++ b/server/tests/integration/billing/multi-attach/customize/multi-attach-customize-addons.test.ts @@ -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(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 }); +}); diff --git a/server/tests/integration/billing/multi-attach/multi-attach-errors.test.ts b/server/tests/integration/billing/multi-attach/multi-attach-errors.test.ts index 8261214f7..9c2eadd87 100644 --- a/server/tests/integration/billing/multi-attach/multi-attach-errors.test.ts +++ b/server/tests/integration/billing/multi-attach/multi-attach-errors.test.ts @@ -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 }); diff --git a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts index 944c66038..8743e2f1b 100644 --- a/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts +++ b/shared/utils/cusProductUtils/classifyCustomerProduct/classifyCustomerProduct.ts @@ -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, ) => {