fix: rollovers
This commit is contained in:
@@ -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: [],
|
||||
},
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user