Files
cfw-autumn/server/tests/integration/billing/attach/params/start-date/starts-at-scheduling.test.ts
2026-05-08 10:39:04 +01:00

707 lines
20 KiB
TypeScript

import { expect, test } from "bun:test";
import {
type ApiCustomerV5,
type ApiEntityV0,
type AttachParamsV0Input,
type AttachParamsV1Input,
CollectionMethod,
CusProductStatus,
ms,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceTestClock } from "@tests/utils/stripeUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addDays, addHours, addMinutes, addMonths } from "date-fns";
import type Stripe from "stripe";
import { CusService } from "@/internal/customers/CusService";
import {
expectResetAnchoredTo,
getCustomerProduct,
triggerSubscriptionCreated,
} from "./utils";
const getScheduleSubscriptionId = (schedule: Stripe.SubscriptionSchedule) =>
typeof schedule.subscription === "string"
? schedule.subscription
: schedule.subscription?.id;
test.concurrent(`${chalk.yellowBright("starts_at: future attach creates scheduled subscription")}`, async () => {
const customerId = "attach-start-date-future";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV2_2.billing.previewAttach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
expect(preview.total).toBe(0);
expect(preview.next_cycle?.starts_at).toBe(startDate);
expect(preview.next_cycle?.total).toBe(20);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.access_starts_at).toBeNull();
expect(cusProduct.collection_method).toBe(
CollectionMethod.ChargeAutomatically,
);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: scheduled subscription can be canceled by customer_product_id")}`, async () => {
const customerId = "attach-start-date-cancel-scheduled";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const scheduledCustomerProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = scheduledCustomerProduct.scheduled_ids?.[0];
if (!scheduleId)
throw new Error("Expected scheduled product to have schedule");
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
customer_product_id: scheduledCustomerProduct.id,
cancel_action: "cancel_immediately",
});
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update({
customer_id: customerId,
customer_product_id: scheduledCustomerProduct.id,
cancel_action: "cancel_immediately",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id],
});
const stripeSchedule =
await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId);
expect(stripeSchedule.status).toBe("canceled");
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: immediate-access future subscription cancels by deleting schedule")}`, async () => {
const customerId = "attach-start-date-cancel-immediate-access-v2";
const pro = products.pro({
id: "pro-immediate-access-cancel",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
enable_plan_immediately: true,
});
const customerProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = customerProduct.scheduled_ids?.[0];
if (!scheduleId)
throw new Error("Expected immediate-access product to have schedule");
expect(customerProduct.status).toBe(CusProductStatus.Active);
expect(customerProduct.subscription_ids ?? []).toEqual([]);
await autumnV1.subscriptions.update({
customer_id: customerId,
customer_product_id: customerProduct.id,
cancel_action: "cancel_end_of_cycle",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id],
});
const stripeSchedule =
await ctx.stripeCli.subscriptionSchedules.retrieve(scheduleId);
expect(stripeSchedule.status).toBe("canceled");
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: future attach without payment method creates invoice schedule")}`, async () => {
const customerId = "attach-start-date-future-invoice";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ email: "future-invoice@example.com" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const result = await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
expect(result.payment_url).toBeNull();
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.collection_method).toBe(CollectionMethod.SendInvoice);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.default_settings.collection_method).toBe(
"send_invoice",
);
expect(stripeSchedule.default_settings.invoice_settings.days_until_due).toBe(
30,
);
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
await expectCustomerInvoiceCorrect({ customerId, count: 0 });
});
test.concurrent(`${chalk.yellowBright("starts_at: beta attach creates scheduled subscription")}`, async () => {
const customerId = "attach-start-date-beta";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1Beta, autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const startDate = addDays(advancedTo, 1).getTime();
const preview = await autumnV1Beta.billing.previewAttach<AttachParamsV0Input>(
{
customer_id: customerId,
product_id: pro.id,
starts_at: startDate,
},
);
expect(preview.total).toBe(0);
await autumnV1Beta.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
starts_at: startDate,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer,
productId: pro.id,
startsAt: startDate,
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Scheduled);
expect(cusProduct.subscription_ids ?? []).toEqual([]);
expect(cusProduct.scheduled_ids).toHaveLength(1);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
cusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startDate / 1000),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: now attaches immediately")}`, async () => {
const customerId = "attach-start-date-now";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: advancedTo,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids?.length).toBe(1);
expect(cusProduct.scheduled_ids ?? []).toEqual([]);
expectResetAnchoredTo({
cusProduct,
featureId: TestFeature.Messages,
startDate: advancedTo,
});
});
test.concurrent(`${chalk.yellowBright("starts_at: entity attach with existing scheduled switch")}`, async () => {
const customerId = "attach-starts-at-entity-existing-schedule";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: premium.id, entityIndex: 0 })],
});
const entityAId = entities[0].id;
const entityBId = entities[1].id;
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityAId,
});
const startsAt = addDays(advancedTo, 10).getTime();
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityBId,
starts_at: startsAt,
});
const entityA = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityAId,
);
const entityB = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entityBId,
);
await expectProductCanceling({ customer: entityA, productId: premium.id });
await expectProductScheduled({ customer: entityA, productId: pro.id });
await expectProductScheduled({
customer: entityB,
productId: pro.id,
startsAt,
});
const entityBCusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
entityId: entityBId,
});
expect(entityBCusProduct.status).toBe(CusProductStatus.Scheduled);
expect(entityBCusProduct.scheduled_ids?.length).toBeGreaterThan(0);
expect(Math.abs(entityBCusProduct.starts_at - startsAt)).toBeLessThan(
ms.minutes(10),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: entity attach creates new billing subscription beside existing schedule")}`, async () => {
const customerId = "attach-starts-at-entity-new-sub-existing-schedule";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV1, ctx, entities, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.billing.attach({ productId: premium.id, entityIndex: 0 })],
});
const entityAId = entities[0].id;
const entityBId = entities[1].id;
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityAId,
});
const startsAt = addDays(advancedTo, 10).getTime();
await autumnV1.billing.attach<AttachParamsV0Input>({
customer_id: customerId,
product_id: pro.id,
entity_id: entityBId,
starts_at: startsAt,
new_billing_subscription: true,
});
const entityBCusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
entityId: entityBId,
});
expect(entityBCusProduct.status).toBe(CusProductStatus.Scheduled);
expect(entityBCusProduct.subscription_ids ?? []).toEqual([]);
expect(entityBCusProduct.scheduled_ids).toHaveLength(1);
expect(Math.abs(entityBCusProduct.starts_at - startsAt)).toBeLessThan(
ms.minutes(10),
);
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
entityBCusProduct.scheduled_ids![0]!,
)) as Stripe.SubscriptionSchedule;
expect(stripeSchedule.phases[0]?.start_date).toBe(
Math.floor(startsAt / 1000),
);
});
test.concurrent(`${chalk.yellowBright("starts_at: immediate attach replaces scheduled plan")}`, async () => {
const customerId = "attach-starts-at-future-then-immediate";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: addDays(advancedTo, 7).getTime(),
});
const customerWithScheduledPro =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer: customerWithScheduledPro,
productId: pro.id,
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
});
test.concurrent(`${chalk.yellowBright("starts_at: custom switch date schedules plan change")}`, async () => {
const customerId = "attach-starts-at-custom-switch";
const premium = products.premium({
id: "premium",
items: [items.monthlyMessages({ includedUsage: 200 })],
});
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.billing.attach({ productId: premium.id })],
});
const startsAt = addMonths(advancedTo, 2).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: pro.id, startsAt });
});
test.concurrent(`${chalk.yellowBright("starts_at: add-on starts in the future")}`, async () => {
const customerId = "attach-starts-at-addon";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
const startsAt = addDays(advancedTo, 7).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: startsAt,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectProductScheduled({ customer, productId: addon.id, startsAt });
});
test.concurrent(`${chalk.yellowBright("starts_at: scheduled add-on is removed when base plan is canceled immediately")}`, async () => {
const customerId = "attach-starts-at-addon-cancel-base";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const addon = products.recurringAddOn({
id: "addon",
items: [items.monthlyUsers({ includedUsage: 5 })],
});
const { autumnV1, autumnV2_2, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: addon.id,
starts_at: addDays(advancedTo, 7).getTime(),
});
const customerWithScheduledAddon =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectProductScheduled({
customer: customerWithScheduledAddon,
productId: addon.id,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({
customer,
notPresent: [pro.id, addon.id],
});
});
test.concurrent(`${chalk.yellowBright("starts_at: test clock start links and activates subscription")}`, async () => {
const customerId = "attach-start-date-clock";
const pro = products.pro({
id: "pro",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
const { autumnV2_2, ctx, advancedTo, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
expect(testClockId).toBeDefined();
const startDate = addDays(advancedTo, 1).getTime();
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
starts_at: startDate,
});
const scheduledProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
const scheduleId = scheduledProduct.scheduled_ids?.[0];
expect(scheduleId).toBeDefined();
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
advanceTo: addHours(startDate, 1).getTime(),
waitForSeconds: 30,
});
const stripeSchedule = (await ctx.stripeCli.subscriptionSchedules.retrieve(
scheduleId!,
)) as Stripe.SubscriptionSchedule;
const stripeSubId = getScheduleSubscriptionId(stripeSchedule);
if (!stripeSubId) throw new Error("Expected schedule to have a subscription");
await triggerSubscriptionCreated({
ctx,
stripeSubId,
scheduleId,
subscriptionCreatedAtMs: addMinutes(startDate, 5).getTime(),
fullCustomer: await CusService.getFull({
ctx,
idOrInternalId: customerId,
}),
});
const cusProduct = await getCustomerProduct({
ctx,
customerId,
productId: pro.id,
});
expect(cusProduct.status).toBe(CusProductStatus.Active);
expect(cusProduct.subscription_ids).toEqual([stripeSubId]);
});