Files
cfw-autumn/server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts
2026-04-03 21:57:33 +01:00

645 lines
20 KiB
TypeScript

/**
* Immediate Switch Basic Tests (Attach V2)
*
* Tests for basic upgrade scenarios where a higher-tier product takes effect immediately.
*
* Key behaviors:
* - Upgrade replaces existing product immediately
* - Prorated charge for price difference
* - Scheduled downgrades are cancelled when upgrading
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, atmnToStripeAmount } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductCanceling,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { calculateProratedDiff } from "@tests/integration/billing/utils/proration";
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 ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { timeout } from "@/utils/genUtils";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Free to Pro
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product
* - Upgrade to pro ($20/mo)
*
* Expected Result:
* - Pro is active, free is removed
* - Invoice for pro base price ($20)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 1: free to pro")}`, async () => {
const customerId = "imm-switch-free-to-pro";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [s.billing.attach({ productId: free.id })],
});
// 1. Preview upgrade - verify pro base price ($20)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
});
expect(preview.total).toBe(20);
// 2. Attach pro (upgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [free.id],
});
// Verify messages feature has pro's balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
// Verify invoice matches preview total: $20
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 20,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Pro to Premium
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro ($20/mo)
* - Upgrade to premium ($50/mo)
*
* Expected Result:
* - Premium is active, pro is removed
* - Prorated charge for price difference
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 2: pro to premium")}`, async () => {
const customerId = "imm-switch-pro-to-premium";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// 1. Preview upgrade - verify prorated charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
// At start of cycle, full price difference: $50 - $20 = $30
expect(preview.total).toBe(30);
// 2. Attach premium (upgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify messages feature has premium's balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
await timeout(2000);
// Verify invoices: pro ($20) + upgrade ($30)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 30,
latestInvoiceProductId: premium.id,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Pro to Premium mid-cycle
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro ($20/mo)
* - Advance 15 days
* - Upgrade to premium ($50/mo)
*
* Expected Result:
* - Prorated charge for remaining half of cycle
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 3: pro to premium mid-cycle")}`, async () => {
const customerId = "imm-switch-pro-premium-midcycle";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 15 }),
],
});
// Calculate expected prorated amount using actual billing period from Stripe
const expectedTotal = await calculateProratedDiff({
customerId,
advancedTo,
oldAmount: 20, // Pro base price
newAmount: 50, // Premium base price
});
// 1. Preview upgrade mid-cycle - prorated charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBeCloseTo(expectedTotal, 0);
// 2. Attach premium (upgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify invoices: pro ($20) + prorated upgrade
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Pro to Free (scheduled) to Premium (upgrade cancels scheduled)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro
* - Downgrade to free (scheduled for end of cycle)
* - Upgrade to premium (should cancel scheduled downgrade)
*
* Expected Result:
* - Scheduled downgrade is cancelled
* - Premium is active immediately
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 4: pro to free to premium")}`, async () => {
const customerId = "imm-switch-pro-free-premium";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
});
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.billing.attach({ productId: free.id }), // Downgrade - scheduled
],
});
// Verify scheduled state before upgrade
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerBefore,
productId: pro.id,
});
await expectProductScheduled({
customer: customerBefore,
productId: free.id,
});
// 1. Preview upgrade to premium
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
// Upgrade from pro ($20) to premium ($50) = $30 difference
expect(preview.total).toBe(30);
// 2. Attach premium (upgrade - should cancel scheduled downgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states - premium active, pro and free removed
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id, free.id],
});
// Verify messages has premium's balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Premium to Pro (scheduled) to Ultra (upgrade cancels scheduled)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has premium ($50/mo)
* - Downgrade to pro (scheduled for end of cycle)
* - Upgrade to ultra ($200/mo) - should cancel scheduled downgrade
*
* Expected Result:
* - Scheduled downgrade is cancelled
* - Ultra is active immediately
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 5: premium to pro to ultra")}`, async () => {
const customerId = "imm-switch-premium-pro-ultra";
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const ultraMessagesItem = items.monthlyMessages({ includedUsage: 5000 });
const ultra = products.ultra({
id: "ultra",
items: [ultraMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium, ultra] }),
],
actions: [
s.billing.attach({ productId: premium.id }),
s.billing.attach({ productId: pro.id }), // Downgrade - scheduled
],
});
// Verify scheduled state before upgrade
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerBefore,
productId: premium.id,
});
await expectProductScheduled({
customer: customerBefore,
productId: pro.id,
});
// 1. Preview upgrade to ultra
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: ultra.id,
});
// Upgrade from premium ($50) to ultra ($200) = $150 difference
expect(preview.total).toBe(150);
// 2. Attach ultra (upgrade - should cancel scheduled downgrade)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: ultra.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states - ultra active, premium and pro removed
await expectCustomerProducts({
customer,
active: [ultra.id],
notPresent: [premium.id, pro.id],
});
// Verify messages has ultra's balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 5000,
balance: 5000,
usage: 0,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Invoice line item metadata and price_data
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has free product
* - Upgrade to pro with invoice mode
*
* Expected Result:
* - Invoice line items have correct metadata:
* - autumn_product_id
* - autumn_price_id
* - stripe_product_id (when available)
* - Line items with positive amounts use price_data (when stripe_product_id exists)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 6: invoice line item metadata and price_data")}`, async () => {
const customerId = "imm-switch-metadata-price-data";
const proMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({
id: "pro",
items: [proMessagesItem],
});
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Upgrade from pro to premium with invoice mode to get invoice response
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
// invoice: true,
// finalize_invoice: true,
// enable_product_immediately: true,
redirect_mode: "if_required",
});
// Verify invoice was created
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
// Retrieve the Stripe invoice with line items expanded
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(
result.invoice!.stripe_id,
{ expand: ["lines.data"] },
);
// Verify invoice has line items (charge for premium, refund for pro)
expect(stripeInvoice.lines.data.length).toBeGreaterThan(0);
// Check each line item for metadata and price_data
for (const lineItem of stripeInvoice.lines.data) {
const metadata = lineItem.metadata;
// Verify autumn metadata is present
expect(metadata).toBeDefined();
expect(metadata?.autumn_product_id).toBeDefined();
expect(metadata?.autumn_price_id).toBeDefined();
// Verify shouldUsePriceData logic:
// - Positive amounts with stripe_product_id should use price_data
// - This manifests as the line having pricing.price_details with product reference
// - Negative amounts (refunds) should NOT have price_data
if (lineItem.amount > 0 && metadata?.stripe_product_id) {
expect(lineItem.pricing?.price_details).toBeDefined();
expect(lineItem.pricing?.price_details?.product).toBe(
metadata.stripe_product_id,
);
}
}
// Verify the total is correct (upgrade from $20 to $50 = $30 difference)
expect(stripeInvoice.total).toBe(
atmnToStripeAmount({ amount: 30, currency: "usd" }),
);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Pro Annual to Premium Monthly (interval change = immediate)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has pro annual ($200/year)
* - Switch to premium monthly ($50/mo)
*
* Expected Result:
* - Premium is active immediately (different billing interval = always immediate)
* - Pro annual is removed
* - Credit for unused annual applied against premium charge
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-basic 7: pro annual to premium monthly (interval change)")}`, async () => {
const customerId = "imm-switch-pro-annual-to-premium";
const proAnnualMessages = items.monthlyMessages({ includedUsage: 500 });
const proAnnual = products.proAnnual({
id: "pro-annual",
items: [proAnnualMessages],
});
const premiumMessages = items.monthlyMessages({ includedUsage: 1000 });
const premium = products.premium({
id: "premium",
items: [premiumMessages],
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proAnnual, premium] }),
],
actions: [s.billing.attach({ productId: proAnnual.id })],
});
// 1. Preview switch to premium monthly
// At start of cycle: credit for full annual ($200) exceeds premium monthly ($50)
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBe(0);
// 2. Attach premium (immediate because interval differs: annual -> monthly)
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Premium should be active, pro annual should be gone (immediate switch)
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [proAnnual.id],
});
// Verify messages feature has premium's balance
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 1000,
balance: 1000,
usage: 0,
});
// Verify invoices: proAnnual ($200) + switch invoice ($0, credit exceeds charge)
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 0,
});
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});