diff --git a/scripts/testGroups/update-subscription.sh b/scripts/testGroups/update-subscription.sh index cd25c6c6e..ac2d7a6a5 100755 --- a/scripts/testGroups/update-subscription.sh +++ b/scripts/testGroups/update-subscription.sh @@ -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 + diff --git a/server/src/internal/billing/v2/billingContext.ts b/server/src/internal/billing/v2/billingContext.ts index 2b9d4e5ea..8d400e113 100644 --- a/server/src/internal/billing/v2/billingContext.ts +++ b/server/src/internal/billing/v2/billingContext.ts @@ -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 diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts index 40454be48..9f6c8b163 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction.ts @@ -28,7 +28,7 @@ export const executeStripeSubscriptionScheduleAction = async ({ billingContext: BillingContext; subscriptionScheduleAction: StripeSubscriptionScheduleAction; stripeSubscription?: Stripe.Subscription; -}): Promise => { +}): Promise => { 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; } }; diff --git a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts index d055293a1..3600d39ec 100644 --- a/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts +++ b/server/src/internal/billing/v2/providers/stripe/setup/setupStripeDiscountsForBilling.ts @@ -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, ]; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/discounts/subToDiscounts.ts b/server/src/internal/billing/v2/providers/stripe/utils/discounts/subToDiscounts.ts index ef5b30242..ffe5a8889 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/discounts/subToDiscounts.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/discounts/subToDiscounts.ts @@ -21,4 +21,4 @@ export const subToDiscounts = ({ .filter(notNullish); return discounts; -}; \ No newline at end of file +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts index 2dcc76820..eaaf4b2eb 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/logSubscriptionScheduleAction.ts @@ -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( diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts index fc482dadd..22bc819b9 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction.ts @@ -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", }; diff --git a/server/src/internal/billing/v2/setup/setupCancelMode.ts b/server/src/internal/billing/v2/setup/setupCancelMode.ts new file mode 100644 index 000000000..0bf1e4c0a --- /dev/null +++ b/server/src/internal/billing/v2/setup/setupCancelMode.ts @@ -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; +}; diff --git a/server/src/internal/billing/v2/types/cancelTypes.ts b/server/src/internal/billing/v2/types/cancelTypes.ts new file mode 100644 index 000000000..462a5b0d7 --- /dev/null +++ b/server/src/internal/billing/v2/types/cancelTypes.ts @@ -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; +} diff --git a/server/src/internal/billing/v2/typesOld.ts b/server/src/internal/billing/v2/typesOld.ts index 8098b7034..d88cd1f12 100644 --- a/server/src/internal/billing/v2/typesOld.ts +++ b/server/src/internal/billing/v2/typesOld.ts @@ -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"; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/cancel/applyUncancelToPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/cancel/applyUncancelToPlan.ts new file mode 100644 index 000000000..1d3975586 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/compute/cancel/applyUncancelToPlan.ts @@ -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, + }; +}; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields.ts b/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields.ts new file mode 100644 index 000000000..d8233cf19 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields.ts @@ -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, + }; +}; diff --git a/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelPlan.ts index e5a1b6bb5..38b6369cc 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelPlan.ts @@ -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 }); diff --git a/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts b/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts index 6d8011b03..071428d3b 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts @@ -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, diff --git a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts index 3c34dc9df..dbcbad26e 100644 --- a/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts +++ b/server/src/internal/billing/v2/updateSubscription/compute/customPlan/computeCustomPlanNewCustomerProduct.ts @@ -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, }, }); diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleUncancelErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleUncancelErrors.ts new file mode 100644 index 000000000..8284c0995 --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleUncancelErrors.ts @@ -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 +}; diff --git a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts index 10bbd3915..dd4f3e023 100644 --- a/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts +++ b/server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts @@ -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, diff --git a/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionContext.ts b/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionContext.ts index 06f71fa8e..e559ec176 100644 --- a/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/logs/logUpdateSubscriptionContext.ts @@ -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", }, }, }); diff --git a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts index ec05e666c..d70db38e8 100644 --- a/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts +++ b/server/src/internal/billing/v2/updateSubscription/setup/setupUpdateSubscriptionBillingContext.ts @@ -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, diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-addon.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-addon.test.ts new file mode 100644 index 000000000..a294150f7 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-addon.test.ts @@ -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(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(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(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(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(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(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(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(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, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts new file mode 100644 index 000000000..d3bed9ff2 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-basic.test.ts @@ -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(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(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(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(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(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(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(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(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(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, + }); + }, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-combined.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-combined.test.ts new file mode 100644 index 000000000..e268aff31 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-combined.test.ts @@ -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(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(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(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(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(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(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(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(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, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-edge-cases.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-edge-cases.test.ts new file mode 100644 index 000000000..5522a9b5e --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-edge-cases.test.ts @@ -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(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(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(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(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(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(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(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(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(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(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(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, + }); +}); diff --git a/server/tests/integration/billing/update-subscription/uncancel/uncancel-entities.test.ts b/server/tests/integration/billing/update-subscription/uncancel/uncancel-entities.test.ts new file mode 100644 index 000000000..c829909e0 --- /dev/null +++ b/server/tests/integration/billing/update-subscription/uncancel/uncancel-entities.test.ts @@ -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, + }); +}); diff --git a/server/tests/scenarios/README.md b/server/tests/scenarios/README.md new file mode 100644 index 000000000..685bc24a0 --- /dev/null +++ b/server/tests/scenarios/README.md @@ -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. \ No newline at end of file diff --git a/server/tests/scenarios/update-subscription/uncancel-scenario.test.ts b/server/tests/scenarios/update-subscription/uncancel-scenario.test.ts new file mode 100644 index 000000000..eae018297 --- /dev/null +++ b/server/tests/scenarios/update-subscription/uncancel-scenario.test.ts @@ -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, + // }); +}); diff --git a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index ba688609e..7da7a9524 100644 --- a/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts +++ b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts @@ -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"; diff --git a/shared/api/common/cancelMode.ts b/shared/api/common/cancelMode.ts new file mode 100644 index 000000000..a71540706 --- /dev/null +++ b/shared/api/common/cancelMode.ts @@ -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; diff --git a/vite/src/views/auth/SignIn.tsx b/vite/src/views/auth/SignIn.tsx index 804049cbc..c50048475 100644 --- a/vite/src/views/auth/SignIn.tsx +++ b/vite/src/views/auth/SignIn.tsx @@ -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(""); diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx index 6074e950a..378436cc0 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsColumns.tsx @@ -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 ( {meta.onTestSheetClick && ( @@ -106,15 +109,27 @@ export const CustomerProductsColumns = [ Transfer )} - { - e.stopPropagation(); - meta.onCancelClick?.(row.original); - }} - > - Cancel - + {isCanceling ? ( + { + e.stopPropagation(); + meta.onUncancelClick?.(row.original); + }} + > + Uncancel + + ) : ( + { + e.stopPropagation(); + meta.onCancelClick?.(row.original); + }} + > + Cancel + + )} ); }, diff --git a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx index 17faf7a29..0cdea1fd5 100644 --- a/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx +++ b/vite/src/views/customers2/components/table/customer-products/CustomerProductsTable.tsx @@ -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, };