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..c135d4fed --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/compute/cancel/applyUncancelToPlan.ts @@ -0,0 +1,75 @@ +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, + }); + + return { + ...plan, + updateCustomerProduct: { + ...plan.updateCustomerProduct, + updates: { + ...plan.updateCustomerProduct.updates, + ...cancelUpdates, + }, + }, + // 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..c977edbbf --- /dev/null +++ b/server/src/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields.ts @@ -0,0 +1,24 @@ +import 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. + */ +export const computeCancelFields = ({ + cancelMode, + currentCustomerProduct, +}: { + cancelMode?: CancelMode; + currentCustomerProduct: FullCusProduct; +}): { canceledAt: number | undefined; endedAt: number | undefined } => { + if (cancelMode === "uncancel") { + return { canceledAt: undefined, endedAt: undefined }; + } + + return { + canceledAt: currentCustomerProduct.canceled_at ?? undefined, + endedAt: currentCustomerProduct.ended_at ?? undefined, + }; +}; 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..11eb5a337 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, @@ -64,8 +71,7 @@ export const computeCustomPlanNewCustomerProduct = ({ 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, + ...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/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts b/shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts index f346372f9..4e26f0265 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/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, };