Files
cfw-autumn/server/tests/integration/billing/update-subscription/errors/cancel-errors.test.ts
2026-01-23 08:43:48 +00:00

420 lines
14 KiB
TypeScript

/**
* Cancel Error Tests
*
* Tests for error cases when using cancel functionality in update subscription.
*/
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ErrCode, FreeTrialDuration } from "@autumn/shared";
import {
expectProductCanceling,
expectProductNotPresent,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Cannot cancel a scheduled product
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Premium ($50/mo)
* - User downgrades to Pro ($20/mo) → Premium is canceling, Pro is scheduled
* - User tries to cancel Pro (the scheduled product)
*
* Expected Result:
* - Should return an error - cannot cancel a scheduled product
*/
test.concurrent(`${chalk.yellowBright("error: cannot cancel scheduled product")}`, async () => {
const customerId = "err-cancel-scheduled";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const premiumPriceItem = items.monthlyPrice({ price: 50 });
const premium = products.base({
id: "premium",
items: [messagesItem, premiumPriceItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.attach({ productId: premium.id }),
s.attach({ productId: pro.id }), // Downgrade: premium canceling, pro scheduled
],
});
// Verify 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,
});
// Try to cancel the scheduled product - should fail
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Cancel immediately twice - second cancel is a no-op
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro ($20/mo)
* - User cancels Pro immediately
* - User tries to cancel Pro again
*
* Expected Result:
* - First cancel removes the product
* - Second cancel should fail because product doesn't exist
*/
test.concurrent(`${chalk.yellowBright("error: cancel non-existent product")}`, async () => {
const customerId = "err-cancel-twice";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Cancel pro immediately
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
// Verify pro is gone
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotPresent({
customer: customerAfterCancel,
productId: pro.id,
});
expect(customerAfterCancel.products.length).toBe(0);
// Try to cancel again - should fail because product doesn't exist
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Cannot combine cancel immediately with options
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro ($20/mo) with prepaid messages
* - User tries to cancel immediately while also updating options (quantity)
*
* Expected Result:
* - Should return an error - cannot combine cancel with options
*/
test.concurrent(`${chalk.yellowBright("error: cancel immediately with options")}`, async () => {
const customerId = "err-cancel-with-options";
const messagesItem = items.prepaidMessages({ includedUsage: 0 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({
productId: pro.id,
options: [{ feature_id: "messages", quantity: 100 }],
}),
],
});
// Try to cancel immediately while also updating options - should fail
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
options: [{ feature_id: "messages", quantity: 200 }],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Cannot combine cancel immediately with version
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro ($20/mo)
* - User tries to cancel immediately while also specifying a version
*
* Expected Result:
* - Should return an error - cannot combine cancel with version
*/
test.concurrent(`${chalk.yellowBright("error: cancel immediately with version")}`, async () => {
const customerId = "err-cancel-with-version";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Try to cancel immediately while also specifying version - should fail
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
version: 2,
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Cannot combine cancel immediately with items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro ($20/mo)
* - User tries to cancel immediately while also specifying custom items
*
* Expected Result:
* - Should return an error - cannot combine cancel with items
*/
test.concurrent(`${chalk.yellowBright("error: cancel immediately with items")}`, async () => {
const customerId = "err-cancel-with-items";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({
id: "pro",
items: [messagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Try to cancel immediately while also specifying custom items - should fail
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel_action: "cancel_immediately",
items: [messagesItem],
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 6: Cannot cancel free product with end_of_cycle
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User has a free product (no subscription)
* - User tries to cancel with end_of_cycle
*
* Expected Result:
* - Should return an error - free products can only be canceled immediately
*/
test.concurrent(`${chalk.yellowBright("error: cannot cancel free product with end_of_cycle")}`, async () => {
const customerId = "err-cancel-free-eoc";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({
id: "free",
items: [messagesItem],
isDefault: true,
});
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
// Try to cancel free product with end_of_cycle - should fail
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: free.id,
cancel_action: "cancel_end_of_cycle",
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 7: Cannot cancel one-time product with end_of_cycle
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User has a one-time product (no recurring subscription)
* - User tries to cancel with end_of_cycle
*
* Expected Result:
* - Should return an error - one-time products can only be canceled immediately
*/
test.concurrent(`${chalk.yellowBright("error: cannot cancel one-time product with end_of_cycle")}`, async () => {
const customerId = "err-cancel-onetime-eoc";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const oneTime = products.oneOff({
id: "onetime",
items: [messagesItem],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [oneTime] }),
],
actions: [s.attach({ productId: oneTime.id })],
});
// Try to cancel one-time product with end_of_cycle - should fail
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: oneTime.id,
cancel_action: "cancel_end_of_cycle",
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 8: Cannot pass free_trial when cancel is end_of_cycle
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - User is on Pro with trial
* - User tries to cancel at end of cycle while also passing free_trial
*
* Expected Result:
* - Should return an error - cannot combine cancel: 'end_of_cycle' with free_trial
*/
test.concurrent(`${chalk.yellowBright("error: cannot pass free_trial when cancel is end_of_cycle")}`, async () => {
const customerId = "err-cancel-eoc-with-trial";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
id: "pro-trial",
items: [messagesItem],
trialDays: 7,
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Try to cancel end_of_cycle while also passing free_trial - should fail
await expectAutumnError({
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: proTrial.id,
cancel_action: "cancel_end_of_cycle",
free_trial: {
length: 14,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
});
},
});
});