migration ops: add update-item support, deduplicate shared UI, clean up draft builder
- Add UpdateItemRows for update_items operations in migration customize - Extract shared constants/components (intervals, billing method dropdown, filter helpers) into operationItemUtils to deduplicate RemoveItemRows and UpdateItemRows - Extract getItemMatchKey helper to replace 5 copies of the inline template string in PlanItemsSection/PlanItemRow - Fix broken indentation in RemoveItemSheetContent - Fix BillingMethodDropdown showing "Included" when unset (now "Any method") - Derive INTERVAL_OPTIONS from enums instead of hand-rolling - Rewrite usePriceChange to delegate to getProductPriceDisplay - Add comments to buildMigrationDraft explaining the three-pass diffing - Refactor PlanChangeDialog into a two-step flow (confirm -> create migration) - Wire updateItemId through PlanEditorContext/SheetStore for stable item tracking after edits Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
Charlie Lamb
parent
819b05765f
commit
5e94c03028
@@ -1,7 +1,14 @@
|
||||
import { BillingMethod } from "@api/products/components/billingMethod";
|
||||
import { BillingInterval } from "@models/productModels/intervals/billingInterval";
|
||||
import { EntInterval } from "@models/productModels/intervals/entitlementInterval";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const billingSet = new Set<string>(Object.values(BillingInterval));
|
||||
const AllIntervals = [
|
||||
...Object.values(BillingInterval),
|
||||
...Object.values(EntInterval).filter((v) => !billingSet.has(v)),
|
||||
] as [string, ...string[]];
|
||||
|
||||
export const PlanItemFilterSchema = z
|
||||
.object({
|
||||
feature_id: z.string().optional().meta({
|
||||
@@ -11,7 +18,7 @@ export const PlanItemFilterSchema = z
|
||||
description:
|
||||
"Match items with this billing method (prepaid or usage_based).",
|
||||
}),
|
||||
interval: z.enum(BillingInterval).optional().meta({
|
||||
interval: z.enum(AllIntervals).optional().meta({
|
||||
description: "Match items with this interval.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents"
|
||||
import { CollapsedBooleanItems } from "./plan-items/CollapsedBooleanItems";
|
||||
import { DeletedItemRow } from "./plan-items/DeletedItemRow";
|
||||
import { PlanEditButton } from "./plan-items/PlanEditButton";
|
||||
import { PlanItemRow } from "./plan-items/PlanItemRow";
|
||||
import { getItemMatchKey, hasItemChanged, PlanItemRow } from "./plan-items/PlanItemRow";
|
||||
import { PlanPriceHeader } from "./plan-items/PlanPriceHeader";
|
||||
import {
|
||||
PlanTrialEditor,
|
||||
@@ -45,7 +45,7 @@ export interface PlanItemsSectionProps {
|
||||
initialPrepaidOptions: Record<string, number | undefined>;
|
||||
existingOptions?: FeatureOptions[];
|
||||
|
||||
form: UseUpdateSubscriptionForm | UseAttachForm;
|
||||
form?: UseUpdateSubscriptionForm | UseAttachForm;
|
||||
|
||||
showDiff: boolean;
|
||||
currency: string;
|
||||
@@ -57,6 +57,7 @@ export interface PlanItemsSectionProps {
|
||||
trialConfig?: TrialConfig;
|
||||
|
||||
gateDeletedItemsByDiff?: boolean;
|
||||
changesOnly?: boolean;
|
||||
readOnly?: boolean;
|
||||
|
||||
adminIds?: import(
|
||||
@@ -79,38 +80,53 @@ export function PlanItemsSection({
|
||||
versionChange,
|
||||
trialConfig,
|
||||
gateDeletedItemsByDiff = false,
|
||||
changesOnly = false,
|
||||
readOnly = false,
|
||||
adminIds,
|
||||
}: PlanItemsSectionProps) {
|
||||
const originalItemsMap = new Map<string, ProductItem>(
|
||||
originalItems
|
||||
?.filter((i) => i.feature_id)
|
||||
.map((i) => [`${i.feature_id}:${i.usage_model ?? ""}`, i]) ?? [],
|
||||
.map((i) => [getItemMatchKey(i), i]) ?? [],
|
||||
);
|
||||
|
||||
const currentFeatureIds = new Set(
|
||||
product?.items?.map((i) => i.feature_id).filter(Boolean) ?? [],
|
||||
const currentItemKeys = new Set(
|
||||
product?.items
|
||||
?.filter((i) => i.feature_id)
|
||||
.map((i) => getItemMatchKey(i)) ?? [],
|
||||
);
|
||||
|
||||
const isItemDeleted = (i: ProductItem) =>
|
||||
!!i.feature_id && !currentItemKeys.has(getItemMatchKey(i));
|
||||
|
||||
const deletedItems = gateDeletedItemsByDiff
|
||||
? showDiff && originalItems
|
||||
? originalItems.filter(
|
||||
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
|
||||
)
|
||||
? originalItems.filter(isItemDeleted)
|
||||
: []
|
||||
: (originalItems?.filter(
|
||||
(i) => i.feature_id && !currentFeatureIds.has(i.feature_id),
|
||||
) ?? []);
|
||||
: (originalItems?.filter(isItemDeleted) ?? []);
|
||||
|
||||
const sortedItems = useMemo(
|
||||
() => sortPlanItems({ items: product?.items ?? [] }),
|
||||
[product?.items],
|
||||
);
|
||||
const { visibleItems, collapsedBooleanItems } = useMemo(
|
||||
const { visibleItems: allVisibleItems, collapsedBooleanItems: allCollapsedBooleanItems } = useMemo(
|
||||
() => splitBooleanItems({ items: sortedItems }),
|
||||
[sortedItems],
|
||||
);
|
||||
|
||||
const isItemChanged = (item: ProductItem) => {
|
||||
const originalItem = originalItemsMap.get(getItemMatchKey(item));
|
||||
if (!originalItem) return true;
|
||||
return hasItemChanged({ originalItem, updatedItem: item });
|
||||
};
|
||||
|
||||
const visibleItems = changesOnly
|
||||
? allVisibleItems.filter(isItemChanged)
|
||||
: allVisibleItems;
|
||||
const collapsedBooleanItems = changesOnly
|
||||
? allCollapsedBooleanItems.filter(isItemChanged)
|
||||
: allCollapsedBooleanItems;
|
||||
|
||||
const hasItems = (product?.items?.length ?? 0) > 0 || deletedItems.length > 0;
|
||||
|
||||
if (!hasItems) {
|
||||
|
||||
@@ -6,6 +6,10 @@ import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/c
|
||||
import type { UseUpdateSubscriptionForm } from "@/components/forms/update-subscription-v2/hooks/useUpdateSubscriptionForm";
|
||||
import { LAYOUT_TRANSITION } from "@/components/v2/sheets/SharedSheetComponents";
|
||||
|
||||
export function getItemMatchKey(item: ProductItem): string {
|
||||
return `${item.feature_id}:${item.usage_model ?? ""}:${item.interval ?? ""}`;
|
||||
}
|
||||
|
||||
export function getPlanItemPrepaidQuantity({
|
||||
featureId,
|
||||
prepaidOptions,
|
||||
@@ -38,7 +42,7 @@ export function getPlanItemPrepaidQuantity({
|
||||
return prepaidOption?.quantity;
|
||||
}
|
||||
|
||||
function hasItemChanged({
|
||||
export function hasItemChanged({
|
||||
originalItem,
|
||||
updatedItem,
|
||||
}: {
|
||||
@@ -85,7 +89,7 @@ export function PlanItemRow({
|
||||
prepaidOptions: Record<string, number | undefined>;
|
||||
initialPrepaidOptions: Record<string, number | undefined>;
|
||||
existingOptions?: FeatureOptions[];
|
||||
form: UseUpdateSubscriptionForm | UseAttachForm;
|
||||
form?: UseUpdateSubscriptionForm | UseAttachForm;
|
||||
showDiff: boolean;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
@@ -103,9 +107,7 @@ export function PlanItemRow({
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const originalItem = originalItemsMap.get(
|
||||
`${featureId}:${item.usage_model ?? ""}`,
|
||||
);
|
||||
const originalItem = originalItemsMap.get(getItemMatchKey(item));
|
||||
|
||||
const isCreated =
|
||||
showDiff && !originalItem && !!originalItems && originalItems.length > 0;
|
||||
|
||||
@@ -101,6 +101,7 @@ export function InlineEditorProvider({
|
||||
initialItem={initialItem}
|
||||
setSheet={setSheet}
|
||||
setInitialItem={setInitialItem}
|
||||
updateItemId={setItemId}
|
||||
closeSheet={closeSheet}
|
||||
itemDraft={itemDraft}
|
||||
>
|
||||
|
||||
@@ -33,6 +33,7 @@ interface ProductContextValue {
|
||||
initialItem: ProductItem | null;
|
||||
setSheet: (params: { type: string | null; itemId?: string | null }) => void;
|
||||
setInitialItem: (item: ProductItem | null) => void;
|
||||
updateItemId: (itemId: string) => void;
|
||||
closeSheet: () => void;
|
||||
itemDraft: ItemDraftController;
|
||||
}
|
||||
@@ -54,6 +55,7 @@ export function ProductProvider({
|
||||
initialItem,
|
||||
setSheet,
|
||||
setInitialItem,
|
||||
updateItemId,
|
||||
closeSheet,
|
||||
itemDraft,
|
||||
}: {
|
||||
@@ -68,6 +70,7 @@ export function ProductProvider({
|
||||
initialItem: ProductItem | null;
|
||||
setSheet: (params: { type: string | null; itemId?: string | null }) => void;
|
||||
setInitialItem: (item: ProductItem | null) => void;
|
||||
updateItemId: (itemId: string) => void;
|
||||
closeSheet: () => void;
|
||||
itemDraft: ItemDraftController;
|
||||
}) {
|
||||
@@ -82,6 +85,7 @@ export function ProductProvider({
|
||||
initialItem,
|
||||
setSheet,
|
||||
setInitialItem,
|
||||
updateItemId,
|
||||
closeSheet,
|
||||
itemDraft,
|
||||
}}
|
||||
@@ -122,6 +126,8 @@ export function useSheet() {
|
||||
const storeSetInitialItem = useSheetStore((s) => s.setInitialItem);
|
||||
const storeCloseSheet = useSheetStore((s) => s.closeSheet);
|
||||
|
||||
const storeUpdateItemId = useSheetStore((s) => s.updateItemId);
|
||||
|
||||
if (context) {
|
||||
return {
|
||||
sheetType: context.sheetType,
|
||||
@@ -129,6 +135,7 @@ export function useSheet() {
|
||||
initialItem: context.initialItem,
|
||||
setSheet: context.setSheet,
|
||||
setInitialItem: context.setInitialItem,
|
||||
updateItemId: context.updateItemId,
|
||||
closeSheet: context.closeSheet,
|
||||
itemDraft: context.itemDraft,
|
||||
};
|
||||
@@ -140,6 +147,7 @@ export function useSheet() {
|
||||
initialItem: storeInitialItem,
|
||||
setSheet: storeSetSheet,
|
||||
setInitialItem: storeSetInitialItem,
|
||||
updateItemId: storeUpdateItemId,
|
||||
closeSheet: storeCloseSheet,
|
||||
itemDraft: disabledItemDraftController,
|
||||
};
|
||||
@@ -180,9 +188,9 @@ export function useCurrentItem() {
|
||||
}
|
||||
|
||||
/** Hook to set the current item being edited. Uses context if available, otherwise Zustand. */
|
||||
function useSetCurrentItem() {
|
||||
export function useSetCurrentItem() {
|
||||
const { product, setProduct } = useProduct();
|
||||
const { itemId, itemDraft } = useSheet();
|
||||
const { itemId, itemDraft, updateItemId } = useSheet();
|
||||
|
||||
return useCallback(
|
||||
(updatedItem: ProductItem) => {
|
||||
@@ -207,11 +215,19 @@ function useSetCurrentItem() {
|
||||
|
||||
if (originalIndex === -1) return;
|
||||
|
||||
const newItemId = getItemId({
|
||||
item: updatedItem,
|
||||
itemIndex: originalIndex,
|
||||
});
|
||||
if (newItemId !== itemId) {
|
||||
updateItemId(newItemId);
|
||||
}
|
||||
|
||||
const updatedItems = [...product.items];
|
||||
updatedItems[originalIndex] = updatedItem;
|
||||
setProduct({ ...product, items: updatedItems });
|
||||
},
|
||||
[itemDraft, itemId, product, setProduct],
|
||||
[itemDraft, itemId, product, setProduct, updateItemId],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ export const useMigrationsQuery = () => {
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
invalidate,
|
||||
createMigration: createMutation.mutateAsync,
|
||||
isCreating: createMutation.isPending,
|
||||
updateMigration: updateMutation.mutateAsync,
|
||||
|
||||
@@ -60,6 +60,7 @@ interface SheetState {
|
||||
data?: Record<string, unknown> | null;
|
||||
}) => void;
|
||||
setInitialItem: (item: ProductItem | null) => void;
|
||||
updateItemId: (itemId: string) => void;
|
||||
closeSheet: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -90,6 +91,9 @@ export const useSheetStore = create<SheetState>((set) => ({
|
||||
// Set the initial item state for change detection
|
||||
setInitialItem: (item) => set({ initialItem: item }),
|
||||
|
||||
// Update just the itemId without clearing other state
|
||||
updateItemId: (itemId) => set({ itemId }),
|
||||
|
||||
// Close the sheet
|
||||
closeSheet: () =>
|
||||
set((state) => ({
|
||||
|
||||
@@ -367,11 +367,6 @@ export function MigrationLiveView({
|
||||
)}
|
||||
|
||||
<StepIndicator step={step} onStepChange={onStepChange}>
|
||||
{count !== null && (
|
||||
<span className="text-xs text-tertiary-foreground">
|
||||
{count} {count === 1 ? "customer" : "customers"}
|
||||
</span>
|
||||
)}
|
||||
{onPrevious && (
|
||||
<Button variant="secondary" size="default" onClick={onPrevious}>
|
||||
<ArrowLeftIcon size={14} />
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { FrontendProduct, ProductItem } from "@autumn/shared";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { ProductProvider } from "@/components/v2/inline-custom-plan-editor/PlanEditorContext";
|
||||
import {
|
||||
ProductProvider,
|
||||
useCurrentItem,
|
||||
useSetCurrentItem,
|
||||
} from "@/components/v2/inline-custom-plan-editor/PlanEditorContext";
|
||||
import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet";
|
||||
import { disabledItemDraftController } from "@/hooks/inline-editor/useItemDraftController";
|
||||
import { getItemId } from "@/utils/product/productItemUtils";
|
||||
@@ -143,27 +147,6 @@ function MigrationOperationSheetContent({
|
||||
onSave(latestProduct.current);
|
||||
};
|
||||
|
||||
const handleFeatureCommit = async () => {
|
||||
onSave(latestProduct.current);
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentItem =
|
||||
product.items?.find(
|
||||
(item, i) => getItemId({ item, itemIndex: i }) === itemId,
|
||||
) ?? null;
|
||||
|
||||
const setCurrentItem = (updatedItem: ProductItem) => {
|
||||
if (!product.items || !itemId) return;
|
||||
const index = product.items.findIndex(
|
||||
(item, i) => getItemId({ item, itemIndex: i }) === itemId,
|
||||
);
|
||||
if (index === -1) return;
|
||||
const updatedItems = [...product.items];
|
||||
updatedItems[index] = updatedItem;
|
||||
wrappedSetProduct((prev) => ({ ...prev, items: updatedItems }));
|
||||
};
|
||||
|
||||
return (
|
||||
<ProductProvider
|
||||
product={product}
|
||||
@@ -173,41 +156,70 @@ function MigrationOperationSheetContent({
|
||||
initialItem={initialItem}
|
||||
setSheet={handleSetSheet}
|
||||
setInitialItem={setInitialItem}
|
||||
updateItemId={setItemId}
|
||||
closeSheet={handleApply}
|
||||
itemDraft={disabledItemDraftController}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sheetType === "select-feature" && <SelectFeatureSheet />}
|
||||
{sheetType === "edit-plan-price" && <EditPlanPriceSheet />}
|
||||
{sheetType === "edit-feature" && currentItem && (
|
||||
<ProductItemContext.Provider
|
||||
value={{
|
||||
item: currentItem,
|
||||
initialItem,
|
||||
setItem: setCurrentItem,
|
||||
selectedIndex: 0,
|
||||
showCreateFeature: false,
|
||||
setShowCreateFeature: () => {},
|
||||
isUpdate: !!editItem,
|
||||
handleUpdateProductItem: handleFeatureCommit,
|
||||
}}
|
||||
>
|
||||
<EditPlanFeatureSheet />
|
||||
</ProductItemContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
{sheetType === "edit-plan-price" && (
|
||||
<div className="shrink-0 p-4 border-t border-border/40 flex gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleApply} className="flex-1">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MigrationSheetInner
|
||||
sheetType={sheetType}
|
||||
isUpdate={!!editItem}
|
||||
onApply={handleApply}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</ProductProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function MigrationSheetInner({
|
||||
sheetType,
|
||||
isUpdate,
|
||||
onApply,
|
||||
onCancel,
|
||||
}: {
|
||||
sheetType: string;
|
||||
isUpdate: boolean;
|
||||
onApply: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const currentItem = useCurrentItem();
|
||||
const setCurrentItem = useSetCurrentItem();
|
||||
|
||||
const handleFeatureCommit = async () => {
|
||||
onApply();
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sheetType === "select-feature" && <SelectFeatureSheet />}
|
||||
{sheetType === "edit-plan-price" && <EditPlanPriceSheet />}
|
||||
{sheetType === "edit-feature" && currentItem && (
|
||||
<ProductItemContext.Provider
|
||||
value={{
|
||||
item: currentItem,
|
||||
setItem: setCurrentItem,
|
||||
selectedIndex: 0,
|
||||
showCreateFeature: false,
|
||||
setShowCreateFeature: () => {},
|
||||
isUpdate,
|
||||
handleUpdateProductItem: handleFeatureCommit,
|
||||
}}
|
||||
>
|
||||
<EditPlanFeatureSheet />
|
||||
</ProductItemContext.Provider>
|
||||
)}
|
||||
</div>
|
||||
{sheetType === "edit-plan-price" && (
|
||||
<div className="shrink-0 p-4 border-t border-border/40 flex gap-2">
|
||||
<Button variant="secondary" onClick={onCancel} className="flex-1">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={onApply} className="flex-1">
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export function OperationsForm({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
|
||||
<PlusIcon size={10} />
|
||||
Add Operation
|
||||
Update or add a different plan
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { FeatureSearchDropdown } from "@/components/v2/dropdowns/FeatureSearchDropdown";
|
||||
import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CaretDownIcon } from "@phosphor-icons/react";
|
||||
import { PlanFeatureIcon } from "@/views/products/plan/components/plan-card/PlanFeatureIcon";
|
||||
import { CustomDotIcon } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
|
||||
import { RemoveButton } from "../shared/RemoveButton";
|
||||
import {
|
||||
BillingMethodDropdown,
|
||||
type ItemFilter,
|
||||
INTERVAL_OPTIONS,
|
||||
filterToProductItem,
|
||||
getFilterSummary,
|
||||
} from "./operationItemUtils";
|
||||
|
||||
export function RemoveItemRows({
|
||||
item,
|
||||
@@ -13,21 +31,220 @@ export function RemoveItemRows({
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { features } = useFeaturesQuery();
|
||||
const featureId = (item.feature_id as string) || null;
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const filter = item as ItemFilter;
|
||||
const hasFeature = !!filter.feature_id;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 group/row">
|
||||
<span className="text-xs text-subtle w-14 shrink-0 select-none">Remove</span>
|
||||
<FeatureSearchDropdown
|
||||
features={features}
|
||||
value={featureId}
|
||||
onSelect={(v) => onChange({ ...item, feature_id: v })}
|
||||
placeholder="Select feature to remove..."
|
||||
triggerClassName={cn(
|
||||
featureId && "!border-destructive/50 hover:!border-destructive/60",
|
||||
)}
|
||||
<>
|
||||
<div className="flex items-center gap-2 group/row">
|
||||
<span className="text-xs text-red-500/60 w-14 shrink-0 select-none">
|
||||
Remove
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="flex items-center gap-2 h-8 px-3 w-full select-none rounded-xl cursor-pointer text-left input-base input-state-open-tiny"
|
||||
>
|
||||
{hasFeature ? (
|
||||
<>
|
||||
<div className="flex flex-row items-center gap-1 shrink-0">
|
||||
<PlanFeatureIcon
|
||||
item={filterToProductItem(filter)}
|
||||
position="left"
|
||||
/>
|
||||
<CustomDotIcon />
|
||||
<PlanFeatureIcon
|
||||
item={filterToProductItem(filter)}
|
||||
position="right"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-body whitespace-nowrap truncate flex-1 min-w-0">
|
||||
{getFilterSummary(filter, features)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-subtle">Configure removal...</span>
|
||||
)}
|
||||
</button>
|
||||
<RemoveButton onClick={onRemove} />
|
||||
</div>
|
||||
|
||||
<RemoveItemSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
item={filter}
|
||||
onSave={(updated) => {
|
||||
onChange(updated);
|
||||
setSheetOpen(false);
|
||||
}}
|
||||
/>
|
||||
<RemoveButton onClick={onRemove} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveItemSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: ItemFilter;
|
||||
onSave: (item: ItemFilter) => void;
|
||||
}) {
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) setKey((k) => k + 1);
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" hideCloseButton>
|
||||
{open && (
|
||||
<RemoveItemSheetContent
|
||||
key={key}
|
||||
item={item}
|
||||
onSave={onSave}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveItemSheetContent({
|
||||
item,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
item: ItemFilter;
|
||||
onSave: (item: ItemFilter) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { features } = useFeaturesQuery();
|
||||
const [draft, setDraft] = useState<ItemFilter>(() =>
|
||||
structuredClone(item),
|
||||
);
|
||||
|
||||
const canSave = !!draft.feature_id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
Remove Item
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary-foreground">
|
||||
Select a feature to remove from the plan. Use interval
|
||||
and billing method to narrow the match.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Feature
|
||||
</label>
|
||||
<FeatureSearchDropdown
|
||||
features={features}
|
||||
value={draft.feature_id ?? null}
|
||||
onSelect={(v) =>
|
||||
setDraft({ ...draft, feature_id: v })
|
||||
}
|
||||
placeholder="Select feature..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Interval
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full rounded-lg border bg-transparent text-sm outline-none h-input input-base input-shadow-default input-state-open p-2"
|
||||
>
|
||||
<span className="truncate">
|
||||
{draft.interval
|
||||
? INTERVAL_OPTIONS.find((o) => o.value === draft.interval)?.label ?? draft.interval
|
||||
: "Any interval"}
|
||||
</span>
|
||||
<CaretDownIcon className="size-4 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--anchor-width) p-1"
|
||||
>
|
||||
{draft.interval && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, interval: undefined })
|
||||
}
|
||||
className="py-1.5 px-2 text-muted-foreground"
|
||||
>
|
||||
Any interval
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{INTERVAL_OPTIONS.map((o) => (
|
||||
<DropdownMenuItem
|
||||
key={o.value}
|
||||
onClick={() =>
|
||||
setDraft({ ...draft, interval: o.value })
|
||||
}
|
||||
className="py-1.5 px-2"
|
||||
>
|
||||
{o.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<p className="text-xs text-tertiary-foreground">
|
||||
Narrow the match when the same feature appears at
|
||||
multiple intervals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Billing method
|
||||
</label>
|
||||
<BillingMethodDropdown
|
||||
value={draft.billing_method ?? null}
|
||||
onChange={(v) =>
|
||||
setDraft({ ...draft, billing_method: v })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 p-4 border-t border-border/40 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={!canSave}
|
||||
className="flex-1"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { ProductItem, UpdatePlanItemParamsV1 } from "@autumn/shared";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { FeatureSearchDropdown } from "@/components/v2/dropdowns/FeatureSearchDropdown";
|
||||
import { Input } from "@/components/v2/inputs/Input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/v2/selects/Select";
|
||||
import { Sheet, SheetContent } from "@/components/v2/sheets/Sheet";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { PlanFeatureIcon } from "@/views/products/plan/components/plan-card/PlanFeatureIcon";
|
||||
import { CustomDotIcon } from "@/views/products/plan/components/plan-card/PlanFeatureRow";
|
||||
import { RemoveButton } from "../shared/RemoveButton";
|
||||
import {
|
||||
BillingMethodDropdown,
|
||||
CLEAR_VALUE,
|
||||
INTERVAL_OPTIONS,
|
||||
filterToProductItem,
|
||||
getFilterSummary,
|
||||
} from "./operationItemUtils";
|
||||
|
||||
function updateFilterToProductItem(item: UpdatePlanItemParamsV1): ProductItem {
|
||||
const base = filterToProductItem({
|
||||
feature_id: item.filter?.feature_id,
|
||||
interval: item.filter?.interval,
|
||||
billing_method: item.filter?.billing_method,
|
||||
});
|
||||
return { ...base, included_usage: item.included } as ProductItem;
|
||||
}
|
||||
|
||||
export function UpdateItemRows({
|
||||
item,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
item: UpdatePlanItemParamsV1;
|
||||
onChange: (item: UpdatePlanItemParamsV1) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { features } = useFeaturesQuery();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
const hasFeature = !!item.filter?.feature_id;
|
||||
const summary = hasFeature ? getFilterSummary(
|
||||
{ feature_id: item.filter?.feature_id, interval: item.filter?.interval },
|
||||
features,
|
||||
) : null;
|
||||
const secondary =
|
||||
item.included !== undefined ? `→ ${item.included} included` : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 group/row">
|
||||
<span className="text-xs text-yellow-500/60 w-14 shrink-0 select-none">
|
||||
Update
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
className="flex items-center gap-2 h-8 px-3 w-full select-none rounded-xl cursor-pointer text-left input-base input-state-open-tiny"
|
||||
>
|
||||
{hasFeature ? (
|
||||
<>
|
||||
<div className="flex flex-row items-center gap-1 shrink-0">
|
||||
<PlanFeatureIcon item={updateFilterToProductItem(item)} position="left" />
|
||||
<CustomDotIcon />
|
||||
<PlanFeatureIcon item={updateFilterToProductItem(item)} position="right" />
|
||||
</div>
|
||||
<p className="whitespace-nowrap truncate flex-1 min-w-0">
|
||||
<span className="text-body">
|
||||
{summary}
|
||||
</span>
|
||||
<span className="text-body-secondary">
|
||||
{" "}{secondary}
|
||||
</span>
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-subtle">Configure update...</span>
|
||||
)}
|
||||
</button>
|
||||
<RemoveButton onClick={onRemove} />
|
||||
</div>
|
||||
|
||||
<UpdateItemSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
item={item}
|
||||
onSave={(updated) => {
|
||||
onChange(updated);
|
||||
setSheetOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateItemSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
item,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
item: UpdatePlanItemParamsV1;
|
||||
onSave: (item: UpdatePlanItemParamsV1) => void;
|
||||
}) {
|
||||
const [key, setKey] = useState(0);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (isOpen) setKey((k) => k + 1);
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<SheetContent side="right" hideCloseButton>
|
||||
{open && (
|
||||
<UpdateItemSheetContent
|
||||
key={key}
|
||||
item={item}
|
||||
onSave={onSave}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateItemSheetContent({
|
||||
item,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
item: UpdatePlanItemParamsV1;
|
||||
onSave: (item: UpdatePlanItemParamsV1) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { features } = useFeaturesQuery();
|
||||
const [draft, setDraft] = useState<UpdatePlanItemParamsV1>(
|
||||
() => structuredClone(item),
|
||||
);
|
||||
|
||||
const featureId = draft.filter?.feature_id ?? null;
|
||||
const canSave = !!featureId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
Update Item
|
||||
</h3>
|
||||
<p className="text-xs text-tertiary-foreground">
|
||||
Override properties on an existing plan item. Use the
|
||||
filter fields to target the specific item.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Feature
|
||||
</label>
|
||||
<FeatureSearchDropdown
|
||||
features={features}
|
||||
value={featureId}
|
||||
onSelect={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
filter: { ...draft.filter, feature_id: v },
|
||||
})
|
||||
}
|
||||
placeholder="Select feature..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Interval
|
||||
</label>
|
||||
<Select
|
||||
value={draft.filter?.interval ?? ""}
|
||||
onValueChange={(v) => {
|
||||
const interval =
|
||||
v === CLEAR_VALUE ? undefined : v;
|
||||
setDraft({
|
||||
...draft,
|
||||
filter: {
|
||||
...draft.filter,
|
||||
interval:
|
||||
interval as UpdatePlanItemParamsV1["filter"]["interval"],
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-xl">
|
||||
<span className="flex-1 text-left text-sm">
|
||||
{draft.filter?.interval ? (
|
||||
<SelectValue />
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
Any interval
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{draft.filter?.interval && (
|
||||
<SelectItem
|
||||
value={CLEAR_VALUE}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Any interval
|
||||
</SelectItem>
|
||||
)}
|
||||
{INTERVAL_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-tertiary-foreground">
|
||||
Narrow the match when the same feature appears at
|
||||
multiple intervals.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
Billing method
|
||||
</label>
|
||||
<BillingMethodDropdown
|
||||
value={draft.filter?.billing_method ?? null}
|
||||
onChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
filter: {
|
||||
...draft.filter,
|
||||
billing_method:
|
||||
v as UpdatePlanItemParamsV1["filter"]["billing_method"],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3 mt-1">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-foreground">
|
||||
New Included Usage
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={draft.included ?? ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setDraft({
|
||||
...draft,
|
||||
included:
|
||||
val === ""
|
||||
? undefined
|
||||
: Number(val),
|
||||
});
|
||||
}}
|
||||
placeholder="New included amount"
|
||||
className="h-8 rounded-xl"
|
||||
/>
|
||||
<p className="text-xs text-tertiary-foreground">
|
||||
The new allowance for matched items. Existing
|
||||
usage carries forward.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 p-4 border-t border-border/40 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => onSave(draft)}
|
||||
disabled={!canSave}
|
||||
className="flex-1"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
BillingInterval,
|
||||
FrontendProduct,
|
||||
ProductItem,
|
||||
UpdatePlanItemParamsV1,
|
||||
UpdatePlanOp,
|
||||
} from "@autumn/shared";
|
||||
import { productV2ToBasePrice } from "@autumn/shared";
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
type OperationSheetMode,
|
||||
} from "./MigrationOperationSheet";
|
||||
import { RemoveItemRows } from "./RemoveItemRows";
|
||||
import { UpdateItemRows } from "./UpdateItemRows";
|
||||
|
||||
function useVersionOptions(planFilter: UpdatePlanOp["plan_filter"]) {
|
||||
const { products } = useProductsQuery({ allVersions: true });
|
||||
@@ -270,7 +272,7 @@ export function UpdatePlanOpForm({
|
||||
|
||||
{addItems.map((item, index) => (
|
||||
<div key={`add-${index}`} className="flex items-center gap-2 group/row">
|
||||
<span className="text-xs text-subtle w-14 shrink-0 select-none">Add</span>
|
||||
<span className="text-xs text-green-500/60 w-14 shrink-0 select-none">Add</span>
|
||||
<ItemSummaryRow
|
||||
item={item}
|
||||
onClick={() => openSheet("edit-feature", index)}
|
||||
@@ -302,19 +304,42 @@ export function UpdatePlanOpForm({
|
||||
/>
|
||||
))}
|
||||
|
||||
{(customize?.update_items ?? []).map((item, index) => (
|
||||
<UpdateItemRows
|
||||
key={`update-${index}`}
|
||||
item={item}
|
||||
onChange={(updated) => {
|
||||
const items = [...(customize?.update_items ?? [])];
|
||||
items[index] = updated;
|
||||
update({ customize: { ...customize, update_items: items } });
|
||||
}}
|
||||
onRemove={() => {
|
||||
const items = (customize?.update_items ?? []).filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
update({
|
||||
customize: {
|
||||
...customize,
|
||||
update_items: items.length > 0 ? items : undefined,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={DASHED_BUTTON_CLASS}>
|
||||
<PlusIcon size={10} />
|
||||
Add modification
|
||||
Add a modification to this plan
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-(--anchor-width)">
|
||||
{value.version === undefined && (
|
||||
<DropdownMenuItem
|
||||
closeOnClick
|
||||
onClick={() => update({ version: 1 })}
|
||||
>
|
||||
Version
|
||||
</DropdownMenuItem>
|
||||
>
|
||||
Set Plan Version
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(!customize || customize.price === undefined) && (
|
||||
<DropdownMenuItem
|
||||
@@ -346,6 +371,22 @@ export function UpdatePlanOpForm({
|
||||
>
|
||||
Remove Item
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
closeOnClick
|
||||
onClick={() =>
|
||||
update({
|
||||
customize: {
|
||||
...customize,
|
||||
update_items: [
|
||||
...(customize?.update_items ?? []),
|
||||
{ filter: {} } as unknown as UpdatePlanItemParamsV1,
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Update Item
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Feature, ProductItem } from "@autumn/shared";
|
||||
import { BillingInterval, EntInterval, UsageModel } from "@autumn/shared";
|
||||
import {
|
||||
BoxArrowDownIcon,
|
||||
CaretDownIcon,
|
||||
MoneyWavyIcon,
|
||||
WalletIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import type React from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
|
||||
|
||||
const LABEL_OVERRIDES: Record<string, string> = {
|
||||
[BillingInterval.SemiAnnual]: "Semi-annual",
|
||||
[BillingInterval.OneOff]: "One-off",
|
||||
};
|
||||
|
||||
const billingSet = new Set<string>(Object.values(BillingInterval));
|
||||
const allIntervals = [
|
||||
...Object.values(BillingInterval),
|
||||
...Object.values(EntInterval).filter((v) => !billingSet.has(v)),
|
||||
];
|
||||
|
||||
export const INTERVAL_OPTIONS: { value: string; label: string }[] =
|
||||
allIntervals.map((v) => ({ value: v, label: LABEL_OVERRIDES[v] ?? keyToTitle(v) }));
|
||||
|
||||
export const CLEAR_VALUE = "__clear__";
|
||||
|
||||
const BILLING_METHOD_OPTIONS: {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}[] = [
|
||||
{
|
||||
value: "included",
|
||||
label: "Included",
|
||||
icon: <BoxArrowDownIcon size={16} weight="duotone" />,
|
||||
color: "text-green-500",
|
||||
},
|
||||
{
|
||||
value: "usage_based",
|
||||
label: "Usage-based",
|
||||
icon: <MoneyWavyIcon size={16} weight="duotone" />,
|
||||
color: "text-yellow-500",
|
||||
},
|
||||
{
|
||||
value: "prepaid",
|
||||
label: "Prepaid",
|
||||
icon: <WalletIcon size={16} weight="duotone" />,
|
||||
color: "text-orange-500",
|
||||
},
|
||||
];
|
||||
|
||||
export function BillingMethodDropdown({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string | null;
|
||||
onChange: (value: string | undefined) => void;
|
||||
}) {
|
||||
const selected = BILLING_METHOD_OPTIONS.find((o) => o.value === value);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full rounded-lg border bg-transparent text-sm outline-none h-input input-base input-shadow-default input-state-open p-2"
|
||||
>
|
||||
{selected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={selected.color}>{selected.icon}</span>
|
||||
<span className="truncate">{selected.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="truncate text-muted-foreground">Any method</span>
|
||||
)}
|
||||
<CaretDownIcon className="size-4 opacity-50" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-(--anchor-width) p-1"
|
||||
>
|
||||
{selected && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onChange(undefined)}
|
||||
className="py-1.5 px-2 text-muted-foreground"
|
||||
>
|
||||
Any method
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{BILLING_METHOD_OPTIONS.map((o) => (
|
||||
<DropdownMenuItem
|
||||
key={o.value}
|
||||
onClick={() =>
|
||||
onChange(
|
||||
o.value === "included" ? undefined : o.value,
|
||||
)
|
||||
}
|
||||
className="py-1.5 px-2"
|
||||
>
|
||||
<span className={o.color}>{o.icon}</span>
|
||||
<span className="truncate flex-1">{o.label}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ItemFilter {
|
||||
feature_id?: string;
|
||||
interval?: string;
|
||||
billing_method?: string;
|
||||
}
|
||||
|
||||
export function filterToProductItem(filter: ItemFilter): ProductItem {
|
||||
return {
|
||||
feature_id: filter.feature_id,
|
||||
interval: filter.interval,
|
||||
usage_model:
|
||||
filter.billing_method === "prepaid"
|
||||
? UsageModel.Prepaid
|
||||
: filter.billing_method === "usage_based"
|
||||
? UsageModel.PayPerUse
|
||||
: undefined,
|
||||
tiers:
|
||||
filter.billing_method === "usage_based"
|
||||
? [{ to: "inf", amount: 0 }]
|
||||
: undefined,
|
||||
} as ProductItem;
|
||||
}
|
||||
|
||||
export function getFilterSummary(
|
||||
filter: ItemFilter,
|
||||
features: Feature[],
|
||||
): string {
|
||||
const feature = features.find((f) => f.id === filter.feature_id);
|
||||
const name = feature?.name || filter.feature_id || "Unconfigured";
|
||||
const parts: string[] = [name];
|
||||
if (filter.interval) parts.push(filter.interval);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -17,6 +17,7 @@ function hasCustomizations(
|
||||
if (!customize) return false;
|
||||
if ((customize.add_items?.length ?? 0) > 0) return true;
|
||||
if ((customize.remove_items?.length ?? 0) > 0) return true;
|
||||
if ((customize.update_items?.length ?? 0) > 0) return true;
|
||||
if (customize.price !== undefined) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export const ProductSheets = () => {
|
||||
itemId,
|
||||
initialItem,
|
||||
setInitialItem,
|
||||
updateItemId,
|
||||
closeSheet,
|
||||
itemDraft,
|
||||
} = useSheet();
|
||||
@@ -107,6 +108,14 @@ export const ProductSheets = () => {
|
||||
|
||||
if (currentItemIndex === -1) return;
|
||||
|
||||
const newItemId = getItemId({
|
||||
item: updatedItem,
|
||||
itemIndex: currentItemIndex,
|
||||
});
|
||||
if (newItemId !== itemId) {
|
||||
updateItemId(newItemId);
|
||||
}
|
||||
|
||||
const updatedItems = [...product.items];
|
||||
updatedItems[currentItemIndex] = updatedItem;
|
||||
setProduct({ ...product, items: updatedItems });
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
MinusCircleIcon,
|
||||
PencilSimpleIcon,
|
||||
PlusCircleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
import type { FrontendProduct } from "@autumn/shared";
|
||||
import { isPriceItem, productsAreSame } from "@autumn/shared";
|
||||
import { CheckCircleIcon } from "@phosphor-icons/react";
|
||||
import { LucideLoaderCircle } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { PlanItemsSection } from "@/components/forms/shared";
|
||||
import { getProductPriceDisplay } from "@/components/forms/update-subscription-v2/components/PriceDisplay";
|
||||
import { Button } from "@/components/v2/buttons/Button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/v2/dialogs/Dialog";
|
||||
import { Input } from "@/components/v2/inputs/Input";
|
||||
import { useOrg } from "@/hooks/common/useOrg";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery";
|
||||
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
|
||||
@@ -24,37 +27,37 @@ import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { getBackendErr, navigateTo } from "@/utils/genUtils";
|
||||
import { useProductQuery } from "../../product/hooks/useProductQuery";
|
||||
import { updateProduct } from "../../product/utils/updateProduct";
|
||||
import {
|
||||
buildDiffSummary,
|
||||
buildMigrationDraft,
|
||||
type DiffSummaryEntry,
|
||||
} from "./buildMigrationDraft";
|
||||
import { buildMigrationDraft, type MigrationDraft } from "./buildMigrationDraft";
|
||||
|
||||
const ACTION_ICONS = {
|
||||
added: PlusCircleIcon,
|
||||
removed: MinusCircleIcon,
|
||||
changed: PencilSimpleIcon,
|
||||
} as const;
|
||||
function usePriceChange(
|
||||
baseProduct: FrontendProduct | null,
|
||||
product: FrontendProduct,
|
||||
currency: string,
|
||||
) {
|
||||
return useMemo(() => {
|
||||
if (!baseProduct) return null;
|
||||
|
||||
const ACTION_COLORS = {
|
||||
added: "text-emerald-500",
|
||||
removed: "text-red-500",
|
||||
changed: "text-amber-500",
|
||||
} as const;
|
||||
const oldDisplay = getProductPriceDisplay({ product: baseProduct, currency });
|
||||
const newDisplay = getProductPriceDisplay({ product, currency });
|
||||
|
||||
function DiffEntry({ entry }: { entry: DiffSummaryEntry }) {
|
||||
const Icon = ACTION_ICONS[entry.action];
|
||||
const color = ACTION_COLORS[entry.action];
|
||||
const oldPrice = oldDisplay.type === "price" ? oldDisplay.formattedPrice : "Free";
|
||||
const newPrice = newDisplay.type === "price" ? newDisplay.formattedPrice : "Free";
|
||||
const oldInterval = oldDisplay.type === "price" ? oldDisplay.intervalText : null;
|
||||
const newInterval = newDisplay.type === "price" ? newDisplay.intervalText : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon size={16} weight="fill" className={color} />
|
||||
<span className="capitalize text-tertiary-foreground text-xs w-16">
|
||||
{entry.action}
|
||||
</span>
|
||||
<span className="text-foreground">{entry.label}</span>
|
||||
</div>
|
||||
);
|
||||
if (oldPrice === newPrice && oldInterval === newInterval) return null;
|
||||
|
||||
const originalPriceItem = baseProduct.items?.find((i) => isPriceItem(i));
|
||||
const currentPriceItem = product.items?.find((i) => isPriceItem(i));
|
||||
|
||||
return {
|
||||
oldPrice,
|
||||
newPrice,
|
||||
oldIntervalText: oldInterval !== newInterval ? oldInterval : null,
|
||||
newIntervalText: newInterval,
|
||||
isUpgrade: (currentPriceItem?.price ?? 0) > (originalPriceItem?.price ?? 0),
|
||||
};
|
||||
}, [baseProduct, product.items, currency]);
|
||||
}
|
||||
|
||||
export default function PlanChangeDialog({
|
||||
@@ -71,22 +74,29 @@ export default function PlanChangeDialog({
|
||||
const { features = [] } = useFeaturesQuery();
|
||||
const { refetch } = useProductQuery();
|
||||
const { invalidate: invalidateProducts } = useProductsQuery();
|
||||
const { createMigration } = useMigrationsQuery();
|
||||
const { createMigration, invalidate: invalidateMigrations } = useMigrationsQuery();
|
||||
const { org } = useOrg();
|
||||
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadingAction, setLoadingAction] = useState<
|
||||
"new-version" | "update" | null
|
||||
"new-version" | "update" | "migrate" | null
|
||||
>(null);
|
||||
const [step, setStep] = useState<"confirm" | "plan-updated">("confirm");
|
||||
const migrationDraftRef = useRef<MigrationDraft | null>(null);
|
||||
|
||||
const diffSummary =
|
||||
baseProduct && features.length > 0
|
||||
? buildDiffSummary({
|
||||
baseProduct,
|
||||
editedProduct: product,
|
||||
features,
|
||||
})
|
||||
: [];
|
||||
const currency = org?.default_currency ?? "USD";
|
||||
const priceChange = usePriceChange(baseProduct, product, currency);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (!baseProduct || features.length === 0) return false;
|
||||
const { same } = productsAreSame({
|
||||
curProductV2: baseProduct,
|
||||
newProductV2: product,
|
||||
features,
|
||||
});
|
||||
return !same;
|
||||
}, [baseProduct, product, features]);
|
||||
|
||||
const confirmed = confirmText === product.id;
|
||||
|
||||
@@ -115,7 +125,7 @@ export default function PlanChangeDialog({
|
||||
setConfirmText("");
|
||||
};
|
||||
|
||||
const handleUpdateAndMigrate = async () => {
|
||||
const handleUpdatePlan = async () => {
|
||||
if (!confirmed) {
|
||||
toast.error("Confirmation text is incorrect");
|
||||
return;
|
||||
@@ -126,6 +136,12 @@ export default function PlanChangeDialog({
|
||||
setLoadingAction("update");
|
||||
|
||||
try {
|
||||
migrationDraftRef.current = buildMigrationDraft({
|
||||
baseProduct,
|
||||
editedProduct: product,
|
||||
features,
|
||||
});
|
||||
|
||||
const result = await updateProduct({
|
||||
axiosInstance,
|
||||
productId: product.id,
|
||||
@@ -139,17 +155,28 @@ export default function PlanChangeDialog({
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
setIsLoading(false);
|
||||
setLoadingAction(null);
|
||||
migrationDraftRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const draft = buildMigrationDraft({
|
||||
baseProduct,
|
||||
editedProduct: product,
|
||||
features,
|
||||
});
|
||||
setStep("plan-updated");
|
||||
} catch (error) {
|
||||
toast.error(getBackendErr(error, "Failed to update plan"));
|
||||
migrationDraftRef.current = null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoadingAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateMigration = async () => {
|
||||
const draft = migrationDraftRef.current;
|
||||
if (!draft) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadingAction("migrate");
|
||||
|
||||
try {
|
||||
const migration = await createMigration({
|
||||
id: draft.id,
|
||||
filter: draft.filter,
|
||||
@@ -157,8 +184,12 @@ export default function PlanChangeDialog({
|
||||
no_billing_changes: draft.no_billing_changes,
|
||||
});
|
||||
|
||||
await invalidateMigrations();
|
||||
|
||||
setOpen(false);
|
||||
setConfirmText("");
|
||||
setStep("confirm");
|
||||
migrationDraftRef.current = null;
|
||||
toast.success("Migration created from plan changes");
|
||||
navigateTo(`/migrations/${migration.id}?step=operations`, navigate);
|
||||
} catch (error) {
|
||||
@@ -172,76 +203,148 @@ export default function PlanChangeDialog({
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!isLoading) {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setConfirmText("");
|
||||
if (!nextOpen) {
|
||||
setConfirmText("");
|
||||
setStep("confirm");
|
||||
migrationDraftRef.current = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save plan changes</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="text-sm flex flex-col gap-4">
|
||||
<p>
|
||||
This plan has existing customers. Choose how to
|
||||
apply your changes.
|
||||
</p>
|
||||
{step === "confirm" ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save plan changes</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="text-sm flex flex-col gap-6">
|
||||
<p>
|
||||
This plan has existing customers. Choose how to
|
||||
apply your changes.
|
||||
</p>
|
||||
|
||||
{diffSummary.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border p-3">
|
||||
<span className="text-xs font-medium text-tertiary-foreground mb-1">
|
||||
Changes
|
||||
</span>
|
||||
{diffSummary.map((entry, i) => (
|
||||
<DiffEntry key={i} entry={entry} />
|
||||
))}
|
||||
{hasChanges && (
|
||||
<PlanItemsSection
|
||||
product={product}
|
||||
originalItems={baseProduct?.items}
|
||||
features={features}
|
||||
prepaidOptions={{}}
|
||||
initialPrepaidOptions={{}}
|
||||
showDiff
|
||||
changesOnly
|
||||
currency={currency}
|
||||
onEditPlan={() => {}}
|
||||
priceChange={priceChange}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p>
|
||||
Type{" "}
|
||||
<code className="font-bold">{product.id}</code>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) =>
|
||||
setConfirmText(e.target.value)
|
||||
}
|
||||
type="text"
|
||||
placeholder={product.id}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<p>
|
||||
Type{" "}
|
||||
<code className="font-bold">{product.id}</code>{" "}
|
||||
to continue.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) =>
|
||||
setConfirmText(e.target.value)
|
||||
}
|
||||
type="text"
|
||||
placeholder={product.id}
|
||||
className="w-full"
|
||||
<DialogFooter className="flex flex-col gap-3 sm:flex-col">
|
||||
<ActionCard
|
||||
title="Update existing plan"
|
||||
description="Update the plan and create a migration to move existing customers to the new configuration."
|
||||
onClick={handleUpdatePlan}
|
||||
isLoading={loadingAction === "update"}
|
||||
disabled={isLoading || !confirmed}
|
||||
/>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ActionCard
|
||||
title="Create new version"
|
||||
description="Publish a new version for future customers. Existing customers stay on their current plan."
|
||||
onClick={handleNewVersion}
|
||||
isLoading={loadingAction === "new-version"}
|
||||
disabled={isLoading || !confirmed}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircleIcon
|
||||
size={20}
|
||||
weight="fill"
|
||||
className="text-green-500"
|
||||
/>
|
||||
<DialogTitle>Plan updated</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
Create a migration to move existing customers to
|
||||
the new plan configuration.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleUpdateAndMigrate}
|
||||
isLoading={loadingAction === "update"}
|
||||
disabled={isLoading || !confirmed}
|
||||
className="w-full"
|
||||
>
|
||||
Update plan & existing customers
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleNewVersion}
|
||||
isLoading={loadingAction === "new-version"}
|
||||
disabled={isLoading || !confirmed}
|
||||
className="w-full"
|
||||
>
|
||||
Create new version
|
||||
</Button>
|
||||
<p className="text-xs text-tertiary-foreground text-center">
|
||||
New version only applies to new customers
|
||||
</p>
|
||||
</DialogFooter>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateMigration}
|
||||
isLoading={loadingAction === "migrate"}
|
||||
disabled={isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
Create migration
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionCard({
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
isLoading,
|
||||
disabled,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
isLoading: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"w-full text-left rounded-lg border p-3 transition-colors cursor-pointer",
|
||||
"hover:border-primary/50 hover:bg-interactive-secondary",
|
||||
"disabled:opacity-50 disabled:pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{isLoading && (
|
||||
<LucideLoaderCircle className="animate-spin size-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-tertiary-foreground mt-0.5">{description}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { Feature, FrontendProduct, ProductItem } from "@autumn/shared";
|
||||
import { isPriceItem, productsAreSame } from "@autumn/shared";
|
||||
import {
|
||||
findSimilarItem,
|
||||
isPriceItem,
|
||||
itemsAreSame,
|
||||
productsAreSame,
|
||||
} from "@autumn/shared";
|
||||
import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js";
|
||||
import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js";
|
||||
|
||||
@@ -10,11 +15,6 @@ export interface MigrationDraft {
|
||||
no_billing_changes: boolean;
|
||||
}
|
||||
|
||||
export interface DiffSummaryEntry {
|
||||
action: "added" | "removed" | "changed";
|
||||
label: string;
|
||||
}
|
||||
|
||||
function productItemToAddItem(item: ProductItem): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { feature_id: item.feature_id };
|
||||
|
||||
@@ -34,64 +34,43 @@ function productItemToAddItem(item: ProductItem): Record<string, unknown> {
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatPriceLabel(item: ProductItem | undefined): string {
|
||||
if (!item) return "free";
|
||||
const amount =
|
||||
(item as Record<string, unknown>).price ?? item.tiers?.[0]?.amount ?? 0;
|
||||
return `$${amount}/${item.interval ?? "one-off"}`;
|
||||
function getIntervalFilter(item: ProductItem): string | undefined {
|
||||
return (item.interval as string) ?? undefined;
|
||||
}
|
||||
|
||||
export function buildDiffSummary({
|
||||
baseProduct,
|
||||
editedProduct,
|
||||
features,
|
||||
}: {
|
||||
baseProduct: FrontendProduct;
|
||||
editedProduct: FrontendProduct;
|
||||
features: Feature[];
|
||||
}): DiffSummaryEntry[] {
|
||||
const { newItems, removedItems } = productsAreSame({
|
||||
curProductV2: baseProduct,
|
||||
newProductV2: editedProduct,
|
||||
features,
|
||||
// True when the only thing that changed is included_usage (e.g. 100 -> 200 free units).
|
||||
// These can use an update_items op instead of a remove+add.
|
||||
function isIncludedOnlyChange(oldItem: ProductItem, newItem: ProductItem): boolean {
|
||||
if (oldItem.included_usage === newItem.included_usage) return false;
|
||||
|
||||
const { same } = itemsAreSame({
|
||||
item1: { ...oldItem, included_usage: newItem.included_usage },
|
||||
item2: newItem,
|
||||
});
|
||||
|
||||
const entries: DiffSummaryEntry[] = [];
|
||||
|
||||
const removedFeatureIds = new Set(
|
||||
removedItems.filter((i) => i.feature_id).map((i) => i.feature_id),
|
||||
);
|
||||
const newFeatureIds = new Set(
|
||||
newItems.filter((i) => i.feature_id).map((i) => i.feature_id),
|
||||
);
|
||||
|
||||
for (const item of removedItems) {
|
||||
if (!item.feature_id) continue;
|
||||
if (newFeatureIds.has(item.feature_id)) {
|
||||
entries.push({ action: "changed", label: item.feature_id });
|
||||
} else {
|
||||
entries.push({ action: "removed", label: item.feature_id });
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of newItems) {
|
||||
if (!item.feature_id) continue;
|
||||
if (removedFeatureIds.has(item.feature_id)) continue;
|
||||
entries.push({ action: "added", label: item.feature_id });
|
||||
}
|
||||
|
||||
const oldBase = baseProduct.items?.find((i) => isPriceItem(i));
|
||||
const newBase = editedProduct.items?.find((i) => isPriceItem(i));
|
||||
if (JSON.stringify(oldBase) !== JSON.stringify(newBase)) {
|
||||
entries.push({
|
||||
action: "changed",
|
||||
label: `Base price: ${formatPriceLabel(oldBase)} → ${formatPriceLabel(newBase)}`,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
return same;
|
||||
}
|
||||
|
||||
function buildItemFilter(item: ProductItem): Record<string, unknown> {
|
||||
const filter: Record<string, unknown> = { feature_id: item.feature_id };
|
||||
const interval = getIntervalFilter(item);
|
||||
if (interval) filter.interval = interval;
|
||||
return filter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diffs baseProduct vs editedProduct and returns a migration draft that,
|
||||
* when executed, will bring existing customers from the old plan shape
|
||||
* to the new one.
|
||||
*
|
||||
* The draft contains a single `update_plan` operation whose `customize`
|
||||
* block may include three kinds of item changes:
|
||||
*
|
||||
* update_items — items where only included_usage changed
|
||||
* remove_items — items that were deleted or replaced
|
||||
* add_items — items that are new or replace a removed item
|
||||
*
|
||||
* It also detects base-price changes (the plan's flat recurring charge).
|
||||
*/
|
||||
export function buildMigrationDraft({
|
||||
baseProduct,
|
||||
editedProduct,
|
||||
@@ -101,6 +80,10 @@ export function buildMigrationDraft({
|
||||
editedProduct: FrontendProduct;
|
||||
features: Feature[];
|
||||
}): MigrationDraft {
|
||||
// productsAreSame gives us the raw diff: which items are new in the
|
||||
// edited product and which were removed from the base product.
|
||||
// `onlyEntsChanged` is true when no pricing fields changed (meaning
|
||||
// the migration won't trigger Stripe subscription modifications).
|
||||
const { newItems, removedItems, onlyEntsChanged } = productsAreSame({
|
||||
curProductV2: baseProduct,
|
||||
newProductV2: editedProduct,
|
||||
@@ -109,36 +92,58 @@ export function buildMigrationDraft({
|
||||
|
||||
const addItems: Record<string, unknown>[] = [];
|
||||
const removeItems: Record<string, unknown>[] = [];
|
||||
const updateItems: Record<string, unknown>[] = [];
|
||||
const baseItems = baseProduct.items ?? [];
|
||||
|
||||
const removedFeatureIds = new Set(
|
||||
removedItems.filter((i) => i.feature_id).map((i) => i.feature_id),
|
||||
);
|
||||
// Pass 1: find items that only need an included_usage update.
|
||||
// These are items that exist in both old and new, where the only
|
||||
// diff is the free-tier allowance. We emit an update_items entry
|
||||
// rather than a remove+add so existing usage state is preserved.
|
||||
const updatedItems = new Set<ProductItem>();
|
||||
for (const newItem of newItems) {
|
||||
if (!newItem.feature_id) continue;
|
||||
const oldItem = findSimilarItem({ item: newItem, items: baseItems });
|
||||
if (oldItem && isIncludedOnlyChange(oldItem, newItem)) {
|
||||
updatedItems.add(newItem);
|
||||
updateItems.push({
|
||||
filter: buildItemFilter(newItem),
|
||||
included: newItem.included_usage,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: everything else that's new. If the new item replaces an
|
||||
// existing one (same feature+interval+usage_model), emit a remove
|
||||
// for the old shape first so the add doesn't conflict.
|
||||
for (const item of newItems) {
|
||||
if (!item.feature_id) continue;
|
||||
if (removedFeatureIds.has(item.feature_id)) {
|
||||
const oldItem = removedItems.find(
|
||||
(r) => r.feature_id === item.feature_id,
|
||||
)!;
|
||||
removeItems.push({ feature_id: oldItem.feature_id });
|
||||
if (updatedItems.has(item)) continue;
|
||||
|
||||
const replacedItem = findSimilarItem({ item, items: removedItems });
|
||||
if (replacedItem) {
|
||||
removeItems.push(buildItemFilter(replacedItem));
|
||||
}
|
||||
addItems.push(productItemToAddItem(item));
|
||||
}
|
||||
|
||||
// Pass 3: purely removed items (no replacement in the new product).
|
||||
for (const item of removedItems) {
|
||||
if (!item.feature_id) continue;
|
||||
if (newItems.some((n) => n.feature_id === item.feature_id)) continue;
|
||||
removeItems.push({ feature_id: item.feature_id });
|
||||
if (findSimilarItem({ item, items: newItems })) continue;
|
||||
removeItems.push(buildItemFilter(item));
|
||||
}
|
||||
|
||||
// Base price change (the plan's flat recurring/one-off charge).
|
||||
const oldBase = baseProduct.items?.find((i) => isPriceItem(i));
|
||||
const newBase = editedProduct.items?.find((i) => isPriceItem(i));
|
||||
const basePriceChanged =
|
||||
JSON.stringify(oldBase) !== JSON.stringify(newBase);
|
||||
|
||||
// Assemble the customize block — only include sections that have entries.
|
||||
const customize: Record<string, unknown> = {};
|
||||
if (addItems.length > 0) customize.add_items = addItems;
|
||||
if (removeItems.length > 0) customize.remove_items = removeItems;
|
||||
if (updateItems.length > 0) customize.update_items = updateItems;
|
||||
if (basePriceChanged && newBase) {
|
||||
customize.price = {
|
||||
amount:
|
||||
@@ -168,8 +173,6 @@ export function buildMigrationDraft({
|
||||
return {
|
||||
id: `${baseProduct.id}-update-${timestamp}`,
|
||||
filter,
|
||||
// The operations shape is validated server-side by Zod; we build it
|
||||
// as a plain object here to avoid fighting the discriminated union types.
|
||||
operations: { customer: [updatePlanOp] } as unknown as Operations,
|
||||
no_billing_changes: onlyEntsChanged,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user