Merge branch 'feat/cancel-v2' of https://github.com/useautumn/autumn into feat/cancel-v2
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -21,4 +21,4 @@ export const subToDiscounts = ({
|
||||
.filter(notNullish);
|
||||
|
||||
return discounts;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
23
server/src/internal/billing/v2/setup/setupCancelMode.ts
Normal file
23
server/src/internal/billing/v2/setup/setupCancelMode.ts
Normal 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;
|
||||
};
|
||||
14
server/src/internal/billing/v2/types/cancelTypes.ts
Normal file
14
server/src/internal/billing/v2/types/cancelTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
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,
|
||||
// });
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
12
shared/api/common/cancelMode.ts
Normal file
12
shared/api/common/cancelMode.ts
Normal 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>;
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user