Files
cfw-autumn/server/tests/integration/billing/migrations-v2/update-plan-operation/create-schedule/update-plan-op-scheduled-create-schedule.test.ts
2026-06-04 12:34:07 +01:00

304 lines
8.2 KiB
TypeScript

/**
* TDD coverage for update_plan migrations over createSchedule-managed scheduled rows.
*
* Contract under test:
* New behaviors:
* - Future scheduled rows created by createSchedule can be version-migrated.
* - Replacing one product in a multi-plan future phase rewires only that ID.
* - Feature quantities/options on scheduled rows survive replacement.
* Side effects:
* - `no_billing_changes: true` updates Autumn only and leaves the Stripe schedule unchanged.
* - Schedule phases never point at deleted customer product IDs.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { CusProductStatus, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectNoExpiredCustomerProducts } from "@tests/integration/billing/utils/expectNoExpiredCustomerProducts";
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 type Stripe from "stripe";
import { runUpdatePlanMigration } from "../../utils/runUpdatePlanMigration";
import {
expectNoCustomerProductRow,
getCustomerProductBalances,
getCustomerProductFeatureIds,
getCustomerProductPriceAmounts,
getPhaseCustomerProductIds,
getRequiredStripeScheduleId,
getScheduledCustomerProductRow,
} from "../utils/scheduledCustomerProductTestUtils";
const stripeScheduleSignature = (schedule: Stripe.SubscriptionSchedule) => ({
status: schedule.status,
currentPhase: schedule.current_phase,
phases: schedule.phases.map((phase) => ({
startDate: phase.start_date,
endDate: phase.end_date,
items: phase.items.map((item) => ({
price: typeof item.price === "string" ? item.price : item.price.id,
quantity: item.quantity,
})),
})),
});
test(`${chalk.yellowBright("migrations update_plan scheduled createSchedule: future row replacement rewires one multi-plan phase ID")}`, async () => {
const customerId = "migration-update-scheduled-create-schedule";
const activePlan = products.pro({
id: "scheduled-create-schedule-active",
items: [items.monthlyWords({ includedUsage: 100 })],
});
const futurePlan = products.base({
id: "scheduled-create-schedule-base",
items: [
items.monthlyMessages({ includedUsage: 100 }),
items.monthlyPrice({ price: 20 }),
],
});
const untouchedFuturePlan = products.base({
id: "scheduled-create-schedule-untouched",
group: "backup",
items: [
items.monthlyPrice({ price: 40 }),
items.monthlyWords({ includedUsage: 50 }),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [activePlan, futurePlan, untouchedFuturePlan] }),
],
actions: [],
});
const now = Date.now();
const response = await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: activePlan.id }],
},
{
starts_at: now + ms.days(30),
plans: [
{ plan_id: futurePlan.id },
{ plan_id: untouchedFuturePlan.id },
],
},
],
});
const scheduledBefore = await getScheduledCustomerProductRow({
ctx,
customerId,
productId: futurePlan.id,
});
const untouchedScheduledBefore = await getScheduledCustomerProductRow({
ctx,
customerId,
productId: untouchedFuturePlan.id,
});
expect(response.phases[1]?.customer_product_ids).toEqual([
scheduledBefore.id,
untouchedScheduledBefore.id,
]);
const stripeScheduleId = getRequiredStripeScheduleId({
scheduledIds: scheduledBefore.scheduledIds,
});
const stripeScheduleBefore =
await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId);
const stripeSignatureBefore = stripeScheduleSignature(
stripeScheduleBefore as Stripe.SubscriptionSchedule,
);
await autumnV1.products.update(futurePlan.id, {
items: [
items.monthlyPrice({ price: 30 }),
items.monthlyMessages({ includedUsage: 250 }),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig-${Date.now()}`,
customerId,
noBillingChanges: true,
filter: { customer: { plan: { plan_id: futurePlan.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: futurePlan.id, version: 1 },
version: 2,
},
],
},
runOnServer: false,
});
await expectNoCustomerProductRow({
ctx,
customerProductId: scheduledBefore.id,
});
const scheduledAfter = await getScheduledCustomerProductRow({
ctx,
customerId,
productId: futurePlan.id,
});
expect(scheduledAfter.id).not.toBe(scheduledBefore.id);
expect(scheduledAfter.version).toBe(2);
expect(scheduledAfter.startsAt).toBe(scheduledBefore.startsAt);
expect(scheduledAfter.scheduledIds).toEqual(scheduledBefore.scheduledIds);
expect(
await getPhaseCustomerProductIds({
ctx,
customerProductId: scheduledAfter.id,
}),
).toEqual([scheduledAfter.id, untouchedScheduledBefore.id]);
expect(
await getCustomerProductPriceAmounts({
ctx,
customerProductId: scheduledAfter.id,
}),
).toEqual([30]);
expect(
await getCustomerProductFeatureIds({
ctx,
customerProductId: scheduledAfter.id,
}),
).toEqual([TestFeature.Messages]);
const stripeScheduleAfter =
await ctx.stripeCli.subscriptionSchedules.retrieve(stripeScheduleId);
expect(stripeScheduleSignature(stripeScheduleAfter as Stripe.SubscriptionSchedule)).toEqual(
stripeSignatureBefore,
);
await expectNoExpiredCustomerProducts({ ctx, customerId, productId: futurePlan.id });
await expectCustomerInvoiceCorrect({
customer: await autumnV1.customers.get<ApiCustomerV3>(customerId),
count: 1,
latestTotal: 20,
});
});
test(`${chalk.yellowBright("migrations update_plan scheduled createSchedule: feature quantities survive replacement")}`, async () => {
const customerId = "migration-update-scheduled-quantity";
const activePlan = products.pro({
id: "scheduled-quantity-active",
items: [items.monthlyWords({ includedUsage: 100 })],
});
const futurePlan = products.base({
id: "scheduled-quantity-future",
items: [
items.monthlyPrice({ price: 20 }),
items.prepaidMessages(),
],
});
const { autumnV1, autumnV2_2, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [activePlan, futurePlan] }),
],
actions: [],
});
const now = Date.now();
await autumnV1.billing.createSchedule({
customer_id: customerId,
phases: [
{
starts_at: now,
plans: [{ plan_id: activePlan.id }],
},
{
starts_at: now + ms.days(30),
plans: [
{
plan_id: futurePlan.id,
feature_quantities: [
{
feature_id: TestFeature.Messages,
quantity: 400,
},
],
},
],
},
],
});
const scheduledBefore = await getScheduledCustomerProductRow({
ctx,
customerId,
productId: futurePlan.id,
});
expect(scheduledBefore.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 4,
}),
]);
await autumnV1.products.update(futurePlan.id, {
items: [
items.monthlyPrice({ price: 25 }),
items.prepaidMessages(),
],
});
await runUpdatePlanMigration({
ctx,
migrationClient: autumnV2_2,
migrationId: `${customerId}-mig-${Date.now()}`,
customerId,
noBillingChanges: true,
filter: { customer: { plan: { plan_id: futurePlan.id } } },
operations: {
customer: [
{
type: "update_plan",
plan_filter: { plan_id: futurePlan.id, version: 1 },
version: 2,
},
],
},
runOnServer: false,
});
await expectNoCustomerProductRow({
ctx,
customerProductId: scheduledBefore.id,
});
const scheduledAfter = await getScheduledCustomerProductRow({
ctx,
customerId,
productId: futurePlan.id,
});
expect(scheduledAfter.version).toBe(2);
expect(scheduledAfter.options).toEqual([
expect.objectContaining({
feature_id: TestFeature.Messages,
quantity: 4,
}),
]);
expect(
await getCustomerProductBalances({
ctx,
customerProductId: scheduledAfter.id,
}),
).toEqual([
expect.objectContaining({
featureId: TestFeature.Messages,
balance: 400,
}),
]);
});