diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts index ae16a64c1..16601efcb 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachTransitionContext.ts @@ -5,6 +5,8 @@ import { type FullProduct, findMainActiveCustomerProductByGroup, findMainScheduledCustomerProductByGroup, + getProductBaseInterval, + intervalsDifferent, isOneOffProduct, isProductUpgrade, } from "@autumn/shared"; @@ -53,12 +55,30 @@ export const setupAttachTransitionContext = ({ cusProduct: currentCustomerProduct, }); - const isUpgrade = isProductUpgrade({ - prices1: currentPrices, - prices2: attachProduct.prices, + const currentBaseInterval = getProductBaseInterval({ + prices: currentPrices, + }); + const newBaseInterval = getProductBaseInterval({ + prices: attachProduct.prices, }); - planTiming = isUpgrade ? "immediate" : "end_of_cycle"; + const baseIntervalsAreDifferent = + currentBaseInterval && + newBaseInterval && + intervalsDifferent({ + intervalA: currentBaseInterval, + intervalB: newBaseInterval, + }); + + if (baseIntervalsAreDifferent) { + planTiming = "immediate"; + } else { + const isUpgrade = isProductUpgrade({ + prices1: currentPrices, + prices2: attachProduct.prices, + }); + planTiming = isUpgrade ? "immediate" : "end_of_cycle"; + } } // Override if plan_schedule param is provided diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts index 549deb617..8a20ea33d 100644 --- a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts +++ b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts @@ -556,3 +556,89 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-basic 6: invoice line it notPresent: [pro.id], }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST 7: Pro Annual to Premium Monthly (interval change = immediate) +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Scenario: + * - Customer has pro annual ($200/year) + * - Switch to premium monthly ($50/mo) + * + * Expected Result: + * - Premium is active immediately (different billing interval = always immediate) + * - Pro annual is removed + * - Credit for unused annual applied against premium charge + */ +test.concurrent(`${chalk.yellowBright("immediate-switch-basic 7: pro annual to premium monthly (interval change)")}`, async () => { + const customerId = "imm-switch-pro-annual-to-premium"; + + const proAnnualMessages = items.monthlyMessages({ includedUsage: 500 }); + const proAnnual = products.proAnnual({ + id: "pro-annual", + items: [proAnnualMessages], + }); + + const premiumMessages = items.monthlyMessages({ includedUsage: 1000 }); + const premium = products.premium({ + id: "premium", + items: [premiumMessages], + }); + + const { autumnV1, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proAnnual, premium] }), + ], + actions: [s.billing.attach({ productId: proAnnual.id })], + }); + + // 1. Preview switch to premium monthly + // At start of cycle: credit for full annual ($200) exceeds premium monthly ($50) + const preview = await autumnV1.billing.previewAttach({ + customer_id: customerId, + product_id: premium.id, + }); + expect(preview.total).toBe(0); + + // 2. Attach premium (immediate because interval differs: annual -> monthly) + await autumnV1.billing.attach({ + customer_id: customerId, + product_id: premium.id, + redirect_mode: "if_required", + }); + + const customer = await autumnV1.customers.get(customerId); + + // Premium should be active, pro annual should be gone (immediate switch) + await expectCustomerProducts({ + customer, + active: [premium.id], + notPresent: [proAnnual.id], + }); + + // Verify messages feature has premium's balance + expectCustomerFeatureCorrect({ + customer, + featureId: TestFeature.Messages, + includedUsage: 1000, + balance: 1000, + usage: 0, + }); + + // Verify invoices: proAnnual ($200) + switch invoice ($0, credit exceeds charge) + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: 0, + }); + + await expectSubToBeCorrect({ + db: ctx.db, + customerId, + org: ctx.org, + env: ctx.env, + }); +}); diff --git a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-entities-cross.test.ts b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-entities-cross.test.ts index 7a65c6250..f1e5b8320 100644 --- a/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-entities-cross.test.ts +++ b/server/tests/integration/billing/attach/scheduled-switch/scheduled-switch-entities-cross.test.ts @@ -344,13 +344,15 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 3: entity /** * Scenario: - * - Entity 1: Premium Annual → Pro (scheduled) - * - Entity 2: Premium Monthly → Pro (scheduled) + * - Entity 1: Premium Annual → Pro (immediate, interval change) + * - Entity 2: Premium Monthly → Pro (scheduled, same interval) * - Advance 1 month (monthly cycle ends) * - Upgrade entity 2 back to premium * * Expected Result: - * - After cycle: Entity 1 still on annual (hasn't ended yet), Entity 2 on pro + * - Entity 1 immediately switches to pro (annual→monthly = different interval = immediate) + * - Entity 2 has premium canceling, pro scheduled (same interval = end_of_cycle) + * - After cycle: Entity 1 on pro (renewed), Entity 2 on pro (scheduled switch completed) * - After upgrade: Entity 2 on premium */ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 4: entity 1 premiumAnnual to pro, entity 2 premium to pro, advance cycle, upgrade entity 2 to premium")}`, async () => { @@ -385,28 +387,27 @@ test.concurrent(`${chalk.yellowBright("scheduled-switch-entities-cross 4: entity actions: [ s.billing.attach({ productId: premiumAnnual.id, entityIndex: 0 }), s.billing.attach({ productId: premium.id, entityIndex: 1 }), - s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Downgrade entity 1 (annual) - s.billing.attach({ productId: pro.id, entityIndex: 1 }), // Downgrade entity 2 (monthly) + s.billing.attach({ productId: pro.id, entityIndex: 0 }), // Entity 1: immediate (interval change) + s.billing.attach({ productId: pro.id, entityIndex: 1 }), // Entity 2: scheduled (same interval) s.advanceToNextInvoice(), // Advance 1 month ], }); - // Verify entity 1: still on annual (annual hasn't ended) + // Entity 1 already switched to pro immediately (annual→monthly = different interval) const entity1Before = await autumnV1.entities.get( customerId, entities[0].id, ); - // Entity 1's annual subscription should still be active with pro scheduled - await expectProductCanceling({ - customer: entity1Before, - productId: premiumAnnual.id, - }); - await expectProductScheduled({ + await expectProductActive({ customer: entity1Before, productId: pro.id, }); + await expectProductNotPresent({ + customer: entity1Before, + productId: premiumAnnual.id, + }); - // Verify entity 2: now on pro (monthly cycle completed) + // Entity 2: now on pro (monthly cycle completed, scheduled switch took effect) const entity2Before = await autumnV1.entities.get( customerId, entities[1].id, diff --git a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts index 8ab19cf2d..32f98683d 100644 --- a/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/classifyPriceUtils.ts @@ -11,6 +11,16 @@ import { BillingType } from "../../../models/productModels/priceModels/priceEnum import type { Price } from "../../../models/productModels/priceModels/priceModels"; import { getBillingType } from "../priceUtils"; +/** Returns the billing interval of the base (fixed) price for a set of prices, or null if none found. */ +export const getProductBaseInterval = ({ prices }: { prices: Price[] }) => { + const basePrice = prices.find(isFixedPrice); + if (!basePrice) return null; + return { + interval: basePrice.config.interval as BillingInterval, + intervalCount: basePrice.config.interval_count ?? 1, + }; +}; + export const isOneOffPrice = ( price: Price, ): price is Price & {