diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts.ts index 1216f8987..bb744fa2c 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts.ts @@ -51,7 +51,14 @@ export const insertNewCusProducts = async ({ RolloverService.insert({ ctx, rows: cusEnt.rollovers, - fullCusEnt: { ...cusEnt, customer_product: cusProduct ?? null }, + // New cusEnt — no pre-existing DB rollovers. Pass [] so the max-cap + // check in clearExcessRollovers doesn't double-count the rows we're + // inserting (cusEnt.rollovers already holds the same objects as `rows`). + fullCusEnt: { + ...cusEnt, + customer_product: cusProduct ?? null, + rollovers: [], + }, }), ]; }); diff --git a/server/tests/_groups/temp.ts b/server/tests/_groups/temp.ts index d04c45751..f204997a0 100644 --- a/server/tests/_groups/temp.ts +++ b/server/tests/_groups/temp.ts @@ -2,13 +2,13 @@ import type { TestGroup } from "./types"; export const temp: TestGroup = { name: "temp", - description: "Failed tests to triage and fix", + description: "Billing rollover regression suite (rollover carry-over fix)", tier: "domain", paths: [ - "integration/balances/check/spend-limit/check-customer-spend-limit.test.ts", - "integration/balances/track/basic/track-event-name.test.ts", - "integration/balances/track/track-misc.test.ts", - "balances/track/entity-balances/track-entity-balances6.test.ts", - "balances/track/entity-balances/track-entity-balances7.test.ts", + "integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts", + "integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts", + "integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-edge.test.ts", + "integration/billing/create-schedule/create-schedule-basic.test.ts", + "integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts", ], }; diff --git a/server/tests/_temp/temp.test.ts b/server/tests/_temp/temp.test.ts index 1c2d5f41f..20ab8a2f1 100644 --- a/server/tests/_temp/temp.test.ts +++ b/server/tests/_temp/temp.test.ts @@ -1,180 +1,4 @@ -import { test } from "bun:test"; -import { - type ApiCustomerV5, - type AttachParamsV1Input, - RolloverExpiryDurationType, -} from "@autumn/shared"; -import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; -import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; -import { TestFeature } from "@tests/setup/v2Features"; -import { products } from "@tests/utils/fixtures/products.js"; -import { advanceTestClock } from "@tests/utils/stripeUtils.js"; -import ctx from "@tests/utils/testInitUtils/createTestContext.js"; -import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; -import chalk from "chalk"; -import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js"; - -test.concurrent(`${chalk.yellowBright("temp: pro annual prepaid credits with rollover carries to pro monthly")}`, async () => { - const customerId = "temp-annual-prepaid-rollover"; - const rolloverConfig = { - max_percentage: 50, - length: 1, - duration: RolloverExpiryDurationType.Month, - }; - - const annualCreditsItem = constructPrepaidItem({ - featureId: TestFeature.Credits, - includedUsage: 100, - billingUnits: 1, - price: 0.25, - rolloverConfig, - }); - - const monthlyCreditsItem = constructPrepaidItem({ - featureId: TestFeature.Credits, - includedUsage: 100, - billingUnits: 1, - price: 0.25, - rolloverConfig, - }); - - const proAnnual = products.proAnnual({ - id: "pro-annual-rollover", - items: [annualCreditsItem], - }); - - const pro = products.pro({ - id: "pro-monthly-rollover", - items: [monthlyCreditsItem], - }); - - const { autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [proAnnual, pro] }), - ], - actions: [ - s.billing.attach({ - productId: proAnnual.id, - options: [{ feature_id: TestFeature.Credits, quantity: 1500 }], - }), - // s.track({ featureId: TestFeature.Action1, value: 10, timeout: 2000 }), - s.advanceToNextInvoice(), - ], - }); - - const customerAfterInvoice = - await autumnV2_2.customers.get(customerId); - - expectBalanceCorrect({ - customer: customerAfterInvoice, - featureId: TestFeature.Credits, - remaining: 1500 + 750, - usage: 0, - rollovers: [{ balance: 750 }], - }); - - await autumnV2_2.billing.attach({ - customer_id: customerId, - plan_id: pro.id, - redirect_mode: "if_required", - // feature_quantities: [{ feature_id: TestFeature.Credits, quantity: 750 }], - }); - - const customerAfterSwitch = - await autumnV2_2.customers.get(customerId); - - expectBalanceCorrect({ - customer: customerAfterSwitch, - featureId: TestFeature.Credits, - remaining: 1500 + 750, - usage: 0, - rollovers: [{ balance: 750 }], - }); - - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); - -test.concurrent(`${chalk.yellowBright("temp: pro prepaid messages rollover persists after price update")}`, async () => { - const customerId = "temp-pro-prepaid-msgs-price-update"; - const rolloverConfig = { - max_percentage: 50, - length: 1, - duration: RolloverExpiryDurationType.Month, - }; - - const messagesItem = constructPrepaidItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - billingUnits: 1, - price: 0.1, - rolloverConfig, - }); - - const updatedMessagesItem = constructPrepaidItem({ - featureId: TestFeature.Messages, - includedUsage: 100, - billingUnits: 1, - price: 0.2, - rolloverConfig, - }); - - const pro = products.pro({ - id: "pro-prepaid-msgs-price-update", - items: [messagesItem], - }); - - const quantity = 1500; - - const { autumnV2_2, ctx } = await initScenario({ - customerId, - setup: [ - s.customer({ paymentMethod: "success" }), - s.products({ list: [pro] }), - ], - actions: [ - s.billing.attach({ - productId: pro.id, - options: [{ feature_id: TestFeature.Messages, quantity }], - }), - s.advanceToNextInvoice(), - ], - }); - - // After invoice: rollover = 50% of 1500 = 750 - // New balance = 1500 + 750 = 2250 - const expectedRollover = quantity / 2; - const expectedRemaining = quantity + expectedRollover; - - const customerAfterInvoice = - await autumnV2_2.customers.get(customerId); - - expectBalanceCorrect({ - customer: customerAfterInvoice, - featureId: TestFeature.Messages, - remaining: expectedRemaining, - usage: 0, - rollovers: [{ balance: expectedRollover }], - }); - - // Update subscription to change prepaid messages price - await autumnV2_2.subscriptions.update({ - customer_id: customerId, - product_id: pro.id, - items: [updatedMessagesItem], - }); - - const customerAfterUpdate = - await autumnV2_2.customers.get(customerId); - - expectBalanceCorrect({ - customer: customerAfterUpdate, - featureId: TestFeature.Messages, - remaining: expectedRemaining, - usage: 0, - rollovers: [{ balance: expectedRollover }], - }); - - await expectStripeSubscriptionCorrect({ ctx, customerId }); -}); +// Scratch TDD file. Tests covered by: +// - integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts +// - integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts +export {}; diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts index a5ba9160d..b723e6222 100644 --- a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts +++ b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts @@ -12,18 +12,26 @@ */ import { test } from "bun:test"; -import { type ApiCustomerV3, RolloverExpiryDurationType } from "@autumn/shared"; +import { + type ApiCustomerV3, + type ApiCustomerV5, + type AttachParamsV1Input, + RolloverExpiryDurationType, +} 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 { expectCustomerRolloverCorrect, expectNoRollovers, } from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; 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"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem"; // ═══════════════════════════════════════════════════════════════════════════════ // TEST 1: Upgrade with rollover carryover (same cap) @@ -431,3 +439,94 @@ test.concurrent(`${chalk.yellowBright("immediate-switch-rollover 4: upgrade to p featureId: TestFeature.Messages, }); }); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST: Pro annual prepaid credits with rollover (max_percentage) carries to pro monthly +// +// Regression for double-counting bug in clearExcessRollovers when inserting a +// new cusProduct: in-memory cusEnt.rollovers (carried-over) was the same array +// instance as `newRows`, so the cap check saw 2× the balance and zeroed out +// the rollover during the immediate switch. +// ═══════════════════════════════════════════════════════════════════════════════ +test.concurrent(`${chalk.yellowBright("immediate-switch-rollover: prepaid credits + max_percentage rollover carries from annual to monthly")}`, async () => { + const customerId = "imm-switch-rollover-prepaid-annual-to-monthly"; + const rolloverConfig = { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + }; + + const annualCreditsItem = constructPrepaidItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 1, + price: 0.25, + rolloverConfig, + }); + + const monthlyCreditsItem = constructPrepaidItem({ + featureId: TestFeature.Credits, + includedUsage: 100, + billingUnits: 1, + price: 0.25, + rolloverConfig, + }); + + const proAnnual = products.proAnnual({ + id: "pro-annual-rollover-prepaid", + items: [annualCreditsItem], + }); + + const proMonthly = products.pro({ + id: "pro-monthly-rollover-prepaid", + items: [monthlyCreditsItem], + }); + + const { autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [proAnnual, proMonthly] }), + ], + actions: [ + s.billing.attach({ + productId: proAnnual.id, + options: [{ feature_id: TestFeature.Credits, quantity: 1500 }], + }), + s.advanceToNextInvoice(), + ], + }); + + const customerAfterInvoice = + await autumnV2_2.customers.get(customerId); + + // After cycle reset: balance refreshed to 1500, rollover = 50% of 1500 = 750 + expectBalanceCorrect({ + customer: customerAfterInvoice, + featureId: TestFeature.Credits, + remaining: 1500 + 750, + usage: 0, + rollovers: [{ balance: 750 }], + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: proMonthly.id, + redirect_mode: "if_required", + }); + + const customerAfterSwitch = + await autumnV2_2.customers.get(customerId); + + // Rollover must persist on the new pro-monthly cusProduct (cap = 50% of new + // quantity 1500 = 750, so the existing 750 rollover survives intact). + expectBalanceCorrect({ + customer: customerAfterSwitch, + featureId: TestFeature.Credits, + remaining: 1500 + 750, + usage: 0, + rollovers: [{ balance: 750 }], + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts new file mode 100644 index 000000000..005f9054a --- /dev/null +++ b/server/tests/integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts @@ -0,0 +1,104 @@ +/** + * Update Subscription Custom Plan — Rollover Preservation + * + * Regression for double-counting bug in clearExcessRollovers: an updateSubscription + * with a custom prepaid item change must preserve any existing rollover that fits + * within the cap (max_percentage of starting balance). + * + * Pre-fix: the carried-over rollover was zeroed out because the cap check saw + * `[...fullCusEnt.rollovers, ...newRows]` where both arrays were the same in-memory + * objects, doubling the total balance and triggering excess clearing. + */ + +import { test } from "bun:test"; +import { + type ApiCustomerV5, + RolloverExpiryDurationType, +} from "@autumn/shared"; +import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect"; +import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect"; +import { TestFeature } from "@tests/setup/v2Features"; +import { products } from "@tests/utils/fixtures/products"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario"; +import chalk from "chalk"; +import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem"; + +test.concurrent(`${chalk.yellowBright("update-paid-prepaid-rollover: max_percentage rollover persists across price update")}`, async () => { + const customerId = "update-prepaid-rollover-price-change"; + const rolloverConfig = { + max_percentage: 50, + length: 1, + duration: RolloverExpiryDurationType.Month, + }; + + const messagesItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 1, + price: 0.1, + rolloverConfig, + }); + + const updatedMessagesItem = constructPrepaidItem({ + featureId: TestFeature.Messages, + includedUsage: 100, + billingUnits: 1, + price: 0.2, + rolloverConfig, + }); + + const pro = products.pro({ + id: "pro-prepaid-rollover-update", + items: [messagesItem], + }); + + const quantity = 1500; + const expectedRollover = quantity / 2; + const expectedRemaining = quantity + expectedRollover; + + const { autumnV1, autumnV2_2, ctx } = await initScenario({ + customerId, + setup: [ + s.customer({ paymentMethod: "success" }), + s.products({ list: [pro] }), + ], + actions: [ + s.billing.attach({ + productId: pro.id, + options: [{ feature_id: TestFeature.Messages, quantity }], + }), + s.advanceToNextInvoice(), + ], + }); + + const customerAfterInvoice = + await autumnV2_2.customers.get(customerId); + + expectBalanceCorrect({ + customer: customerAfterInvoice, + featureId: TestFeature.Messages, + remaining: expectedRemaining, + usage: 0, + rollovers: [{ balance: expectedRollover }], + }); + + // Update subscription items (price change). Rollover must survive. + await autumnV1.subscriptions.update({ + customer_id: customerId, + product_id: pro.id, + items: [updatedMessagesItem], + }); + + const customerAfterUpdate = + await autumnV2_2.customers.get(customerId); + + expectBalanceCorrect({ + customer: customerAfterUpdate, + featureId: TestFeature.Messages, + remaining: expectedRemaining, + usage: 0, + rollovers: [{ balance: expectedRollover }], + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +});