Files
cfw-autumn/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts
2026-01-22 13:42:01 +00:00

356 lines
12 KiB
TypeScript

import { expect, test } from "bun:test";
import { type ApiCustomerV3, ErrCode } from "@autumn/shared";
import {
expectProductActive,
expectProductCanceling,
expectProductNotPresent,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
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";
/**
* Uncancel Basic Tests
*
* Core uncancel functionality and error cases.
* Tests: cancel: null via subscriptions.update()
*/
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Uncancel with scheduled default product
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel: with scheduled default product")}`, async () => {
const customerId = "uncancel-with-default";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
const pro = products.pro({ items: [messagesItem] });
const free = constructProduct({
id: "free",
items: [freeMessagesItem],
type: "free",
isDefault: true,
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })],
});
// Verify pro is canceling 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,
});
// Uncancel via subscriptions.update with cancel: null
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
// Verify pro is now active (not canceling)
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: pro.id,
});
// Scheduled free product should be deleted
await expectProductNotPresent({
customer: customerAfterUncancel,
productId: free.id,
});
// Verify balance unchanged (still 100 from pro)
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
// Verify Stripe subscription is correct (cancel_at cleared, schedule released)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Uncancel already active product (no-op)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel: already active (no-op)")}`, async () => {
const customerId = "uncancel-noop";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify pro is active (not canceling)
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerBefore,
productId: pro.id,
});
// Uncancel on already active product - should be a no-op
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
// Verify pro is still active
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfter,
productId: pro.id,
});
// Balance unchanged
expect(customerAfter.features?.[TestFeature.Messages]?.balance).toBe(100);
// Stripe subscription correct
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Uncancel preserves usage
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel: preserves usage")}`, async () => {
const customerId = "uncancel-usage";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id, timeout: 4000 })],
});
// Track some usage (the timeout waits after track completes)
const messagesUsage = 40;
await autumnV1.track(
{
customer_id: customerId,
feature_id: TestFeature.Messages,
value: messagesUsage,
},
{ timeout: 4000 },
);
// Verify usage tracked
const customerWithUsage =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerWithUsage.features?.[TestFeature.Messages]?.usage).toBe(
messagesUsage,
);
// Cancel pro
await autumnV1.cancel({
customer_id: customerId,
product_id: pro.id,
});
// await autumnV1.subscriptions.update({
// customer_id: customerId,
// product_id: pro.id,
// cancel: "end_of_cycle",
// });
await new Promise((resolve) => setTimeout(resolve, 4000));
// Verify pro is canceling
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
// Uncancel
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
await new Promise((resolve) => setTimeout(resolve, 4000));
// Verify pro is active and usage preserved
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: pro.id,
});
// Usage should be preserved
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.usage).toBe(
messagesUsage,
);
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100 - messagesUsage,
);
// Stripe subscription correct
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Error - cannot uncancel scheduled product
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("error: uncancel scheduled product")}`, async () => {
const customerId = "uncancel-err-scheduled";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const pro = products.pro({ items: [messagesItem] });
const premium = constructProduct({
id: "premium",
items: [premiumMessagesItem],
type: "premium",
isDefault: false,
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.attach({ productId: premium.id })],
});
// Downgrade from premium to pro - pro becomes scheduled
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
// Verify pro is scheduled
const customerAfterDowngrade =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductScheduled({
customer: customerAfterDowngrade,
productId: pro.id,
});
// Try to uncancel the scheduled product - should error
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
},
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 5: Error - cannot uncancel expired product
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("error: uncancel expired product")}`, async () => {
const customerId = "uncancel-err-expired";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
const pro = products.pro({ items: [messagesItem] });
const free = constructProduct({
id: "free",
items: [freeMessagesItem],
type: "free",
isDefault: true,
});
const { autumnV1, ctx, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })],
});
// Advance to next billing cycle so pro expires
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// Verify pro is expired (free should be active now)
const customerAfterAdvance =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotPresent({
customer: customerAfterAdvance,
productId: pro.id,
});
await expectProductActive({
customer: customerAfterAdvance,
productId: free.id,
});
// Try to uncancel the expired product - should error
await expectAutumnError({
errCode: ErrCode.InternalError,
func: async () => {
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
},
});
});