Files
cfw-autumn/server/tests/integration/billing/attach/immediate-switch/immediate-switch-reset-behavior.test.ts
2026-02-08 13:54:33 -08:00

642 lines
19 KiB
TypeScript

/**
* Immediate Switch Reset Behavior Tests (Attach V2)
*
* Tests for next_reset_at and usage reset behavior during upgrades.
*
* Key behaviors:
* - Consumable: usage RESETS, reset_at preserved (same billing interval)
* - Prepaid: usage RESETS, reset_at preserved
* - Allocated: usage CARRIES OVER, reset_at preserved
* - Free to paid: reset_at preserved (anchor from free product)
* - Monthly to annual: reset_at preserved (same anchor)
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerProducts } 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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { addMonths } from "date-fns";
// ═══════════════════════════════════════════════════════════════════════════════
// CONSUMABLE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Consumable - same interval upgrade preserves reset_at, usage resets
*
* Scenario:
* - Pro monthly (100 messages)
* - Advance 15 days mid-cycle
* - Track 30 usage
* - Upgrade to Premium monthly (500 messages)
*
* Expected:
* - next_reset_at stays the same (same billing interval)
* - Usage RESETS for consumable (new allowance starts fresh)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-reset 1: consumable same interval preserves reset_at")}`, async () => {
const customerId = "reset-consumable-same-interval";
const proMessages = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [proMessages],
});
const premiumMessages = items.monthlyMessages({ includedUsage: 500 });
const premium = products.premium({
id: "premium",
items: [premiumMessages],
});
// Advance 15 days so we're mid-cycle when upgrading - this makes reset_at comparison meaningful
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 15 }),
],
});
// Wait for webhooks to process and cache to reset
await new Promise((r) => setTimeout(r, 4000));
// Track some usage
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
});
await new Promise((r) => setTimeout(r, 2000));
// Get original reset_at before upgrade
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const originalResetAt =
customerBefore.features[TestFeature.Messages]?.next_reset_at;
expect(originalResetAt).toBeDefined();
// Verify pre-upgrade state
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 70,
usage: 30,
});
// Upgrade to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// KEY: next_reset_at stays the same, but usage RESETS for consumable
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500, // Usage resets for consumable on upgrade
usage: 0,
resetsAt: originalResetAt!,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// PREPAID TESTS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Prepaid - usage RESETS on upgrade, reset_at preserved
*
* Scenario:
* - Pro with prepaid (200 purchased)
* - Advance 15 days mid-cycle
* - Track 50 usage (balance = 150)
* - Upgrade to premium with prepaid (300 purchased)
*
* Expected:
* - Usage RESETS to 0 on upgrade (prepaid behavior)
* - Balance = 300 (new purchased quantity)
* - next_reset_at stays same
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-reset 2: prepaid usage resets, reset_at preserved")}`, async () => {
const customerId = "reset-prepaid-usage-resets";
const proPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro",
items: [proPrepaid],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premium = products.premium({
id: "premium",
items: [premiumPrepaid],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
s.advanceTestClock({ days: 15 }),
],
});
// Wait for webhooks to process and cache to reset
await new Promise((r) => setTimeout(r, 4000));
// Track 50 usage
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await new Promise((r) => setTimeout(r, 2000));
// Get original reset_at before upgrade
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const originalResetAt =
customerBefore.features[TestFeature.Messages]?.next_reset_at;
expect(originalResetAt).toBeDefined();
// Verify state before upgrade: balance = 150
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 150,
usage: 50,
});
// Attach premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 300 }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// KEY: Usage RESETS on prepaid upgrade, reset_at preserved
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 300,
usage: 0,
resetsAt: originalResetAt!,
});
});
/**
* Prepaid vs Allocated comparison - different behaviors
*
* Scenario:
* - Pro with BOTH prepaid messages and allocated users
* - Advance 15 days mid-cycle
* - Track 50 messages and 3 users
* - Upgrade to premium with both
*
* Expected:
* - Messages (prepaid): usage RESETS
* - Users (allocated): usage CARRIES OVER
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-reset 3: prepaid resets, allocated carries over")}`, async () => {
const customerId = "reset-prepaid-vs-allocated";
const proPrepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const proAllocatedUsers = items.allocatedUsers({ includedUsage: 5 });
const pro = products.pro({
id: "pro",
items: [proPrepaidMessages, proAllocatedUsers],
});
const premiumPrepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premiumAllocatedUsers = items.allocatedUsers({ includedUsage: 10 });
const premium = products.premium({
id: "premium",
items: [premiumPrepaidMessages, premiumAllocatedUsers],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
// Wait for webhooks to process and cache to reset
await new Promise((r) => setTimeout(r, 2000));
// Track both features
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 3,
});
await new Promise((r) => setTimeout(r, 2000));
// Verify state before upgrade
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
balance: 150, // 200 - 50
usage: 50,
});
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Users,
includedUsage: 5,
balance: 2, // 5 - 3
usage: 3,
});
// Attach premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// KEY DIFFERENCE:
// Messages (prepaid): RESETS - balance = 200, usage = 0
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
usage: 0,
});
// Users (allocated): CARRIES OVER - balance = 10 - 3 = 7, usage = 3
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 10,
balance: 7,
usage: 3,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// ALLOCATED TESTS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Allocated - reset_at preserved, usage carries over
*
* Scenario:
* - Pro with allocated (5 users included)
* - Advance 15 days mid-cycle
* - Track 3 users
* - Upgrade to Premium (10 users included)
*
* Expected:
* - next_reset_at stays the same
* - Usage carries over (allocated behavior)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-reset 4: allocated preserves reset_at, usage carries over")}`, async () => {
const customerId = "reset-allocated-carries-over";
const proAllocated = items.allocatedUsers({ includedUsage: 5 });
const pro = products.pro({
id: "pro",
items: [proAllocated],
});
const premiumAllocated = items.allocatedUsers({ includedUsage: 10 });
const premium = products.premium({
id: "premium",
items: [premiumAllocated],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 15 }),
],
});
// Wait for webhooks to process and cache to reset
await new Promise((r) => setTimeout(r, 4000));
// Track 3 users
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 3,
});
await new Promise((r) => setTimeout(r, 2000));
// Get original reset_at before upgrade
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const originalResetAt =
customerBefore.features[TestFeature.Users]?.next_reset_at;
expect(originalResetAt).toBeDefined();
// Verify pre-upgrade state
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Users,
includedUsage: 5,
balance: 2, // 5 - 3
usage: 3,
});
// Upgrade to premium
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// KEY: next_reset_at stays same, usage carries over
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 10,
balance: 7, // 10 - 3 (usage carries over for allocated)
usage: 3,
resetsAt: originalResetAt!,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// FREE TO PAID TESTS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Free to Paid - reset_at anchor preserved from free product
*
* Scenario:
* - Free product (50 messages)
* - Advance 15 days mid-cycle
* - Track 20 usage
* - Upgrade to Pro paid ($20/mo, 100 messages)
*
* Expected:
* - next_reset_at anchor preserved from free product
* - Usage resets for consumable
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-reset 5: free to paid preserves reset_at anchor")}`, async () => {
const customerId = "reset-free-to-paid";
const freeMessages = items.monthlyMessages({ includedUsage: 50 });
const free = products.base({
id: "free",
items: [freeMessages],
});
const proMessages = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [proMessages],
});
// Advance 15 days so we're mid-cycle when upgrading
const { autumnV1, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [
s.billing.attach({ productId: free.id }),
s.advanceTestClock({ days: 15 }),
],
});
// Wait for webhooks to process and cache to reset
await new Promise((r) => setTimeout(r, 4000));
// Track some usage on free
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 20,
});
await new Promise((r) => setTimeout(r, 2000));
// Get free product's reset_at - this is the anchor we expect to be preserved
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const freeResetAt =
customerBefore.features[TestFeature.Messages]?.next_reset_at;
expect(freeResetAt).toBeDefined();
// Upgrade to paid
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
// KEY: next_reset_at should preserve the same anchor from free product
const newResetAt = customer.features[TestFeature.Messages]?.next_reset_at;
expect(newResetAt).toBeDefined();
// Reset anchor is preserved - should be the same as the free product's reset_at
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100, // Usage resets for consumable on upgrade
usage: 0,
resetsAt: addMonths(advancedTo, 1).getTime(),
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// INTERVAL CHANGE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Monthly to Annual - reset_at anchor preserved
*
* Scenario:
* - Pro monthly ($20/mo, 100 messages)
* - Advance 15 days mid-cycle
* - Track 30 usage
* - Upgrade to Pro annual ($200/year, 100 messages)
*
* Expected:
* - next_reset_at anchor is preserved from monthly
* - Usage resets for consumable
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-reset 6: monthly to annual preserves reset_at anchor")}`, async () => {
const customerId = "reset-monthly-to-annual";
const proMonthlyMessages = items.monthlyMessages({ includedUsage: 100 });
const proMonthly = products.pro({
id: "pro-monthly",
items: [proMonthlyMessages],
});
const proAnnualMessages = items.monthlyMessages({ includedUsage: 100 });
const proAnnual = products.proAnnual({
id: "pro-annual",
items: [proAnnualMessages],
});
// Advance 15 days so we're mid-cycle when upgrading
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proMonthly, proAnnual] }),
],
actions: [
s.billing.attach({ productId: proMonthly.id }),
s.advanceTestClock({ days: 15 }),
],
});
// Wait for webhooks to process and cache to reset
await new Promise((r) => setTimeout(r, 4000));
// Track some usage
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 30,
});
await new Promise((r) => setTimeout(r, 2000));
// Get monthly reset_at - this is the anchor we expect to be preserved
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const monthlyResetAt =
customerBefore.features[TestFeature.Messages]?.next_reset_at;
expect(monthlyResetAt).toBeDefined();
// Upgrade to annual
await autumnV1.billing.attach({
customer_id: customerId,
product_id: proAnnual.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [proAnnual.id],
notPresent: [proMonthly.id],
});
// KEY: next_reset_at should preserve the same anchor (monthly reset carries over to annual)
const newResetAt = customer.features[TestFeature.Messages]?.next_reset_at;
expect(newResetAt).toBeDefined();
// Reset anchor is preserved - should be the same as the monthly reset_at
expect(newResetAt).toBe(monthlyResetAt);
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100, // Usage resets for consumable on upgrade
usage: 0,
resetsAt: monthlyResetAt!,
});
});