test: add update subscription uncancel tests
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
5
server/tests/scenarios/README.md
Normal file
5
server/tests/scenarios/README.md
Normal 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.
|
||||
@@ -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,
|
||||
// });
|
||||
});
|
||||
Reference in New Issue
Block a user