414 lines
12 KiB
TypeScript
414 lines
12 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import type { ApiCustomerV3 } from "@autumn/shared";
|
|
import { expectCustomerInvoiceCorrect } from "@tests/billing/utils/expectCustomerInvoiceCorrect";
|
|
import {
|
|
expectProductActive,
|
|
expectProductCanceling,
|
|
expectProductNotPresent,
|
|
expectProductScheduled,
|
|
} from "@tests/billing/utils/expectCustomerProductCorrect";
|
|
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 { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
|
|
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
|
|
import chalk from "chalk";
|
|
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
|
|
|
|
/**
|
|
* Update While Canceling Tests
|
|
*
|
|
* Tests for updating a subscription that is in the process of being canceled
|
|
* or is scheduled to downgrade.
|
|
*/
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// TEST 1: Update while canceling
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Scenario:
|
|
* - User is on Pro (paid product)
|
|
* - Free default product exists
|
|
* - User cancels Pro → free default is scheduled
|
|
* - User updates Pro product items
|
|
*
|
|
* Expected Result:
|
|
* - Pro should remain canceling (canceling state preserved)
|
|
* - Scheduled free product should remain scheduled
|
|
* - Stripe subscription is correct (still set to cancel at period end)
|
|
*/
|
|
test.concurrent(`${chalk.yellowBright("update while canceling")}`, async () => {
|
|
const customerId = "upd-while-canceling";
|
|
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
|
|
// Free is the default product
|
|
const free = constructProduct({
|
|
id: "free",
|
|
items: [messagesItem],
|
|
type: "free",
|
|
isDefault: true,
|
|
});
|
|
|
|
const pro = products.pro({
|
|
id: "pro",
|
|
items: [messagesItem],
|
|
});
|
|
|
|
const { autumnV1, ctx } = await initScenario({
|
|
customerId,
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [free, pro] }),
|
|
],
|
|
actions: [
|
|
s.attach({ productId: "pro" }),
|
|
s.cancel({ productId: "pro" }), // Cancel pro → free scheduled
|
|
],
|
|
});
|
|
|
|
// Verify pro is canceled and free is scheduled
|
|
const customerAfterCancel =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
await expectProductCanceling({
|
|
customer: customerAfterCancel,
|
|
productId: pro.id,
|
|
});
|
|
await expectProductScheduled({
|
|
customer: customerAfterCancel,
|
|
productId: free.id,
|
|
});
|
|
|
|
// Now update pro's items while it's canceling
|
|
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
|
const newPriceItem = items.monthlyPrice({ price: 30 }); // $30/mo instead of $20
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [updatedMessagesItem, newPriceItem],
|
|
});
|
|
|
|
// Should charge prorated difference for price increase ($10 prorated)
|
|
console.log("Preview total (update while canceling):", preview.total);
|
|
|
|
await autumnV1.subscriptions.update({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [updatedMessagesItem, newPriceItem],
|
|
});
|
|
|
|
// Verify state after update
|
|
const customerAfterUpdate =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Pro should remain canceling (canceling state preserved)
|
|
await expectProductCanceling({
|
|
customer: customerAfterUpdate,
|
|
productId: pro.id,
|
|
});
|
|
|
|
// Scheduled free product should remain scheduled
|
|
await expectProductScheduled({
|
|
customer: customerAfterUpdate,
|
|
productId: free.id,
|
|
});
|
|
|
|
// Verify Stripe subscription is correct (still set to cancel at period end)
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
shouldBeCanceled: true,
|
|
});
|
|
|
|
// Verify invoices
|
|
expectCustomerInvoiceCorrect({
|
|
customer: customerAfterUpdate,
|
|
count: 2, // Initial pro attach + update
|
|
latestTotal: preview.total,
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// TEST 2: Update while downgrading
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Scenario:
|
|
* - User is on Premium
|
|
* - User downgrades from Premium → Pro (Premium is canceling, Pro is scheduled)
|
|
* - User updates the canceling Premium product
|
|
*
|
|
* Expected Result:
|
|
* - Premium product should remain canceling (with updated items)
|
|
* - Scheduled Pro product should remain scheduled
|
|
* - After advancing test clock, Pro should become active
|
|
*/
|
|
test.concurrent(`${chalk.yellowBright("update while downgrading")}`, async () => {
|
|
const customerId = "upd-while-downgrading";
|
|
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
const consumableItem = items.consumableMessages({ includedUsage: 50 });
|
|
|
|
// Premium product ($50/mo)
|
|
const premium = constructProduct({
|
|
id: "premium",
|
|
items: [consumableItem],
|
|
type: "premium",
|
|
isDefault: false,
|
|
});
|
|
|
|
// Pro product ($20/mo)
|
|
const pro = products.pro({
|
|
id: "pro",
|
|
items: [messagesItem],
|
|
});
|
|
|
|
const { autumnV1, ctx, testClockId } = await initScenario({
|
|
customerId,
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [premium, pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "premium" })],
|
|
});
|
|
|
|
// Verify premium is active
|
|
const customerAfterAttach =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
await expectProductActive({
|
|
customer: customerAfterAttach,
|
|
productId: premium.id,
|
|
});
|
|
|
|
// User downgrades from premium to pro (premium becomes canceling, pro is scheduled)
|
|
await autumnV1.attach({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
});
|
|
|
|
// Verify premium is canceled and pro is scheduled
|
|
const customerAfterDowngrade =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
await expectProductCanceling({
|
|
customer: customerAfterDowngrade,
|
|
productId: premium.id,
|
|
});
|
|
await expectProductScheduled({
|
|
customer: customerAfterDowngrade,
|
|
productId: pro.id,
|
|
});
|
|
|
|
// Now update the canceling premium's items (not the scheduled pro)
|
|
const updatedConsumableItem = items.consumableMessages({
|
|
includedUsage: 100,
|
|
}); // Increase from 50 to 100
|
|
const newPriceItem = items.monthlyPrice({ price: 60 }); // $60/mo instead of $50
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate({
|
|
customer_id: customerId,
|
|
product_id: premium.id,
|
|
items: [updatedConsumableItem, newPriceItem],
|
|
});
|
|
|
|
console.log("Preview total (update canceling product):", preview.total);
|
|
|
|
await autumnV1.subscriptions.update({
|
|
customer_id: customerId,
|
|
product_id: premium.id,
|
|
items: [updatedConsumableItem, newPriceItem],
|
|
});
|
|
|
|
// Verify state after update
|
|
const customerAfterUpdate =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
console.log("Products after update:", customerAfterUpdate.products);
|
|
|
|
// Premium product should remain canceling (with updated items)
|
|
await expectProductCanceling({
|
|
customer: customerAfterUpdate,
|
|
productId: premium.id,
|
|
});
|
|
|
|
// Scheduled pro product should remain scheduled
|
|
await expectProductScheduled({
|
|
customer: customerAfterUpdate,
|
|
productId: pro.id,
|
|
});
|
|
|
|
// Verify Stripe subscription is correct (still set to cancel at period end)
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
subCount: 1,
|
|
});
|
|
|
|
// Verify invoices - should have initial premium attach + update
|
|
expectCustomerInvoiceCorrect({
|
|
customer: customerAfterUpdate,
|
|
count: 2,
|
|
latestTotal: preview.total,
|
|
});
|
|
|
|
// Advance to next billing cycle
|
|
await advanceToNextInvoice({
|
|
stripeCli: ctx.stripeCli,
|
|
testClockId: testClockId!,
|
|
});
|
|
|
|
// After advancing, pro should be active and premium should be gone
|
|
const customerAfterAdvance =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
console.log("Products after advance:", customerAfterAdvance.products);
|
|
|
|
// Pro should now be active
|
|
await expectProductActive({
|
|
customer: customerAfterAdvance,
|
|
productId: pro.id,
|
|
});
|
|
|
|
// Premium should no longer be present
|
|
await expectProductNotPresent({
|
|
customer: customerAfterAdvance,
|
|
productId: premium.id,
|
|
});
|
|
});
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// TEST 3: Update while canceling (preserves usage)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Scenario:
|
|
* - User is on Pro with some usage tracked
|
|
* - User cancels Pro → free default is scheduled
|
|
* - User updates Pro product items (increases included usage)
|
|
*
|
|
* Expected Result:
|
|
* - Pro should remain canceling (canceling state preserved)
|
|
* - Scheduled free product should remain scheduled
|
|
* - Usage should be preserved
|
|
* - Stripe subscription is correct (still set to cancel at period end)
|
|
*/
|
|
test.concurrent(`${chalk.yellowBright("update while canceling: preserves usage")}`, async () => {
|
|
const customerId = "upd-while-cancel-usage";
|
|
|
|
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
|
|
|
|
// Free is the default product
|
|
const free = constructProduct({
|
|
id: "free",
|
|
items: [messagesItem],
|
|
type: "free",
|
|
isDefault: true,
|
|
});
|
|
|
|
const pro = products.pro({
|
|
id: "pro",
|
|
items: [messagesItem],
|
|
});
|
|
|
|
const { autumnV1, ctx } = await initScenario({
|
|
customerId,
|
|
setup: [
|
|
s.customer({ paymentMethod: "success" }),
|
|
s.products({ list: [free, pro] }),
|
|
],
|
|
actions: [s.attach({ productId: "pro" })],
|
|
});
|
|
|
|
// Track some usage
|
|
const messagesUsage = 50;
|
|
await autumnV1.track(
|
|
{
|
|
customer_id: customerId,
|
|
feature_id: TestFeature.Messages,
|
|
value: messagesUsage,
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Verify usage tracked
|
|
const customerWithUsage =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
expect(customerWithUsage.features[TestFeature.Messages].usage).toBe(
|
|
messagesUsage,
|
|
);
|
|
|
|
// Cancel pro → free scheduled
|
|
await autumnV1.cancel({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
});
|
|
|
|
// Verify pro is canceled and free is scheduled
|
|
const customerAfterCancel2 =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
await expectProductCanceling({
|
|
customer: customerAfterCancel2,
|
|
productId: pro.id,
|
|
});
|
|
await expectProductScheduled({
|
|
customer: customerAfterCancel2,
|
|
productId: free.id,
|
|
});
|
|
|
|
// Now update pro's items while it's canceling
|
|
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
|
|
|
|
const preview = await autumnV1.subscriptions.previewUpdate({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [updatedMessagesItem, items.monthlyPrice()],
|
|
});
|
|
|
|
console.log(
|
|
"Preview total (update while canceling with usage):",
|
|
preview.total,
|
|
);
|
|
|
|
await autumnV1.subscriptions.update({
|
|
customer_id: customerId,
|
|
product_id: pro.id,
|
|
items: [updatedMessagesItem, items.monthlyPrice()],
|
|
});
|
|
|
|
// Verify state after update
|
|
const customerAfterUpdate2 =
|
|
await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
|
|
|
// Pro should remain canceling (canceling state preserved)
|
|
await expectProductCanceling({
|
|
customer: customerAfterUpdate2,
|
|
productId: pro.id,
|
|
});
|
|
|
|
// Scheduled free product should remain scheduled
|
|
await expectProductScheduled({
|
|
customer: customerAfterUpdate2,
|
|
productId: free.id,
|
|
});
|
|
|
|
// Usage should be preserved
|
|
expect(customerAfterUpdate2.features[TestFeature.Messages].usage).toBe(
|
|
messagesUsage,
|
|
);
|
|
|
|
// Verify Stripe subscription is correct (still set to cancel at period end)
|
|
await expectSubToBeCorrect({
|
|
db: ctx.db,
|
|
customerId,
|
|
org: ctx.org,
|
|
env: ctx.env,
|
|
shouldBeCanceled: true,
|
|
});
|
|
});
|