diff --git a/shared/api/products/items/filter/planItemFilter.ts b/shared/api/products/items/filter/planItemFilter.ts index 0d0e8d1e2..81db5e0aa 100644 --- a/shared/api/products/items/filter/planItemFilter.ts +++ b/shared/api/products/items/filter/planItemFilter.ts @@ -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(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.", }), }) diff --git a/vite/src/components/forms/shared/PlanItemsSection.tsx b/vite/src/components/forms/shared/PlanItemsSection.tsx index 7271307ae..f3af33743 100644 --- a/vite/src/components/forms/shared/PlanItemsSection.tsx +++ b/vite/src/components/forms/shared/PlanItemsSection.tsx @@ -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; 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( 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) { diff --git a/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx b/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx index d3faaef9f..10864906a 100644 --- a/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx +++ b/vite/src/components/forms/shared/plan-items/PlanItemRow.tsx @@ -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; initialPrepaidOptions: Record; 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; diff --git a/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx b/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx index 3aa455f6e..4900b45c0 100644 --- a/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx +++ b/vite/src/components/v2/inline-custom-plan-editor/InlineEditorContext.tsx @@ -101,6 +101,7 @@ export function InlineEditorProvider({ initialItem={initialItem} setSheet={setSheet} setInitialItem={setInitialItem} + updateItemId={setItemId} closeSheet={closeSheet} itemDraft={itemDraft} > diff --git a/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx b/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx index 9e0a9a0aa..054644c37 100644 --- a/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx +++ b/vite/src/components/v2/inline-custom-plan-editor/PlanEditorContext.tsx @@ -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], ); } diff --git a/vite/src/hooks/queries/useMigrationsQuery.tsx b/vite/src/hooks/queries/useMigrationsQuery.tsx index cf90c1e89..568fc367c 100644 --- a/vite/src/hooks/queries/useMigrationsQuery.tsx +++ b/vite/src/hooks/queries/useMigrationsQuery.tsx @@ -132,6 +132,7 @@ export const useMigrationsQuery = () => { isLoading, error, refetch, + invalidate, createMigration: createMutation.mutateAsync, isCreating: createMutation.isPending, updateMigration: updateMutation.mutateAsync, diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index ca3f1ffbf..7fec5e65c 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -60,6 +60,7 @@ interface SheetState { data?: Record | null; }) => void; setInitialItem: (item: ProductItem | null) => void; + updateItemId: (itemId: string) => void; closeSheet: () => void; reset: () => void; } @@ -90,6 +91,9 @@ export const useSheetStore = create((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) => ({ diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index bbb5d1cf2..6daa61738 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -367,11 +367,6 @@ export function MigrationLiveView({ )} - {count !== null && ( - - {count} {count === 1 ? "customer" : "customers"} - - )} {onPrevious && ( - - - )} - + ); } + +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 ( +
+
+ {sheetType === "select-feature" && } + {sheetType === "edit-plan-price" && } + {sheetType === "edit-feature" && currentItem && ( + {}, + isUpdate, + handleUpdateProductItem: handleFeatureCommit, + }} + > + + + )} +
+ {sheetType === "edit-plan-price" && ( +
+ + +
+ )} +
+ ); +} diff --git a/vite/src/views/migrations/migration/operations/OperationsForm.tsx b/vite/src/views/migrations/migration/operations/OperationsForm.tsx index 3c4dc12fe..d18d39463 100644 --- a/vite/src/views/migrations/migration/operations/OperationsForm.tsx +++ b/vite/src/views/migrations/migration/operations/OperationsForm.tsx @@ -190,7 +190,7 @@ export function OperationsForm({ - Add Operation + Update or add a different plan 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 ( -
- Remove - onChange({ ...item, feature_id: v })} - placeholder="Select feature to remove..." - triggerClassName={cn( - featureId && "!border-destructive/50 hover:!border-destructive/60", - )} + <> +
+ + Remove + + + +
+ + { + onChange(updated); + setSheetOpen(false); + }} /> - + + ); +} + +function RemoveItemSheet({ + open, + onOpenChange, + item, + onSave, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + item: ItemFilter; + onSave: (item: ItemFilter) => void; +}) { + const [key, setKey] = useState(0); + + return ( + { + if (isOpen) setKey((k) => k + 1); + onOpenChange(isOpen); + }} + > + + {open && ( + onOpenChange(false)} + /> + )} + + + ); +} + +function RemoveItemSheetContent({ + item, + onSave, + onCancel, +}: { + item: ItemFilter; + onSave: (item: ItemFilter) => void; + onCancel: () => void; +}) { + const { features } = useFeaturesQuery(); + const [draft, setDraft] = useState(() => + structuredClone(item), + ); + + const canSave = !!draft.feature_id; + + return ( +
+
+
+

+ Remove Item +

+

+ Select a feature to remove from the plan. Use interval + and billing method to narrow the match. +

+
+ +
+
+ + + setDraft({ ...draft, feature_id: v }) + } + placeholder="Select feature..." + /> +
+ +
+ + + + + + + {draft.interval && ( + + setDraft({ ...draft, interval: undefined }) + } + className="py-1.5 px-2 text-muted-foreground" + > + Any interval + + )} + {INTERVAL_OPTIONS.map((o) => ( + + setDraft({ ...draft, interval: o.value }) + } + className="py-1.5 px-2" + > + {o.label} + + ))} + + +

+ Narrow the match when the same feature appears at + multiple intervals. +

+
+ +
+ + + setDraft({ ...draft, billing_method: v }) + } + /> +
+
+
+ +
+ + +
); } diff --git a/vite/src/views/migrations/migration/operations/UpdateItemRows.tsx b/vite/src/views/migrations/migration/operations/UpdateItemRows.tsx new file mode 100644 index 000000000..17f6b3bd5 --- /dev/null +++ b/vite/src/views/migrations/migration/operations/UpdateItemRows.tsx @@ -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 ( + <> +
+ + Update + + + +
+ + { + 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 ( + { + if (isOpen) setKey((k) => k + 1); + onOpenChange(isOpen); + }} + > + + {open && ( + onOpenChange(false)} + /> + )} + + + ); +} + +function UpdateItemSheetContent({ + item, + onSave, + onCancel, +}: { + item: UpdatePlanItemParamsV1; + onSave: (item: UpdatePlanItemParamsV1) => void; + onCancel: () => void; +}) { + const { features } = useFeaturesQuery(); + const [draft, setDraft] = useState( + () => structuredClone(item), + ); + + const featureId = draft.filter?.feature_id ?? null; + const canSave = !!featureId; + + return ( +
+
+
+

+ Update Item +

+

+ Override properties on an existing plan item. Use the + filter fields to target the specific item. +

+
+ +
+
+ + + setDraft({ + ...draft, + filter: { ...draft.filter, feature_id: v }, + }) + } + placeholder="Select feature..." + /> +
+ +
+ + +

+ Narrow the match when the same feature appears at + multiple intervals. +

+
+ +
+ + + setDraft({ + ...draft, + filter: { + ...draft.filter, + billing_method: + v as UpdatePlanItemParamsV1["filter"]["billing_method"], + }, + }) + } + /> +
+ +
+
+ + { + const val = e.target.value; + setDraft({ + ...draft, + included: + val === "" + ? undefined + : Number(val), + }); + }} + placeholder="New included amount" + className="h-8 rounded-xl" + /> +

+ The new allowance for matched items. Existing + usage carries forward. +

+
+
+
+
+ +
+ + +
+
+ ); +} diff --git a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx index a0cc6d2d0..f1f097e1d 100644 --- a/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx +++ b/vite/src/views/migrations/migration/operations/UpdatePlanOpForm.tsx @@ -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) => (
- Add + Add openSheet("edit-feature", index)} @@ -302,19 +304,42 @@ export function UpdatePlanOpForm({ /> ))} + {(customize?.update_items ?? []).map((item, index) => ( + { + 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, + }, + }); + }} + /> + ))} + - Add modification + Add a modification to this plan {value.version === undefined && ( update({ version: 1 })} - > - Version - + > + Set Plan Version + )} {(!customize || customize.price === undefined) && ( Remove Item + + update({ + customize: { + ...customize, + update_items: [ + ...(customize?.update_items ?? []), + { filter: {} } as unknown as UpdatePlanItemParamsV1, + ], + }, + }) + } + > + Update Item + diff --git a/vite/src/views/migrations/migration/operations/operationItemUtils.tsx b/vite/src/views/migrations/migration/operations/operationItemUtils.tsx new file mode 100644 index 000000000..f23627f0a --- /dev/null +++ b/vite/src/views/migrations/migration/operations/operationItemUtils.tsx @@ -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 = { + [BillingInterval.SemiAnnual]: "Semi-annual", + [BillingInterval.OneOff]: "One-off", +}; + +const billingSet = new Set(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: , + color: "text-green-500", + }, + { + value: "usage_based", + label: "Usage-based", + icon: , + color: "text-yellow-500", + }, + { + value: "prepaid", + label: "Prepaid", + icon: , + 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 ( + + + + + + {selected && ( + onChange(undefined)} + className="py-1.5 px-2 text-muted-foreground" + > + Any method + + )} + {BILLING_METHOD_OPTIONS.map((o) => ( + + onChange( + o.value === "included" ? undefined : o.value, + ) + } + className="py-1.5 px-2" + > + {o.icon} + {o.label} + + ))} + + + ); +} + +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(" · "); +} diff --git a/vite/src/views/migrations/migration/shared/operationUtils.ts b/vite/src/views/migrations/migration/shared/operationUtils.ts index 60e943887..794d678c5 100644 --- a/vite/src/views/migrations/migration/shared/operationUtils.ts +++ b/vite/src/views/migrations/migration/shared/operationUtils.ts @@ -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; } diff --git a/vite/src/views/products/plan/ProductSheets.tsx b/vite/src/views/products/plan/ProductSheets.tsx index 52ad52d74..beff11403 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -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 }); diff --git a/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx b/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx index fcb0a062a..b7bfbcd5c 100644 --- a/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx +++ b/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx @@ -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 ( -
- - - {entry.action} - - {entry.label} -
- ); + 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(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 ( - - Save plan changes - -
-

- This plan has existing customers. Choose how to - apply your changes. -

+ {step === "confirm" ? ( + <> + + Save plan changes + +
+

+ This plan has existing customers. Choose how to + apply your changes. +

- {diffSummary.length > 0 && ( -
- - Changes - - {diffSummary.map((entry, i) => ( - - ))} + {hasChanges && ( + {}} + priceChange={priceChange} + readOnly + /> + )} + +
+

+ Type{" "} + {product.id}{" "} + to continue. +

+ + + setConfirmText(e.target.value) + } + type="text" + placeholder={product.id} + className="w-full" + /> +
- )} + + -

- Type{" "} - {product.id}{" "} - to continue. -

- - - setConfirmText(e.target.value) - } - type="text" - placeholder={product.id} - className="w-full" + + -
-
-
+ + + + ) : ( + <> + +
+ + Plan updated +
+ + Create a migration to move existing customers to + the new plan configuration. + +
- - - -

- New version only applies to new customers -

-
+ + + + + )}
); } + +function ActionCard({ + title, + description, + onClick, + isLoading, + disabled, +}: { + title: string; + description: string; + onClick: () => void; + isLoading: boolean; + disabled: boolean; +}) { + return ( + + ); +} diff --git a/vite/src/views/products/plan/versioning/buildMigrationDraft.ts b/vite/src/views/products/plan/versioning/buildMigrationDraft.ts index bd64b2a45..3f808ea66 100644 --- a/vite/src/views/products/plan/versioning/buildMigrationDraft.ts +++ b/vite/src/views/products/plan/versioning/buildMigrationDraft.ts @@ -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 { const result: Record = { feature_id: item.feature_id }; @@ -34,64 +34,43 @@ function productItemToAddItem(item: ProductItem): Record { return result; } -function formatPriceLabel(item: ProductItem | undefined): string { - if (!item) return "free"; - const amount = - (item as Record).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 { + const filter: Record = { 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[] = []; const removeItems: Record[] = []; + const updateItems: Record[] = []; + 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(); + 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 = {}; 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, };