645 lines
20 KiB
TypeScript
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,
|
|
});
|
|
});
|