chore: merge with cancel branch
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
Price,
|
||||
StripeDiscountWithCoupon,
|
||||
} from "@autumn/shared";
|
||||
import type { CancelMode } from "@shared/api/common/cancelMode";
|
||||
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod/v4";
|
||||
@@ -53,9 +54,10 @@ export interface BillingContext {
|
||||
// Trial context
|
||||
trialContext?: TrialContext;
|
||||
isCustom?: boolean;
|
||||
}
|
||||
|
||||
export type CancelMode = "immediately" | "end_of_cycle";
|
||||
// Cancel mode (used by update subscription for uncancel)
|
||||
cancelMode?: CancelMode;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionBillingContext extends BillingContext {
|
||||
customerProduct: FullCusProduct; // target customer product
|
||||
|
||||
@@ -28,7 +28,7 @@ export const executeStripeSubscriptionScheduleAction = async ({
|
||||
billingContext: BillingContext;
|
||||
subscriptionScheduleAction: StripeSubscriptionScheduleAction;
|
||||
stripeSubscription?: Stripe.Subscription;
|
||||
}): Promise<Stripe.SubscriptionSchedule> => {
|
||||
}): Promise<Stripe.SubscriptionSchedule | null> => {
|
||||
const { org, env } = ctx;
|
||||
const stripeCli = createStripeCli({ org, env });
|
||||
|
||||
@@ -36,15 +36,13 @@ export const executeStripeSubscriptionScheduleAction = async ({
|
||||
`[executeStripeSubscriptionScheduleAction] Executing subscription schedule operation: ${subscriptionScheduleAction.type}`,
|
||||
);
|
||||
|
||||
// Log phases
|
||||
logSubscriptionScheduleAction({
|
||||
ctx,
|
||||
billingContext,
|
||||
subscriptionScheduleAction,
|
||||
});
|
||||
|
||||
switch (subscriptionScheduleAction.type) {
|
||||
case "create": {
|
||||
logSubscriptionScheduleAction({
|
||||
ctx,
|
||||
billingContext,
|
||||
subscriptionScheduleAction,
|
||||
});
|
||||
const { params } = subscriptionScheduleAction;
|
||||
|
||||
// If there's an existing subscription, create from it first then update with phases
|
||||
@@ -73,14 +71,23 @@ export const executeStripeSubscriptionScheduleAction = async ({
|
||||
}
|
||||
|
||||
case "update":
|
||||
logSubscriptionScheduleAction({
|
||||
ctx,
|
||||
billingContext,
|
||||
subscriptionScheduleAction,
|
||||
});
|
||||
return await stripeCli.subscriptionSchedules.update(
|
||||
subscriptionScheduleAction.stripeSubscriptionScheduleId,
|
||||
subscriptionScheduleAction.params,
|
||||
);
|
||||
|
||||
case "release":
|
||||
return await stripeCli.subscriptionSchedules.release(
|
||||
ctx.logger.debug(
|
||||
`[executeStripeSubscriptionScheduleAction] Releasing schedule: ${subscriptionScheduleAction.stripeSubscriptionScheduleId}`,
|
||||
);
|
||||
await stripeCli.subscriptionSchedules.release(
|
||||
subscriptionScheduleAction.stripeSubscriptionScheduleId,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -32,11 +32,9 @@ export const setupStripeDiscountsForBilling = ({
|
||||
if (!coupon || typeof coupon === "string") return [];
|
||||
|
||||
// Normalize to StripeDiscountWithCoupon format
|
||||
// Extract the coupon and put it under source.coupon
|
||||
const { coupon: _coupon, ...discountWithoutCoupon } = customerDiscount;
|
||||
return [
|
||||
{
|
||||
...discountWithoutCoupon,
|
||||
...customerDiscount,
|
||||
source: { coupon },
|
||||
} as StripeDiscountWithCoupon,
|
||||
];
|
||||
|
||||
@@ -21,4 +21,4 @@ export const subToDiscounts = ({
|
||||
.filter(notNullish);
|
||||
|
||||
return discounts;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -43,7 +43,10 @@ export const logSubscriptionScheduleAction = ({
|
||||
}: {
|
||||
ctx: AutumnContext;
|
||||
billingContext: BillingContext;
|
||||
subscriptionScheduleAction: StripeSubscriptionScheduleAction;
|
||||
subscriptionScheduleAction: Extract<
|
||||
StripeSubscriptionScheduleAction,
|
||||
{ type: "create" | "update" }
|
||||
>;
|
||||
}): void => {
|
||||
if (subscriptionScheduleAction.type === "release") {
|
||||
ctx.logger.debug(
|
||||
|
||||
@@ -22,7 +22,7 @@ export const buildStripeSubscriptionUpdateAction = ({
|
||||
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
|
||||
subscriptionCancelAt?: number;
|
||||
}): StripeSubscriptionAction | undefined => {
|
||||
const { stripeSubscription, trialContext } = billingContext;
|
||||
const { stripeSubscription, trialContext, cancelMode } = billingContext;
|
||||
|
||||
if (!stripeSubscription) {
|
||||
throw new Error(
|
||||
@@ -48,9 +48,14 @@ export const buildStripeSubscriptionUpdateAction = ({
|
||||
shouldUnsetTrialEnd = !scheduleManagesSubscription && trialEndsAt === null;
|
||||
}
|
||||
|
||||
// Only set cancel_at if it differs from current value
|
||||
// Determine cancel_at handling:
|
||||
// 1. Clear cancel_at if uncancel mode and currently has a cancel_at
|
||||
// 2. Set cancel_at if explicitly provided and differs from current value
|
||||
const currentCancelAt = stripeSubscription.cancel_at;
|
||||
const shouldClearCancelAt =
|
||||
cancelMode === "uncancel" && currentCancelAt !== null;
|
||||
const shouldSetCancelAt =
|
||||
!shouldClearCancelAt &&
|
||||
subscriptionCancelAt !== undefined &&
|
||||
subscriptionCancelAt !== currentCancelAt;
|
||||
|
||||
@@ -61,7 +66,11 @@ export const buildStripeSubscriptionUpdateAction = ({
|
||||
: shouldUnsetTrialEnd
|
||||
? "now"
|
||||
: undefined,
|
||||
cancel_at: shouldSetCancelAt ? subscriptionCancelAt : undefined,
|
||||
cancel_at: shouldClearCancelAt
|
||||
? null
|
||||
: shouldSetCancelAt
|
||||
? subscriptionCancelAt
|
||||
: undefined,
|
||||
proration_behavior: "none",
|
||||
};
|
||||
|
||||
|
||||
23
server/src/internal/billing/v2/setup/setupCancelMode.ts
Normal file
23
server/src/internal/billing/v2/setup/setupCancelMode.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { UpdateSubscriptionV0Params } from "@shared/api/billing/updateSubscription/updateSubscriptionV0Params";
|
||||
import type { CancelMode } from "@shared/api/common/cancelMode";
|
||||
|
||||
/**
|
||||
* Setup cancel mode from params
|
||||
* @param params - The params
|
||||
* Converts cancel param to internal cancel mode
|
||||
* - cancel: null means "uncancel" (remove scheduled cancellation)
|
||||
* - cancel: "immediately" or "end_of_cycle" means cancel
|
||||
* - cancel: undefined means no cancel operation
|
||||
* @returns The cancel mode
|
||||
*/
|
||||
export const setupCancelMode = ({
|
||||
params,
|
||||
}: {
|
||||
params: UpdateSubscriptionV0Params;
|
||||
}): CancelMode | undefined => {
|
||||
if (params.cancel === null) {
|
||||
return "uncancel";
|
||||
}
|
||||
|
||||
return params.cancel;
|
||||
};
|
||||
14
server/src/internal/billing/v2/types/cancelTypes.ts
Normal file
14
server/src/internal/billing/v2/types/cancelTypes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { CusProductStatus } from "@autumn/shared";
|
||||
|
||||
// Re-export CancelMode from shared for convenience
|
||||
export type { CancelMode } from "@shared/api/common/cancelMode";
|
||||
|
||||
/**
|
||||
* Updates to apply to a customer product when canceling or uncanceling.
|
||||
*/
|
||||
export interface CancelUpdates {
|
||||
canceled: boolean;
|
||||
canceled_at: number | null;
|
||||
ended_at: number | null;
|
||||
status?: CusProductStatus;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
type AttachBodyV1,
|
||||
type FreeTrial,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type FullCustomerPrice,
|
||||
type FullProduct,
|
||||
type LineItem,
|
||||
import type {
|
||||
AttachBodyV1,
|
||||
FreeTrial,
|
||||
FullCusProduct,
|
||||
FullCustomer,
|
||||
FullCustomerPrice,
|
||||
FullProduct,
|
||||
LineItem,
|
||||
} from "@autumn/shared";
|
||||
import type Stripe from "stripe";
|
||||
import type { StripeInvoiceAction } from "./types/billingPlan";
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
import { applyUncancelToPlan } from "@/internal/billing/v2/updateSubscription/compute/cancel/applyUncancelToPlan";
|
||||
import { applyCancelPlan } from "./applyCancelPlan";
|
||||
import { computeCancelLineItems } from "./computeCancelLineItems";
|
||||
import { computeCancelUpdates } from "./computeCancelUpdates";
|
||||
@@ -26,6 +27,13 @@ export const computeCancelPlan = ({
|
||||
}): AutumnBillingPlan => {
|
||||
if (!billingContext.cancelMode) return plan;
|
||||
|
||||
if (billingContext.cancelMode === "uncancel") {
|
||||
return applyUncancelToPlan({
|
||||
billingContext,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
|
||||
// Step 1: Calculate when the subscription ends
|
||||
const endOfCycleMs = computeEndOfCycleMs({ billingContext });
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||
|
||||
import { computeCancelPlan } from "@/internal/billing/v2/updateSubscription/compute/cancel/computeCancelPlan";
|
||||
|
||||
import {
|
||||
computeUpdateSubscriptionIntent,
|
||||
UpdateSubscriptionIntent,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FullCusProduct, FullProduct } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
import { computeCancelFields } from "@/internal/billing/v2/updateSubscription/compute/cancel/computeCancelFields";
|
||||
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
|
||||
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
|
||||
import { initFullCustomerProduct } from "@/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct";
|
||||
@@ -25,6 +26,7 @@ export const computeCustomPlanNewCustomerProduct = ({
|
||||
currentEpochMs,
|
||||
featureQuantities,
|
||||
trialContext,
|
||||
cancelMode,
|
||||
} = updateSubscriptionContext;
|
||||
|
||||
const existingUsages = cusProductToExistingUsages({
|
||||
@@ -41,6 +43,11 @@ export const computeCustomPlanNewCustomerProduct = ({
|
||||
existingUsages,
|
||||
);
|
||||
|
||||
const cancelFields = computeCancelFields({
|
||||
cancelMode,
|
||||
currentCustomerProduct,
|
||||
});
|
||||
|
||||
// Compute the new full customer product
|
||||
const newFullCustomerProduct = initFullCustomerProduct({
|
||||
ctx,
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CusProductStatus, RecaseError } from "@autumn/shared";
|
||||
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||
|
||||
/**
|
||||
* Validates uncancel operation and throws appropriate errors.
|
||||
* - Cannot uncancel a scheduled product
|
||||
* - Cannot uncancel an expired product
|
||||
* - Uncanceling an already active (non-canceling) product is a no-op (not an error)
|
||||
*/
|
||||
export const handleUncancelErrors = ({
|
||||
billingContext,
|
||||
}: {
|
||||
billingContext: UpdateSubscriptionBillingContext;
|
||||
}) => {
|
||||
if (billingContext.cancelMode !== "uncancel") {
|
||||
return;
|
||||
}
|
||||
|
||||
const { customerProduct } = billingContext;
|
||||
|
||||
if (customerProduct.status === CusProductStatus.Scheduled) {
|
||||
throw new RecaseError({
|
||||
message: "Cannot uncancel a scheduled product",
|
||||
});
|
||||
}
|
||||
|
||||
if (customerProduct.status === CusProductStatus.Expired) {
|
||||
throw new RecaseError({
|
||||
message: "Cannot uncancel an expired product",
|
||||
});
|
||||
}
|
||||
|
||||
// If product is not canceling, this is a no-op - not an error
|
||||
// The compute layer will handle it gracefully
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "./handleOneOffErrors";
|
||||
import { handleProductTypeTransitionErrors } from "./handleProductTypeTransitionErrors";
|
||||
import { handleProrateBillingErrors } from "./handleProrateBillingErrors";
|
||||
import { handleUncancelErrors } from "./handleUncancelErrors";
|
||||
|
||||
export const handleUpdateSubscriptionErrors = async ({
|
||||
ctx,
|
||||
@@ -64,7 +65,10 @@ export const handleUpdateSubscriptionErrors = async ({
|
||||
// 7. Cancel end of cycle errors
|
||||
handleCancelEndOfCycleErrors({ billingContext, params });
|
||||
|
||||
// 8. Prorate billing errors
|
||||
// 8. Uncancel validation errors
|
||||
handleUncancelErrors({ billingContext });
|
||||
|
||||
// 9. Prorate billing errors
|
||||
handleProrateBillingErrors({
|
||||
billingContext,
|
||||
autumnBillingPlan,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const logUpdateSubscriptionContext = ({
|
||||
stripeSubscription,
|
||||
stripeSubscriptionSchedule,
|
||||
isCustom,
|
||||
cancelMode,
|
||||
} = billingContext;
|
||||
|
||||
const fullProduct = fullProducts[0];
|
||||
@@ -53,6 +54,7 @@ export const logUpdateSubscriptionContext = ({
|
||||
: "undefined",
|
||||
|
||||
defaultProduct: billingContext.defaultProduct?.name ?? "undefined",
|
||||
cancelMode: cancelMode ? cancelMode : "no cancel operation",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { notNullish, type UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext";
|
||||
import { setupBillingCycleAnchor } from "@/internal/billing/v2/setup/setupBillingCycleAnchor";
|
||||
import { setupCancelMode } from "@/internal/billing/v2/setup/setupCancelMode";
|
||||
import { setupFeatureQuantitiesContext } from "@/internal/billing/v2/setup/setupFeatureQuantitiesContext";
|
||||
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext";
|
||||
import { setupInvoiceModeContext } from "@/internal/billing/v2/setup/setupInvoiceModeContext";
|
||||
@@ -96,8 +97,7 @@ export const setupUpdateSubscriptionBillingContext = async ({
|
||||
customerProduct,
|
||||
});
|
||||
|
||||
// Cancel mode from params (undefined if not canceling)
|
||||
const cancelMode = params.cancel ?? undefined;
|
||||
const cancelMode = setupCancelMode({ params });
|
||||
|
||||
return {
|
||||
fullCustomer,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { nullish } from "@utils/utils";
|
||||
import { z } from "zod/v4";
|
||||
import { FeatureOptionsSchema } from "../../../models/cusProductModels/cusProductModels";
|
||||
import { ProductItemSchema } from "../../../models/productV2Models/productItemModels/productItemModels";
|
||||
import { CancelModeSchema } from "../../common/cancelMode";
|
||||
import { CustomerDataSchema } from "../../common/customerData";
|
||||
import { EntityDataSchema } from "../../models";
|
||||
|
||||
|
||||
12
shared/api/common/cancelMode.ts
Normal file
12
shared/api/common/cancelMode.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Mode for canceling a subscription via update subscription API
|
||||
*/
|
||||
export const CancelModeSchema = z.enum([
|
||||
"immediately",
|
||||
"end_of_cycle",
|
||||
"uncancel",
|
||||
]);
|
||||
|
||||
export type CancelMode = z.infer<typeof CancelModeSchema>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type FullCusProduct, isCustomerProductTrialing } from "@autumn/shared";
|
||||
import { FlaskIcon } from "@phosphor-icons/react";
|
||||
import type { Row, Table } from "@tanstack/react-table";
|
||||
import { ArrowRightLeft, Delete } from "lucide-react";
|
||||
import { ArrowRightLeft, Delete, RotateCcw } from "lucide-react";
|
||||
import { TableDropdownMenuCell } from "@/components/general/table/table-dropdown-menu-cell";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { createDateTimeColumn } from "@/views/customers2/utils/ColumnHelpers";
|
||||
@@ -75,6 +75,7 @@ export const CustomerProductsColumns = [
|
||||
}) => {
|
||||
const meta = table.options.meta as {
|
||||
onCancelClick?: (product: FullCusProduct) => void;
|
||||
onUncancelClick?: (product: FullCusProduct) => void;
|
||||
onTransferClick?: (product: FullCusProduct) => void;
|
||||
onTestSheetClick?: (product: FullCusProduct) => void;
|
||||
hasEntities?: boolean;
|
||||
@@ -82,6 +83,8 @@ export const CustomerProductsColumns = [
|
||||
|
||||
if (!meta?.onCancelClick) return null;
|
||||
|
||||
const isCanceling = row.original.canceled;
|
||||
|
||||
return (
|
||||
<TableDropdownMenuCell>
|
||||
{meta.onTestSheetClick && (
|
||||
@@ -106,15 +109,27 @@ export const CustomerProductsColumns = [
|
||||
<ArrowRightLeft size={16} /> Transfer
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onCancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<Delete size={16} /> Cancel
|
||||
</DropdownMenuItem>
|
||||
{isCanceling ? (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onUncancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} /> Uncancel
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 text-xs text-red-500 dark:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
meta.onCancelClick?.(row.original);
|
||||
}}
|
||||
>
|
||||
<Delete size={16} /> Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</TableDropdownMenuCell>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { AppEnv, type Entity, type FullCusProduct } from "@autumn/shared";
|
||||
import { ArrowSquareOutIcon, PackageIcon } from "@phosphor-icons/react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Table } from "@/components/general/table";
|
||||
import { SectionTag } from "@/components/v2/badges/SectionTag";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||
import { useEntity } from "@/hooks/stores/useSubscriptionStore";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useEnv } from "@/utils/envUtils";
|
||||
import { useFullCusSearchQuery } from "@/views/customers/hooks/useFullCusSearchQuery";
|
||||
import { useSavedViewsQuery } from "@/views/customers/hooks/useSavedViewsQuery";
|
||||
@@ -116,6 +120,34 @@ export function CustomerProductsTable() {
|
||||
setTransferOpen(true);
|
||||
};
|
||||
|
||||
const axiosInstance = useAxiosInstance();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const uncancelMutation = useMutation({
|
||||
mutationFn: async (product: FullCusProduct) => {
|
||||
const response = await axiosInstance.post("/v1/subscriptions/update", {
|
||||
customer_id: customer.id,
|
||||
product_id: product.product.id,
|
||||
cancel: null,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Subscription uncanceled successfully");
|
||||
queryClient.invalidateQueries({ queryKey: ["customer", customer.id] });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
(error as AxiosError<{ message: string }>)?.response?.data?.message ??
|
||||
"Failed to uncancel subscription",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleUncancelClick = (product: FullCusProduct) => {
|
||||
uncancelMutation.mutate(product);
|
||||
};
|
||||
|
||||
const handleRowClick = (cusProduct: FullCusProduct) => {
|
||||
setSheet({
|
||||
type: "subscription-detail",
|
||||
@@ -125,6 +157,7 @@ export function CustomerProductsTable() {
|
||||
|
||||
const tableMeta = {
|
||||
onCancelClick: handleCancelClick,
|
||||
onUncancelClick: handleUncancelClick,
|
||||
onTransferClick: handleTransferClick,
|
||||
hasEntities,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user