Merge branch 'feat/cancel-v2' of https://github.com/useautumn/autumn into feat/cancel-v2

This commit is contained in:
John Yeo
2026-01-23 08:22:18 +00:00
31 changed files with 2290 additions and 46 deletions

View File

@@ -27,4 +27,6 @@ BUN_PARALLEL_V2 \
'update-subscription/multi-product' \
'update-subscription/update-quantity' \
'update-subscription/version-update' \
--max=3
'update-subscription/uncancel' \
--max=2

View File

@@ -7,6 +7,7 @@ import type {
Price,
StripeDiscountWithCoupon,
} from "@autumn/shared";
import type { CancelMode } from "@shared/api/common/cancelMode";
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
import type Stripe from "stripe";
import { z } from "zod/v4";
@@ -53,9 +54,10 @@ export interface BillingContext {
// Trial context
trialContext?: TrialContext;
isCustom?: boolean;
}
export type CancelMode = "immediately" | "end_of_cycle";
// Cancel mode (used by update subscription for uncancel)
cancelMode?: CancelMode;
}
export interface UpdateSubscriptionBillingContext extends BillingContext {
customerProduct: FullCusProduct; // target customer product

View File

@@ -28,7 +28,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
billingContext: BillingContext;
subscriptionScheduleAction: StripeSubscriptionScheduleAction;
stripeSubscription?: Stripe.Subscription;
}): Promise<Stripe.SubscriptionSchedule> => {
}): Promise<Stripe.SubscriptionSchedule | null> => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
@@ -36,15 +36,13 @@ export const executeStripeSubscriptionScheduleAction = async ({
`[executeStripeSubscriptionScheduleAction] Executing subscription schedule operation: ${subscriptionScheduleAction.type}`,
);
// Log phases
logSubscriptionScheduleAction({
ctx,
billingContext,
subscriptionScheduleAction,
});
switch (subscriptionScheduleAction.type) {
case "create": {
logSubscriptionScheduleAction({
ctx,
billingContext,
subscriptionScheduleAction,
});
const { params } = subscriptionScheduleAction;
// If there's an existing subscription, create from it first then update with phases
@@ -73,14 +71,23 @@ export const executeStripeSubscriptionScheduleAction = async ({
}
case "update":
logSubscriptionScheduleAction({
ctx,
billingContext,
subscriptionScheduleAction,
});
return await stripeCli.subscriptionSchedules.update(
subscriptionScheduleAction.stripeSubscriptionScheduleId,
subscriptionScheduleAction.params,
);
case "release":
return await stripeCli.subscriptionSchedules.release(
ctx.logger.debug(
`[executeStripeSubscriptionScheduleAction] Releasing schedule: ${subscriptionScheduleAction.stripeSubscriptionScheduleId}`,
);
await stripeCli.subscriptionSchedules.release(
subscriptionScheduleAction.stripeSubscriptionScheduleId,
);
return null;
}
};

View File

@@ -32,11 +32,9 @@ export const setupStripeDiscountsForBilling = ({
if (!coupon || typeof coupon === "string") return [];
// Normalize to StripeDiscountWithCoupon format
// Extract the coupon and put it under source.coupon
const { coupon: _coupon, ...discountWithoutCoupon } = customerDiscount;
return [
{
...discountWithoutCoupon,
...customerDiscount,
source: { coupon },
} as StripeDiscountWithCoupon,
];

View File

@@ -21,4 +21,4 @@ export const subToDiscounts = ({
.filter(notNullish);
return discounts;
};
};

View File

@@ -43,7 +43,10 @@ export const logSubscriptionScheduleAction = ({
}: {
ctx: AutumnContext;
billingContext: BillingContext;
subscriptionScheduleAction: StripeSubscriptionScheduleAction;
subscriptionScheduleAction: Extract<
StripeSubscriptionScheduleAction,
{ type: "create" | "update" }
>;
}): void => {
if (subscriptionScheduleAction.type === "release") {
ctx.logger.debug(

View File

@@ -22,7 +22,7 @@ export const buildStripeSubscriptionUpdateAction = ({
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
subscriptionCancelAt?: number;
}): StripeSubscriptionAction | undefined => {
const { stripeSubscription, trialContext } = billingContext;
const { stripeSubscription, trialContext, cancelMode } = billingContext;
if (!stripeSubscription) {
throw new Error(
@@ -48,9 +48,14 @@ export const buildStripeSubscriptionUpdateAction = ({
shouldUnsetTrialEnd = !scheduleManagesSubscription && trialEndsAt === null;
}
// Only set cancel_at if it differs from current value
// Determine cancel_at handling:
// 1. Clear cancel_at if uncancel mode and currently has a cancel_at
// 2. Set cancel_at if explicitly provided and differs from current value
const currentCancelAt = stripeSubscription.cancel_at;
const shouldClearCancelAt =
cancelMode === "uncancel" && currentCancelAt !== null;
const shouldSetCancelAt =
!shouldClearCancelAt &&
subscriptionCancelAt !== undefined &&
subscriptionCancelAt !== currentCancelAt;
@@ -61,7 +66,11 @@ export const buildStripeSubscriptionUpdateAction = ({
: shouldUnsetTrialEnd
? "now"
: undefined,
cancel_at: shouldSetCancelAt ? subscriptionCancelAt : undefined,
cancel_at: shouldClearCancelAt
? null
: shouldSetCancelAt
? subscriptionCancelAt
: undefined,
proration_behavior: "none",
};

View File

@@ -0,0 +1,23 @@
import type { UpdateSubscriptionV0Params } from "@shared/api/billing/updateSubscription/updateSubscriptionV0Params";
import type { CancelMode } from "@shared/api/common/cancelMode";
/**
* Setup cancel mode from params
* @param params - The params
* Converts cancel param to internal cancel mode
* - cancel: null means "uncancel" (remove scheduled cancellation)
* - cancel: "immediately" or "end_of_cycle" means cancel
* - cancel: undefined means no cancel operation
* @returns The cancel mode
*/
export const setupCancelMode = ({
params,
}: {
params: UpdateSubscriptionV0Params;
}): CancelMode | undefined => {
if (params.cancel === null) {
return "uncancel";
}
return params.cancel;
};

View File

@@ -0,0 +1,14 @@
import type { CusProductStatus } from "@autumn/shared";
// Re-export CancelMode from shared for convenience
export type { CancelMode } from "@shared/api/common/cancelMode";
/**
* Updates to apply to a customer product when canceling or uncanceling.
*/
export interface CancelUpdates {
canceled: boolean;
canceled_at: number | null;
ended_at: number | null;
status?: CusProductStatus;
}

View File

@@ -1,11 +1,11 @@
import {
type AttachBodyV1,
type FreeTrial,
type FullCusProduct,
type FullCustomer,
type FullCustomerPrice,
type FullProduct,
type LineItem,
import type {
AttachBodyV1,
FreeTrial,
FullCusProduct,
FullCustomer,
FullCustomerPrice,
FullProduct,
LineItem,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { StripeInvoiceAction } from "./types/billingPlan";

View File

@@ -0,0 +1,81 @@
import {
type FullCusProduct,
findMainScheduledCustomerProductByGroup,
isCustomerProductCanceling,
isCustomerProductMain,
} from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
/**
* Finds the scheduled product to delete when uncanceling.
* Only applies to main products that are currently canceling.
*/
const findScheduledProductToDelete = ({
billingContext,
}: {
billingContext: UpdateSubscriptionBillingContext;
}): FullCusProduct | undefined => {
const { customerProduct, fullCustomer } = billingContext;
const isMain = isCustomerProductMain(customerProduct);
const isCanceling = isCustomerProductCanceling(customerProduct);
if (!isMain || !isCanceling) {
return undefined;
}
return findMainScheduledCustomerProductByGroup({
fullCustomer,
productGroup: customerProduct.product.group,
});
};
/**
* Applies uncancel updates to an existing billing plan.
* This merges the uncancel changes (clear cancellation state, delete scheduled product)
* with any other changes in the plan.
*/
export const applyUncancelToPlan = ({
billingContext,
plan,
}: {
billingContext: UpdateSubscriptionBillingContext;
plan: AutumnBillingPlan;
}): AutumnBillingPlan => {
const { cancelMode } = billingContext;
if (cancelMode !== "uncancel") {
return plan;
}
const cancelUpdates = {
canceled: false,
canceled_at: null,
ended_at: null,
};
// Find scheduled product to delete (only for main canceling products)
const deleteCustomerProduct = findScheduledProductToDelete({
billingContext,
});
// Build the updateCustomerProduct with cancel updates merged in
// If plan doesn't have updateCustomerProduct, create one targeting the current product
const existingUpdate = plan.updateCustomerProduct;
const updateCustomerProduct = {
customerProduct:
existingUpdate?.customerProduct ?? billingContext.customerProduct,
updates: {
...existingUpdate?.updates,
...cancelUpdates,
},
};
return {
...plan,
updateCustomerProduct,
// Use the plan's deleteCustomerProduct if already set, otherwise use ours
deleteCustomerProduct: plan.deleteCustomerProduct ?? deleteCustomerProduct,
};
};

View File

@@ -0,0 +1,37 @@
import { CusProductStatus, type FullCusProduct } from "@autumn/shared";
import type { CancelMode } from "@/internal/billing/v2/types/cancelTypes";
/**
* Computes cancel-related fields for a new customer product.
* When uncanceling, returns undefined values to clear the cancel state.
* Otherwise, preserves the cancel state from the current product.
* Always preserves active status when replacing an active product.
*/
export const computeCancelFields = ({
cancelMode,
currentCustomerProduct,
}: {
cancelMode?: CancelMode;
currentCustomerProduct: FullCusProduct;
}): {
canceledAt: number | undefined;
endedAt: number | undefined;
status: CusProductStatus | undefined;
} => {
// When replacing an active product, preserve the active status
// This ensures the replacement product is also active (not scheduled due to timing)
const status =
currentCustomerProduct.status === CusProductStatus.Active
? CusProductStatus.Active
: undefined;
if (cancelMode === "uncancel") {
return { canceledAt: undefined, endedAt: undefined, status };
}
return {
canceledAt: currentCustomerProduct.canceled_at ?? undefined,
endedAt: currentCustomerProduct.ended_at ?? undefined,
status,
};
};

View File

@@ -1,6 +1,7 @@
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { applyUncancelToPlan } from "@/internal/billing/v2/updateSubscription/compute/cancel/applyUncancelToPlan";
import { applyCancelPlan } from "./applyCancelPlan";
import { computeCancelLineItems } from "./computeCancelLineItems";
import { computeCancelUpdates } from "./computeCancelUpdates";
@@ -26,6 +27,13 @@ export const computeCancelPlan = ({
}): AutumnBillingPlan => {
if (!billingContext.cancelMode) return plan;
if (billingContext.cancelMode === "uncancel") {
return applyUncancelToPlan({
billingContext,
plan,
});
}
// Step 1: Calculate when the subscription ends
const endOfCycleMs = computeEndOfCycleMs({ billingContext });

View File

@@ -2,7 +2,9 @@ import type { UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
import { computeCancelPlan } from "@/internal/billing/v2/updateSubscription/compute/cancel/computeCancelPlan";
import {
computeUpdateSubscriptionIntent,
UpdateSubscriptionIntent,

View File

@@ -1,6 +1,7 @@
import type { FullCusProduct, FullProduct } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { computeCancelFields } from "@/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
@@ -25,6 +26,7 @@ export const computeCustomPlanNewCustomerProduct = ({
currentEpochMs,
featureQuantities,
trialContext,
cancelMode,
} = updateSubscriptionContext;
const existingUsages = cusProductToExistingUsages({
@@ -41,6 +43,11 @@ export const computeCustomPlanNewCustomerProduct = ({
existingUsages,
);
const cancelFields = computeCancelFields({
cancelMode,
currentCustomerProduct,
});
// Compute the new full customer product
const newFullCustomerProduct = initFullCustomerProduct({
ctx,
@@ -62,10 +69,8 @@ export const computeCustomPlanNewCustomerProduct = ({
isCustom: updateSubscriptionContext.isCustom,
subscriptionId: stripeSubscription?.id, // don't populate if it's starting in the future.
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
startsAt: currentCustomerProduct.starts_at ?? undefined, // keep same starts at as current customer product?
canceledAt: currentCustomerProduct.canceled_at ?? undefined,
endedAt: currentCustomerProduct.ended_at ?? undefined,
startsAt: currentCustomerProduct.starts_at ?? undefined,
...cancelFields,
},
});

View File

@@ -0,0 +1,35 @@
import { CusProductStatus, RecaseError } from "@autumn/shared";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
/**
* Validates uncancel operation and throws appropriate errors.
* - Cannot uncancel a scheduled product
* - Cannot uncancel an expired product
* - Uncanceling an already active (non-canceling) product is a no-op (not an error)
*/
export const handleUncancelErrors = ({
billingContext,
}: {
billingContext: UpdateSubscriptionBillingContext;
}) => {
if (billingContext.cancelMode !== "uncancel") {
return;
}
const { customerProduct } = billingContext;
if (customerProduct.status === CusProductStatus.Scheduled) {
throw new RecaseError({
message: "Cannot uncancel a scheduled product",
});
}
if (customerProduct.status === CusProductStatus.Expired) {
throw new RecaseError({
message: "Cannot uncancel an expired product",
});
}
// If product is not canceling, this is a no-op - not an error
// The compute layer will handle it gracefully
};

View File

@@ -17,6 +17,7 @@ import {
} from "./handleOneOffErrors";
import { handleProductTypeTransitionErrors } from "./handleProductTypeTransitionErrors";
import { handleProrateBillingErrors } from "./handleProrateBillingErrors";
import { handleUncancelErrors } from "./handleUncancelErrors";
export const handleUpdateSubscriptionErrors = async ({
ctx,
@@ -64,7 +65,10 @@ export const handleUpdateSubscriptionErrors = async ({
// 7. Cancel end of cycle errors
handleCancelEndOfCycleErrors({ billingContext, params });
// 8. Prorate billing errors
// 8. Uncancel validation errors
handleUncancelErrors({ billingContext });
// 9. Prorate billing errors
handleProrateBillingErrors({
billingContext,
autumnBillingPlan,

View File

@@ -22,6 +22,7 @@ export const logUpdateSubscriptionContext = ({
stripeSubscription,
stripeSubscriptionSchedule,
isCustom,
cancelMode,
} = billingContext;
const fullProduct = fullProducts[0];
@@ -53,6 +54,7 @@ export const logUpdateSubscriptionContext = ({
: "undefined",
defaultProduct: billingContext.defaultProduct?.name ?? "undefined",
cancelMode: cancelMode ? cancelMode : "no cancel operation",
},
},
});

View File

@@ -2,6 +2,7 @@ import { notNullish, type UpdateSubscriptionV0Params } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
import { setupCancelMode } from "@/internal/billing/v2/setup/setupCancelMode";
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
@@ -96,8 +97,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
customerProduct,
});
// Cancel mode from params (undefined if not canceling)
const cancelMode = params.cancel ?? undefined;
const cancelMode = setupCancelMode({ params });
return {
fullCustomer,

View File

@@ -0,0 +1,338 @@
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.updateSubscription({ productId: addon.id, cancel: "end_of_cycle" }),
],
});
// 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.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
s.updateSubscription({ productId: addon.id, cancel: "end_of_cycle" }),
],
});
// 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.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
s.updateSubscription({ productId: addon.id, cancel: "end_of_cycle" }),
],
});
// 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 via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: addon.id,
cancel: "end_of_cycle",
});
// 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,356 @@
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.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
],
});
// 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 via subscriptions.update
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.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
],
});
// 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,381 @@
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({ testClock: true, 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 via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: "end_of_cycle",
});
// 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({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Cancel pro via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
cancel: "end_of_cycle",
});
// 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 via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: proTrial.id,
cancel: "end_of_cycle",
});
// 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,551 @@
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({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [
s.attach({ productId: pro.id }),
s.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
],
});
// 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.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
],
});
// 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({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [
s.attach({ productId: pro.id }),
s.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
],
});
// 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({ testClock: true, 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({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, free] }),
],
actions: [
s.attach({ productId: pro.id }),
s.updateSubscription({ productId: pro.id, cancel: "end_of_cycle" }),
],
});
// 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,284 @@
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 via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel: "end_of_cycle",
});
// 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 via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel: "end_of_cycle",
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[1].id,
cancel: "end_of_cycle",
});
// 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 (entities don't get default products scheduled)
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("uncancel entity: no scheduled default for entities")}`, async () => {
const customerId = "uncancel-entity-no-scheduled";
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
// No default product in this test - we're testing that entities
// don't get default products scheduled when canceled
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: 1, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: pro.id, entityIndex: 0 })],
});
// Cancel entity's pro via subscriptions.update
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
entity_id: entities[0].id,
cancel: "end_of_cycle",
});
// Verify pro is canceling - entities do NOT get default products scheduled
const entityAfterCancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectProductCanceling({
customer: entityAfterCancel,
productId: pro.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
const entityAfterUncancel = await autumnV1.entities.get(
customerId,
entities[0].id,
);
await expectProductActive({
customer: entityAfterUncancel,
productId: pro.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,
// });
});

View File

@@ -3,6 +3,7 @@ import { nullish } from "@utils/utils";
import { z } from "zod/v4";
import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels";
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels";
import { CancelModeSchema } from "../../common/cancelMode";
import { CustomerDataSchema } from "../../common/customerData";
import { EntityDataSchema } from "../../models";

View File

@@ -0,0 +1,12 @@
import { z } from "zod/v4";
/**
* Mode for canceling a subscription via update subscription API
*/
export const CancelModeSchema = z.enum([
"immediately",
"end_of_cycle",
"uncancel",
]);
export type CancelMode = z.infer<typeof CancelModeSchema>;

View File

@@ -13,7 +13,7 @@ import { authClient, signIn } from "@/lib/auth-client";
import { getBackendErr } from "@/utils/genUtils";
import { OTPSignIn } from "./components/OTPSignIn";
export const emailSchema = z.email();
export const emailSchema = z.string().email();
export const SignIn = () => {
const [email, setEmail] = useState("");

View File

@@ -1,7 +1,7 @@
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
import { FlaskIcon } from "@phosphor-icons/react";
import type { Row, Table } from "@tanstack/react-table";
import { ArrowRightLeft, Delete } from "lucide-react";
import { ArrowRightLeft, Delete, RotateCcw } from "lucide-react";
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers";
@@ -75,6 +75,7 @@ export const CustomerProductsColumns = [
}) => {
const meta = table.options.meta as {
onCancelClick?: (product: FullCusProduct) => void;
onUncancelClick?: (product: FullCusProduct) => void;
onTransferClick?: (product: FullCusProduct) => void;
onTestSheetClick?: (product: FullCusProduct) => void;
hasEntities?: boolean;
@@ -82,6 +83,8 @@ export const CustomerProductsColumns = [
if (!meta?.onCancelClick) return null;
const isCanceling = row.original.canceled;
return (
<TableDropdownMenuCell>
{meta.onTestSheetClick && (
@@ -106,15 +109,27 @@ export const CustomerProductsColumns = [
<ArrowRightLeft size={16} /> Transfer
</DropdownMenuItem>
)}
<DropdownMenuItem
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
onClick={(e) => {
e.stopPropagation();
meta.onCancelClick?.(row.original);
}}
>
<Delete size={16} /> Cancel
</DropdownMenuItem>
{isCanceling ? (
<DropdownMenuItem
className="flex items-center gap-2 text-xs"
onClick={(e) => {
e.stopPropagation();
meta.onUncancelClick?.(row.original);
}}
>
<RotateCcw size={16} /> Uncancel
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
onClick={(e) => {
e.stopPropagation();
meta.onCancelClick?.(row.original);
}}
>
<Delete size={16} /> Cancel
</DropdownMenuItem>
)}
</TableDropdownMenuCell>
);
},

View File

@@ -1,13 +1,17 @@
import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared";
import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Row } from "@tanstack/react-table";
import type { AxiosError } from "axios";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Table } from "@/components/general/table";
import { SectionTag } from "@/components/v2/badges/SectionTag";
import { Button } from "@/components/v2/buttons/Button";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useEnv } from "@/utils/envUtils";
import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery";
import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery";
@@ -116,6 +120,34 @@ export function CustomerProductsTable() {
setTransferOpen(true);
};
const axiosInstance = useAxiosInstance();
const queryClient = useQueryClient();
const uncancelMutation = useMutation({
mutationFn: async (product: FullCusProduct) => {
const response = await axiosInstance.post("/v1/subscriptions/update", {
customer_id: customer.id,
product_id: product.product.id,
cancel: null,
});
return response.data;
},
onSuccess: () => {
toast.success("Subscription uncanceled successfully");
queryClient.invalidateQueries({ queryKey: ["customer", customer.id] });
},
onError: (error) => {
toast.error(
(error as AxiosError<{ message: string }>)?.response?.data?.message ??
"Failed to uncancel subscription",
);
},
});
const handleUncancelClick = (product: FullCusProduct) => {
uncancelMutation.mutate(product);
};
const handleRowClick = (cusProduct: FullCusProduct) => {
setSheet({
type: "subscription-detail",
@@ -125,6 +157,7 @@ export function CustomerProductsTable() {
const tableMeta = {
onCancelClick: handleCancelClick,
onUncancelClick: handleUncancelClick,
onTransferClick: handleTransferClick,
hasEntities,
};