fix: rollovers

This commit is contained in:
John Yeo
2026-04-28 16:52:41 +01:00
parent 30e25c5dd9
commit aab7321bdb
5 changed files with 222 additions and 188 deletions

View File

@@ -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: [],
},
}),
];
});

View File

@@ -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",
],
};

View File

@@ -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<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerAfterInvoice,
featureId: TestFeature.Credits,
remaining: 1500 + 750,
usage: 0,
rollovers: [{ balance: 750 }],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
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<ApiCustomerV5>(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<ApiCustomerV5>(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<ApiCustomerV5>(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 {};

View File

@@ -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<ApiCustomerV5>(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<AttachParamsV1Input>({
customer_id: customerId,
plan_id: proMonthly.id,
redirect_mode: "if_required",
});
const customerAfterSwitch =
await autumnV2_2.customers.get<ApiCustomerV5>(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 });
});

View File

@@ -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<ApiCustomerV5>(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<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerAfterUpdate,
featureId: TestFeature.Messages,
remaining: expectedRemaining,
usage: 0,
rollovers: [{ balance: expectedRollover }],
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});