fix: rollovers
This commit is contained in:
@@ -51,7 +51,14 @@ export const insertNewCusProducts = async ({
|
|||||||
RolloverService.insert({
|
RolloverService.insert({
|
||||||
ctx,
|
ctx,
|
||||||
rows: cusEnt.rollovers,
|
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 = {
|
export const temp: TestGroup = {
|
||||||
name: "temp",
|
name: "temp",
|
||||||
description: "Failed tests to triage and fix",
|
description: "Billing rollover regression suite (rollover carry-over fix)",
|
||||||
tier: "domain",
|
tier: "domain",
|
||||||
paths: [
|
paths: [
|
||||||
"integration/balances/check/spend-limit/check-customer-spend-limit.test.ts",
|
"integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts",
|
||||||
"integration/balances/track/basic/track-event-name.test.ts",
|
"integration/billing/attach/scheduled-switch/scheduled-switch-rollover.test.ts",
|
||||||
"integration/balances/track/track-misc.test.ts",
|
"integration/billing/attach/scheduled-switch/discounts/scheduled-switch-discounts-edge.test.ts",
|
||||||
"balances/track/entity-balances/track-entity-balances6.test.ts",
|
"integration/billing/create-schedule/create-schedule-basic.test.ts",
|
||||||
"balances/track/entity-balances/track-entity-balances7.test.ts",
|
"integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,180 +1,4 @@
|
|||||||
import { test } from "bun:test";
|
// Scratch TDD file. Tests covered by:
|
||||||
import {
|
// - integration/billing/attach/immediate-switch/immediate-switch-rollover.test.ts
|
||||||
type ApiCustomerV5,
|
// - integration/billing/update-subscription/custom-plan/update-paid-prepaid-rollover.test.ts
|
||||||
type AttachParamsV1Input,
|
export {};
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -12,18 +12,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { test } from "bun:test";
|
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 { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
|
||||||
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
|
||||||
|
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
|
||||||
import {
|
import {
|
||||||
expectCustomerRolloverCorrect,
|
expectCustomerRolloverCorrect,
|
||||||
expectNoRollovers,
|
expectNoRollovers,
|
||||||
} from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect";
|
} from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect";
|
||||||
|
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
|
||||||
import { TestFeature } from "@tests/setup/v2Features";
|
import { TestFeature } from "@tests/setup/v2Features";
|
||||||
import { items } from "@tests/utils/fixtures/items";
|
import { items } from "@tests/utils/fixtures/items";
|
||||||
import { products } from "@tests/utils/fixtures/products";
|
import { products } from "@tests/utils/fixtures/products";
|
||||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
|
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem";
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════════
|
||||||
// TEST 1: Upgrade with rollover carryover (same cap)
|
// 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,
|
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