Files
cfw-autumn/server/tests/integration/billing/update-subscription/version-update/version-billing-models.test.ts
2026-01-15 15:04:37 +00:00

362 lines
11 KiB
TypeScript

import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@shared/index";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// VERSION BILLING MODELS: Different billing model transitions between versions
// ═══════════════════════════════════════════════════════════════════════════════
// 6.1 Add prepaid item in v2
test.concurrent(`${chalk.yellowBright("version-billing: add prepaid users")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "version-billing-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Verify no users feature before
const customerBefore = await autumnV1.customers.get(customerId);
expect(customerBefore.features[TestFeature.Users]).toBeUndefined();
// Create v2 with prepaid users added
const prepaidUsersItem = items.prepaidUsers({ includedUsage: 5 });
await autumnV1.products.update(pro.id, {
items: [messagesItem, priceItem, prepaidUsersItem],
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
version: 2,
options: [{ feature_id: TestFeature.Users, quantity: 0 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Adding prepaid with included usage has no price impact
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Users should now be accessible with 5 included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 5,
balance: 5,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.2 Add consumable (pay-per-use) overage in v2
test.concurrent(`${chalk.yellowBright("version-billing: add consumable overage")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "version-billing-consumable",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Create v2 with consumable messages (pay-per-use after included)
const consumableMessagesItem = items.consumableMessages({
includedUsage: 100,
});
await autumnV1.products.update(pro.id, {
items: [consumableMessagesItem, priceItem],
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
version: 2,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Switching to consumable has no upfront price impact
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get(customerId);
// Messages feature should still be accessible
expect(customer.features[TestFeature.Messages]).toBeDefined();
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.3 Add allocated (prorated) seats in v2
test.concurrent(`${chalk.yellowBright("version-billing: add allocated seats")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "version-billing-allocated",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Create v2 with allocated users (prorated billing)
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 3 });
await autumnV1.products.update(pro.id, {
items: [messagesItem, priceItem, allocatedUsersItem],
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
version: 2,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Adding allocated with included seats has no upfront price impact
expect(preview.total).toBe(0);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Users should now be accessible with 3 included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
balance: 3,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.4 Change from metered to prepaid for same feature
test.concurrent(`${chalk.yellowBright("version-billing: metered to prepaid")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "version-billing-to-prepaid",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
}),
],
});
// Verify messages is metered (limited) before update
const customerBefore = await autumnV1.customers.get(customerId);
expect(customerBefore.features[TestFeature.Messages]).toBeDefined();
// Create v2 with prepaid messages instead of metered
const prepaidMessagesItem = items.prepaidMessages({ includedUsage: 0 });
await autumnV1.products.update(pro.id, {
items: [prepaidMessagesItem, priceItem],
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
version: 2,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
};
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Messages should now have prepaid model with 300 included
// prepaidMessages has billingUnits=100, quantity=300 rounds up to 300
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 300,
balance: 300,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.5 Mixed billing models: v2 has multiple billing types
test.concurrent(`${chalk.yellowBright("version-billing: mixed billing models")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "version-billing-mixed",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
// Create v2 with mixed billing: prepaid users + consumable messages + higher base price
const prepaidUsersItem = items.prepaidUsers({ includedUsage: 3 });
const consumableMessagesItem = items.consumableMessages({
includedUsage: 100,
});
const newPriceItem = items.monthlyPrice({ price: 30 });
await autumnV1.products.update(pro.id, {
items: [consumableMessagesItem, prepaidUsersItem, newPriceItem],
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
version: 2,
options: [{ feature_id: TestFeature.Users, quantity: 0 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should charge $10 for price increase
expect(preview.total).toBe(10);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Messages should have consumable model
expect(customer.features[TestFeature.Messages]).toBeDefined();
// Users should have prepaid model with 3 included
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
balance: 3,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// 6.6 Remove billing model: v2 removes prepaid, keeps metered
test.concurrent(`${chalk.yellowBright("version-billing: remove prepaid keep metered")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const prepaidUsersItem = items.prepaidUsers({ includedUsage: 5 });
const priceItem = items.monthlyPrice({ price: 30 });
const pro = products.base({
id: "pro",
items: [messagesItem, prepaidUsersItem, priceItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "version-billing-remove",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Users, quantity: 0 }],
}),
],
});
// Verify users feature exists before
const customerBefore = await autumnV1.customers.get(customerId);
expect(customerBefore.features[TestFeature.Users]).toBeDefined();
// Create v2 without prepaid users (simpler plan)
const newPriceItem = items.monthlyPrice({ price: 20 });
await autumnV1.products.update(pro.id, {
items: [messagesItem, newPriceItem],
});
const updateParams = {
customer_id: customerId,
product_id: pro.id,
version: 2,
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// Should credit $10 for price decrease
expect(preview.total).toBe(-10);
await autumnV1.subscriptions.update(updateParams);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Messages should still be accessible
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
// Users should be removed
expect(customer.features[TestFeature.Users]).toBeUndefined();
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});