test: add update subscription uncancel tests

This commit is contained in:
Charlie Lamb
2026-01-22 10:59:30 +00:00
parent c81aa66d37
commit 2444a9a43d
7 changed files with 1943 additions and 0 deletions

View File

@@ -0,0 +1,337 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import {
expectProductActive,
expectProductCanceling,
} from "@tests/integration/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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
/**
* Uncancel Add-on Tests
*
* Tests for uncanceling add-on products and multi-subscription scenarios.
*/
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Uncancel add-on while main is active
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel addon: main active")}`, async () => {
const customerId = "uncancel-addon-main-active";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 50 });
const pro = products.pro({ items: [messagesItem] });
const addon = constructProduct({
id: "addon",
items: [creditsItem],
type: "pro",
isDefault: false,
isAddOn: true,
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attach({ productId: addon.id }),
s.cancel({ productId: addon.id }),
],
});
// Verify pro is active and addon is canceling
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductCanceling({
customer: customerAfterCancel,
productId: addon.id,
});
// Uncancel the addon
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: addon.id,
cancel: null,
});
// Verify addon is now active, pro unchanged
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: addon.id,
});
await expectProductActive({
customer: customerAfterUncancel,
productId: pro.id,
});
// Verify balances
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
expect(customerAfterUncancel.features?.[TestFeature.Credits]?.balance).toBe(
50,
);
// Verify Stripe subscription is correct
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Uncancel main while add-on is canceling
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel main: addon canceling")}`, async () => {
const customerId = "uncancel-main-addon-cancel";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 50 });
const pro = products.pro({ items: [messagesItem] });
const addon = constructProduct({
id: "addon",
items: [creditsItem],
type: "pro",
isDefault: false,
isAddOn: true,
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attach({ productId: addon.id }),
s.cancel({ productId: pro.id }),
s.cancel({ productId: addon.id }),
],
});
// Verify both are canceling
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductCanceling({
customer: customerAfterCancel,
productId: addon.id,
});
// Uncancel only the main product
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
// Verify main is active, addon still canceling
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: pro.id,
});
await expectProductCanceling({
customer: customerAfterUncancel,
productId: addon.id,
});
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Uncancel both main and add-on
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel both: main and addon")}`, async () => {
const customerId = "uncancel-both";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 50 });
const pro = products.pro({ items: [messagesItem] });
const addon = constructProduct({
id: "addon",
items: [creditsItem],
type: "pro",
isDefault: false,
isAddOn: true,
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attach({ productId: addon.id }),
s.cancel({ productId: pro.id }),
s.cancel({ productId: addon.id }),
],
});
// Verify both are canceling
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductCanceling({
customer: customerAfterCancel,
productId: addon.id,
});
// Uncancel both products
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: addon.id,
cancel: null,
});
// Verify both are active
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: pro.id,
});
await expectProductActive({
customer: customerAfterUncancel,
productId: addon.id,
});
// Verify balances
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
expect(customerAfterUncancel.features?.[TestFeature.Credits]?.balance).toBe(
50,
);
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Uncancel product on separate subscription
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel: separate subscriptions")}`, async () => {
const customerId = "uncancel-separate-subs";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const creditsItem = items.monthlyCredits({ includedUsage: 50 });
// Main product - monthly billing
const pro = products.pro({ items: [messagesItem] });
// Add-on with different billing - will create separate subscription
const addon = constructProduct({
id: "addon",
items: [creditsItem],
type: "pro",
isDefault: false,
isAddOn: true,
});
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attach({ productId: addon.id }),
],
});
// Cancel only the addon
await autumnV1.cancel({
customer_id: customerId,
product_id: addon.id,
});
// Verify addon is canceling, pro still active
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductCanceling({
customer: customerAfterCancel,
productId: addon.id,
});
// Uncancel the addon
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: addon.id,
cancel: null,
});
// Verify both active
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: pro.id,
});
await expectProductActive({
customer: customerAfterUncancel,
productId: addon.id,
});
// Verify Stripe - main subscription should not have been affected
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});

View File

@@ -0,0 +1,355 @@
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,
});
},
});
});

View File

@@ -0,0 +1,378 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, ms } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductCanceling,
expectProductNotPresent,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductTrialing,
getTrialEndsAt,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
/**
* Uncancel Combined Tests
*
* Tests for uncancel combined with other update operations.
* Tests: cancel: null + options, cancel: null + items, cancel: null + trialing
*/
// ===============================================================================
// TEST 1: Uncancel + update quantity
// ===============================================================================
/**
* Scenario:
* - User is on Pro with some usage tracked
* - User cancels Pro -> free default is scheduled
* - User uncancels AND updates quantity in the same request
*
* Expected Result:
* - Pro should be active (not canceling)
* - Scheduled free product should be deleted
* - Quantity should be updated
* - Usage should be preserved
*/
test.concurrent(`${chalk.yellowBright("uncancel + update quantity")}`, async () => {
const customerId = "uncancel-plus-qty";
const prepaidItem = items.prepaidMessages({ includedUsage: 0 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
const pro = products.pro({ items: [prepaidItem] });
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,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// 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: 2000 },
);
// 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,
});
// 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 AND update quantity in the same request
const newQuantity = 200;
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel: null,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
options: [{ feature_id: TestFeature.Messages, quantity: newQuantity }],
});
// 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,
});
// Balance should be updated (new quantity minus usage)
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
newQuantity - messagesUsage,
);
// Usage should be preserved
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.usage).toBe(
messagesUsage,
);
// Verify Stripe subscription is correct
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
// Verify invoices
expectCustomerInvoiceCorrect({
customer: customerAfterUncancel,
count: 2, // Initial attach + update
latestTotal: preview.total,
});
});
// ===============================================================================
// TEST 2: Uncancel + custom plan (items)
// ===============================================================================
/**
* Scenario:
* - User is on Pro
* - User cancels Pro -> free default is scheduled
* - User uncancels AND provides custom items in the same request
*
* Expected Result:
* - Pro should be active with custom items
* - Scheduled free product should be deleted
*/
test.concurrent(`${chalk.yellowBright("uncancel + custom plan (items)")}`, async () => {
const customerId = "uncancel-plus-items";
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 })],
});
// Cancel pro
await autumnV1.cancel({
customer_id: customerId,
product_id: 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 AND provide custom items in the same request
const updatedMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const newPriceItem = items.monthlyPrice({ price: 30 });
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel: null,
items: [updatedMessagesItem, newPriceItem],
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
items: [updatedMessagesItem, newPriceItem],
});
// 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,
});
// Balance should reflect the custom plan (200 messages)
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
200,
);
// Verify Stripe subscription is correct
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
// Verify invoices
expectCustomerInvoiceCorrect({
customer: customerAfterUncancel,
count: 2, // Initial attach + update
latestTotal: preview.total,
});
});
// ===============================================================================
// TEST 3: Uncancel trialing product
// ===============================================================================
/**
* Scenario:
* - User is on Pro with trial (14 days)
* - User cancels Pro while trialing -> product becomes trialing + canceling
* - User uncancels
*
* Expected Result:
* - Pro should be trialing (still in trial period)
* - Trial end time should be unchanged
* - Product should no longer be canceling
*/
test.concurrent(`${chalk.yellowBright("uncancel trialing product")}`, async () => {
const customerId = "uncancel-trialing";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
items: [messagesItem],
id: "pro-trial",
trialDays: 14,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
],
actions: [s.attach({ productId: proTrial.id })],
});
// Verify initially trialing
const customerAfterAttach =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerAfterAttach,
productId: proTrial.id,
});
// Get the trial end time before cancel
const trialEndsAtBefore = await getTrialEndsAt({
customer: customerAfterAttach,
productId: proTrial.id,
});
expect(trialEndsAtBefore).toBeDefined();
// Cancel the trialing product
await autumnV1.cancel({
customer_id: customerId,
product_id: proTrial.id,
});
// Verify product is canceling (canceled flag set, but still trialing)
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: proTrial.id,
});
// Uncancel
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: proTrial.id,
cancel: null,
});
// Verify product is still trialing and no longer canceling
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should be trialing (active with trial status)
await expectProductTrialing({
customer: customerAfterUncancel,
productId: proTrial.id,
});
// Get the trial end time after uncancel
const trialEndsAtAfter = await getTrialEndsAt({
customer: customerAfterUncancel,
productId: proTrial.id,
});
// Trial end time should be unchanged (within tolerance)
expect(trialEndsAtAfter).toBeDefined();
expect(
Math.abs(trialEndsAtAfter! - trialEndsAtBefore!) < ms.minutes(10),
).toBe(true);
// Balance should be unchanged
expect(customerAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
// Verify Stripe subscription is correct (not set to cancel)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});

View File

@@ -0,0 +1,539 @@
import { expect, test } from "bun:test";
import { type ApiCustomerV3, FreeTrialDuration, ms } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectProductActive,
expectProductCanceling,
expectProductNotPresent,
expectProductScheduled,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import {
expectProductNotTrialing,
expectProductTrialing,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
/**
* Uncancel Edge Cases Tests
*
* Critical edge cases for update subscription that combine multiple operations.
* These tests cover parameter combinations that weren't previously tested.
*/
// ===============================================================================
// TEST 1: Uncancel + version upgrade
// ===============================================================================
/**
* User is on Pro v1 (canceling), uncancels AND upgrades to v2 in one request.
* Verifies: version change + uncancel combined, proration, scheduled product deleted.
*/
test.concurrent(`${chalk.yellowBright("uncancel + version upgrade")}`, async () => {
const customerId = "uncancel-version-upgrade";
const messagesItemV1 = items.monthlyMessages({ includedUsage: 100 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
// products.pro already includes $20/month price
const pro = products.pro({ items: [messagesItemV1] });
const free = constructProduct({
id: "free",
items: [freeMessagesItem],
type: "free",
isDefault: true,
});
const { autumnV1 } = 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,
});
// Create v2 with increased features (price stays $20 from pro)
const messagesItemV2 = items.monthlyMessages({ includedUsage: 200 });
await autumnV1.products.update(pro.id, {
items: [messagesItemV2],
});
// Preview uncancel + version upgrade (same price, just feature change)
await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel: null,
version: 2,
});
// Execute uncancel + version upgrade
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
version: 2,
});
// Verify pro is now active (not canceling)
const customerAfterUpdate =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUpdate,
productId: pro.id,
});
// Scheduled free product should be deleted
await expectProductNotPresent({
customer: customerAfterUpdate,
productId: free.id,
});
// Should have v2 features (200 messages)
expectCustomerFeatureCorrect({
customer: customerAfterUpdate,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
});
});
// ===============================================================================
// TEST 2: Uncancel + add trial (paid product enters trial)
// ===============================================================================
/**
* User is on paid Pro (canceling, not trialing), uncancels AND adds a trial.
* Verifies: gets refund for entering trial, trial end set correctly.
*/
test.concurrent(`${chalk.yellowBright("uncancel + add trial")}`, async () => {
const customerId = "uncancel-add-trial";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
// products.pro already includes $20/month price
const pro = products.pro({ items: [messagesItem] });
const free = constructProduct({
id: "free",
items: [freeMessagesItem],
type: "free",
isDefault: true,
});
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })],
});
// Verify pro is canceling (not trialing)
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductNotTrialing({
customer: customerAfterCancel,
productId: pro.id,
});
// Preview uncancel + add trial
const trialDays = 14;
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel: null,
free_trial: {
length: trialDays,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
});
// Should refund for entering trial (negative total)
expect(preview.total).toBeLessThan(0);
// next_cycle should show when trial ends
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(trialDays),
total: 20, // pro price
});
// Execute uncancel + add trial
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
free_trial: {
length: trialDays,
duration: FreeTrialDuration.Day,
card_required: true,
unique_fingerprint: false,
},
});
// Verify pro is now active AND trialing
const customerAfterUpdate =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUpdate,
productId: pro.id,
});
await expectProductTrialing({
customer: customerAfterUpdate,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(trialDays),
});
// Scheduled free should be deleted
await expectProductNotPresent({
customer: customerAfterUpdate,
productId: free.id,
});
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ===============================================================================
// TEST 3: Remove trial while canceling (cancel state preserved)
// ===============================================================================
/**
* User is on Pro (trialing AND canceling), removes trial but does NOT uncancel.
* Verifies: cancel state is preserved, charged for ending trial.
*/
test.concurrent(`${chalk.yellowBright("remove trial while canceling: cancel preserved")}`, async () => {
const customerId = "remove-trial-while-cancel";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
// products.proWithTrial includes $20/month price + trial
const pro = products.proWithTrial({
items: [messagesItem],
id: "pro-trial",
trialDays: 14,
});
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 trialing + canceling
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductTrialing({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: free.id,
});
// Preview removing trial (NOT uncanceling)
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
free_trial: null,
});
// Should charge for ending trial
expect(preview.total).toBeGreaterThan(0);
// Execute remove trial
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
free_trial: null,
});
// Verify pro is STILL canceling but NOT trialing
const customerAfterUpdate =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterUpdate,
productId: pro.id,
});
await expectProductNotTrialing({
customer: customerAfterUpdate,
productId: pro.id,
});
// Scheduled free should STILL be scheduled
await expectProductScheduled({
customer: customerAfterUpdate,
productId: free.id,
});
// Verify invoices (don't assert latestTotal - proration varies based on timing)
expectCustomerInvoiceCorrect({
customer: customerAfterUpdate,
count: 2,
});
// Verify Stripe subscription (should still be set to cancel)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: true,
});
});
// ===============================================================================
// TEST 4: Uncancel during downgrade (Premium -> Pro, uncancel Premium)
// ===============================================================================
/**
* User is on Premium ($50), downgrades to Pro (Premium canceling, Pro scheduled).
* User uncancels Premium. Verifies: Premium active, scheduled Pro deleted.
*/
test.concurrent(`${chalk.yellowBright("uncancel during downgrade")}`, async () => {
const customerId = "uncancel-downgrade";
const premiumMessagesItem = items.monthlyMessages({ includedUsage: 500 });
const proMessagesItem = items.monthlyMessages({ includedUsage: 100 });
// constructProduct with type: "premium" adds $50/month price
const premium = constructProduct({
id: "premium",
items: [premiumMessagesItem],
type: "premium",
isDefault: false,
});
// products.pro adds $20/month price
const pro = products.pro({ items: [proMessagesItem] });
const { autumnV1, ctx } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [s.attach({ productId: premium.id })],
});
// Verify premium is active
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerBefore,
productId: premium.id,
});
// Downgrade from Premium to Pro
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id,
});
// Verify Premium is canceling, 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,
});
// Uncancel Premium
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premium.id,
cancel: null,
});
// Verify Premium is active, Pro is deleted
const customerAfterUncancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUncancel,
productId: premium.id,
});
await expectProductNotPresent({
customer: customerAfterUncancel,
productId: pro.id,
});
// Features should be unchanged
expectCustomerFeatureCorrect({
customer: customerAfterUncancel,
featureId: TestFeature.Messages,
includedUsage: 500,
balance: 500,
usage: 0,
});
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ===============================================================================
// TEST 5: Uncancel + custom items + invoice mode
// ===============================================================================
/**
* User is on Pro (canceling), uncancels with custom items using invoice mode.
* Verifies: invoice created and paid, product updated, scheduled product deleted.
*/
test.concurrent(`${chalk.yellowBright("uncancel + items + invoice mode")}`, async () => {
const customerId = "uncancel-invoice-mode";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const freeMessagesItem = items.monthlyMessages({ includedUsage: 10 });
// products.pro adds $20/month price
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 canceling
const customerAfterCancel =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductCanceling({
customer: customerAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: customerAfterCancel,
productId: free.id,
});
// Custom items: more features + higher price ($40)
const customMessagesItem = items.monthlyMessages({ includedUsage: 200 });
const customPriceItem = items.monthlyPrice({ price: 40 });
// Preview
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
cancel: null,
items: [customMessagesItem, customPriceItem],
invoice: true,
finalize_invoice: true,
});
// Should charge prorated difference ($40 - $20 = $20 prorated)
expect(preview.total).toBeGreaterThan(0);
// Execute uncancel + items with invoice mode
const updateResult = await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: null,
items: [customMessagesItem, customPriceItem],
invoice: true,
finalize_invoice: true,
});
// Should return invoice info (finalized but awaiting payment)
expect(updateResult.invoice).toBeDefined();
expect(updateResult.invoice?.status).toBe("open");
// Verify pro is active with custom items
const customerAfterUpdate =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfterUpdate,
productId: pro.id,
});
// Scheduled free should be deleted
await expectProductNotPresent({
customer: customerAfterUpdate,
productId: free.id,
});
// Should have custom features
expectCustomerFeatureCorrect({
customer: customerAfterUpdate,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
});
// Verify invoices (don't assert latestTotal - proration varies based on timing)
expectCustomerInvoiceCorrect({
customer: customerAfterUpdate,
count: 2,
});
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});

View File

@@ -0,0 +1,293 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } 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 { 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";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts";
/**
* Uncancel Entity Tests
*
* Tests for uncanceling entity-scoped products.
*/
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Uncancel single entity while other entity is active
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel entity: other entity active")}`, async () => {
const customerId = "uncancel-entity-other-active";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { autumnV1, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: pro.id, entityIndex: 1 }),
],
});
// Cancel only entity 1's product
await autumnV1.cancel({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
});
// Verify entity 1 is canceling, entity 2 is active
const entity1AfterCancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
const entity2AfterCancel = await autumnV1.entities.get(
customerId,
entities[1].id,
);
await expectProductCanceling({
customer: entity1AfterCancel,
productId: pro.id,
});
await expectProductActive({
customer: entity2AfterCancel,
productId: pro.id,
});
// Uncancel entity 1
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel: null,
});
// Verify both entities are now active
const entity1AfterUncancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
const entity2AfterUncancel = await autumnV1.entities.get(
customerId,
entities[1].id,
);
await expectProductActive({
customer: entity1AfterUncancel,
productId: pro.id,
});
await expectProductActive({
customer: entity2AfterUncancel,
productId: pro.id,
});
// Verify balances for both entities
expect(entity1AfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
expect(entity2AfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Uncancel all entities
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel: all entities")}`, async () => {
const customerId = "uncancel-all-entities";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { autumnV1, ctx, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: pro.id, entityIndex: 1 }),
],
});
// Cancel both entities
await autumnV1.cancel({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
});
await autumnV1.cancel({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[1].id,
});
// Verify both are canceling
const entity1AfterCancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
const entity2AfterCancel = await autumnV1.entities.get(
customerId,
entities[1].id,
);
await expectProductCanceling({
customer: entity1AfterCancel,
productId: pro.id,
});
await expectProductCanceling({
customer: entity2AfterCancel,
productId: pro.id,
});
// Uncancel both entities
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel: null,
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[1].id,
cancel: null,
});
// Verify both are active
const entity1AfterUncancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
const entity2AfterUncancel = await autumnV1.entities.get(
customerId,
entities[1].id,
);
await expectProductActive({
customer: entity1AfterUncancel,
productId: pro.id,
});
await expectProductActive({
customer: entity2AfterUncancel,
productId: pro.id,
});
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Uncancel entity with scheduled default product
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel entity: with scheduled default")}`, async () => {
const customerId = "uncancel-entity-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, entities } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 1, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: pro.id, entityIndex: 0 })],
});
// Cancel entity's pro - should schedule free default
await autumnV1.cancel({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
});
// Verify pro is canceling and free is scheduled
const entityAfterCancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectProductCanceling({
customer: entityAfterCancel,
productId: pro.id,
});
await expectProductScheduled({
customer: entityAfterCancel,
productId: free.id,
});
// Uncancel the entity's pro
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel: null,
});
// Verify pro is active and free is deleted
const entityAfterUncancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectProductActive({
customer: entityAfterUncancel,
productId: pro.id,
});
await expectProductNotPresent({
customer: entityAfterUncancel,
productId: free.id,
});
// Verify balance
expect(entityAfterUncancel.features?.[TestFeature.Messages]?.balance).toBe(
100,
);
// Verify Stripe subscription
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
shouldBeCanceled: false,
});
});

View File

@@ -0,0 +1,5 @@
This folder contains shared test scenarios for efficient setup and teardown of common test cases.
This is NOT intended to be used for unit or integration tests.
We keep the `.test.ts` postfix so that we can easily run a setup with the test runner. e.g. `ctrl + enter` to run the scenario.

View File

@@ -0,0 +1,36 @@
import { test } from "bun:test";
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";
/**
* Uncancel Tests (cancel: null)
*
* Tests the uncancel functionality which removes a scheduled cancellation
* from a subscription via the update subscription API.
*
* Usage: subscriptions.update({ customer_id, product_id, cancel: null })
*/
test(`${chalk.yellowBright("uncancel: basic - canceling product → uncancel → active")}`, async () => {
const customerId = "uncancel-basic";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id }), s.cancel({ productId: pro.id })],
});
// Uncancel via subscriptions.update with cancel: null
// await autumnV1.subscriptions.update({
// customer_id: customerId,
// product_id: pro.id,
// cancel: null,
// });
});