chore: merge with cancel branch

This commit is contained in:
Charlie Lamb
2026-01-22 10:59:18 +00:00
parent 64b4409998
commit 94f3394122
22 changed files with 315 additions and 42 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,
};
};

View File

@@ -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,
};
};

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import type { FullCusProduct, FullProduct } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
import { computeCancelFields } from "@/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields";
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
@@ -25,6 +26,7 @@ export const computeCustomPlanNewCustomerProduct = ({
currentEpochMs,
featureQuantities,
trialContext,
cancelMode,
} = updateSubscriptionContext;
const existingUsages = cusProductToExistingUsages({
@@ -41,6 +43,11 @@ export const computeCustomPlanNewCustomerProduct = ({
existingUsages,
);
const cancelFields = computeCancelFields({
cancelMode,
currentCustomerProduct,
});
// Compute the new full customer product
const newFullCustomerProduct = initFullCustomerProduct({
ctx,
@@ -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,
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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