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:
Ayush Rodrigues
2026-05-27 19:22:40 +01:00
committed by Charlie Lamb
parent 819b05765f
commit 5e94c03028
18 changed files with 1163 additions and 279 deletions

View File

@@ -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.",
}),
})

View File

@@ -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) {

View File

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

View File

@@ -101,6 +101,7 @@ export function InlineEditorProvider({
initialItem={initialItem}
setSheet={setSheet}
setInitialItem={setInitialItem}
updateItemId={setItemId}
closeSheet={closeSheet}
itemDraft={itemDraft}
>

View File

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

View File

@@ -132,6 +132,7 @@ export const useMigrationsQuery = () => {
isLoading,
error,
refetch,
invalidate,
createMigration: createMutation.mutateAsync,
isCreating: createMutation.isPending,
updateMigration: updateMutation.mutateAsync,

View File

@@ -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) => ({

View File

@@ -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} />

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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>

View File

@@ -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(" · ");
}

View File

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

View File

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

View File

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

View File

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