From d77fd2b2bd472e0c053ebffc5069e12fb70f7533 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Wed, 3 Jun 2026 16:39:31 +0100 Subject: [PATCH] good luck --- .../migrationItemEventsDataSource.ts | 4 + .../handlers/handleListMigrationItemEvents.ts | 4 +- .../listMigrationItemEvents.ts | 80 ++++ .../products/items/filter/planItemFilter.ts | 23 +- shared/utils/index.ts | 4 + shared/utils/planV1Utils/diff/applyDiff.ts | 95 +++++ shared/utils/planV1Utils/diff/diffPlanV1.ts | 142 +++++++ .../productItemUtils/matchPlanItem.ts | 6 + vite/src/components/v2/dialogs/Dialog.tsx | 2 +- .../hooks/queries/useMigrationRunsQuery.ts | 10 +- vite/src/services/products/ProductService.tsx | 3 +- .../migration/live/CustomerRunSheet.tsx | 19 +- .../migration/live/EventResultDetail.tsx | 308 ++++++++------- .../migration/live/MigrationCustomerSheet.tsx | 5 +- .../migration/live/MigrationLiveView.tsx | 236 +++++------- .../migration/shared/migrationItemUtils.ts | 31 +- .../edit-plan-feature/PriceTiers.tsx | 2 +- .../plan/versioning/PlanChangeDialog.tsx | 358 ++++++++++-------- .../plan/versioning/buildMigrationDraft.ts | 180 +++++---- .../hooks/queries/useMigrationsQuery.tsx.tsx | 21 - .../product/hooks/useProductQuery.tsx | 7 +- .../products/product/utils/updateProduct.ts | 7 +- 22 files changed, 944 insertions(+), 603 deletions(-) create mode 100644 shared/utils/planV1Utils/diff/applyDiff.ts create mode 100644 shared/utils/planV1Utils/diff/diffPlanV1.ts delete mode 100644 vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx diff --git a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts index 4bd7f9c3c..f92235955 100644 --- a/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts +++ b/server/src/external/tinybird/migrations/migrationItemEventsDataSource.ts @@ -74,6 +74,7 @@ export const listMigrationItemEventsEndpoint = defineEndpoint( env: p.string(), migration_internal_id: p.string(), migration_run_id: p.string().optional(""), + item_ids: p.array(p.string()).optional(), limit: p.int32().optional(1000), }, nodes: [ @@ -99,6 +100,9 @@ export const listMigrationItemEventsEndpoint = defineEndpoint( {% if defined(migration_run_id) and String(migration_run_id, '') != '' %} AND migration_run_id = {{String(migration_run_id)}} {% end %} + {% if defined(item_ids) and length(item_ids) > 0 %} + AND item_id IN {{Array(item_ids, 'String')}} + {% end %} ORDER BY timestamp DESC, item_kind ASC, item_id ASC LIMIT {{Int32(limit, 1000)}} `, diff --git a/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts b/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts index 8cd8690ee..77ef543cc 100644 --- a/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts +++ b/server/src/internal/migrations/v2/handlers/handleListMigrationItemEvents.ts @@ -6,6 +6,7 @@ import { migrationItemEventRepo } from "../repos/index.js"; const ListMigrationItemEventsBody = z.object({ migrationId: z.string(), migrationRunId: z.string().optional(), + itemIds: z.array(z.string()).optional(), }); export const handleListMigrationItemEvents = createRoute({ @@ -13,11 +14,12 @@ export const handleListMigrationItemEvents = createRoute({ body: ListMigrationItemEventsBody, handler: async (c) => { const ctx = c.get("ctx"); - const { migrationId, migrationRunId } = c.req.valid("json"); + const { migrationId, migrationRunId, itemIds } = c.req.valid("json"); const events = await migrationItemEventRepo.list({ ctx, migrationId, migrationRunId, + itemIds, }); return c.json({ list: events }); diff --git a/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts b/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts index a4f9add0c..8ea3aed66 100644 --- a/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts +++ b/server/src/internal/migrations/v2/repos/migrationItemEvents/listMigrationItemEvents.ts @@ -38,10 +38,12 @@ export const listMigrationItemEvents = async ({ ctx, migrationId, migrationRunId, + itemIds, }: { ctx: RepoContext; migrationId: string; migrationRunId?: string; + itemIds?: string[]; }): Promise => { if (!migrationTinybird) { ctx.logger.debug( @@ -51,6 +53,18 @@ export const listMigrationItemEvents = async ({ } const migration = await findMigration({ ctx, id: migrationId }); + + if (itemIds && itemIds.length > 0) { + return listMigrationItemEventsBySql({ + ctx, + orgId: ctx.org.id, + env: ctx.env, + migrationInternalId: migration.internal_id, + migrationRunId, + itemIds, + }); + } + const queryParams = { org_id: ctx.org.id, env: ctx.env, @@ -70,3 +84,69 @@ export const listMigrationItemEvents = async ({ normalizeMigrationItemEventJson, ); }; + +const escapeString = (s: string) => s.replace(/'/g, "\\'"); + +const listMigrationItemEventsBySql = async ({ + ctx, + orgId, + env, + migrationInternalId, + migrationRunId, + itemIds, +}: { + ctx: RepoContext; + orgId: string; + env: string; + migrationInternalId: string; + migrationRunId?: string; + itemIds: string[]; +}): Promise => { + const conditions = [ + `org_id = '${escapeString(orgId)}'`, + `env = '${escapeString(env)}'`, + `migration_internal_id = '${escapeString(migrationInternalId)}'`, + ]; + + if (migrationRunId) { + conditions.push( + `migration_run_id = '${escapeString(migrationRunId)}'`, + ); + } + + const idList = itemIds.map((id) => `'${escapeString(id)}'`).join(","); + conditions.push(`item_id IN (${idList})`); + + const sql = ` + SELECT + timestamp, + org_id, + env, + migration_internal_id, + migration_run_id, + dry_run, + item_kind, + item_id, + item_preview, + status, + response + FROM migration_item_events + WHERE ${conditions.join(" AND ")} + ORDER BY timestamp DESC, item_kind ASC, item_id ASC + LIMIT 1000 + FORMAT JSON + `; + + ctx.logger.info( + `listMigrationItemEventsBySql: querying ${itemIds.length} item_ids for migration=${migrationInternalId}`, + ); + + const result = await migrationTinybird!.sql(sql); + const rows = result.data ?? []; + + ctx.logger.info( + `listMigrationItemEventsBySql: got ${rows.length} results`, + ); + + return rows.map(normalizeMigrationItemEventJson); +}; diff --git a/shared/api/products/items/filter/planItemFilter.ts b/shared/api/products/items/filter/planItemFilter.ts index 81db5e0aa..755140ba9 100644 --- a/shared/api/products/items/filter/planItemFilter.ts +++ b/shared/api/products/items/filter/planItemFilter.ts @@ -1,14 +1,8 @@ import { BillingMethod } from "@api/products/components/billingMethod"; import { BillingInterval } from "@models/productModels/intervals/billingInterval"; -import { EntInterval } from "@models/productModels/intervals/entitlementInterval"; +import { ResetInterval } from "@models/productModels/intervals/resetInterval"; 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({ @@ -18,15 +12,24 @@ export const PlanItemFilterSchema = z description: "Match items with this billing method (prepaid or usage_based).", }), - interval: z.enum(AllIntervals).optional().meta({ - description: "Match items with this interval.", + interval: z + .union([z.enum(BillingInterval), z.enum(ResetInterval)]) + .optional() + .meta({ + description: + "Match items with this interval. Accepts either a BillingInterval (price-side) or a ResetInterval (reset-side, includes day/hour/minute) so price-less items keyed by reset.interval can be disambiguated.", + }), + interval_count: z.number().int().positive().optional().meta({ + description: + "Match items with this interval_count. Disambiguates between items that share an interval but differ in count.", }), }) .refine( (filter) => filter.feature_id !== undefined || filter.billing_method !== undefined || - filter.interval !== undefined, + filter.interval !== undefined || + filter.interval_count !== undefined, { message: "PlanItemFilter must have at least one field set." }, ) .meta({ diff --git a/shared/utils/index.ts b/shared/utils/index.ts index 23d5d0423..7a0482cd4 100644 --- a/shared/utils/index.ts +++ b/shared/utils/index.ts @@ -65,6 +65,10 @@ export * from "./productV2Utils/productV2ToFrontendProduct"; export * from "./productV2Utils/productV2ToV1"; export * from "./productV3Utils/productItemUtils/productV3ItemUtils"; +// Plan V1 diff/apply utils +export * from "./planV1Utils/diff/diffPlanV1"; +export * from "./planV1Utils/diff/applyDiff"; + // Stripe resource utils export * from "./stripeUtils/classifyStripeResource/isPreviewStripeId"; diff --git a/shared/utils/planV1Utils/diff/applyDiff.ts b/shared/utils/planV1Utils/diff/applyDiff.ts new file mode 100644 index 000000000..2338983ed --- /dev/null +++ b/shared/utils/planV1Utils/diff/applyDiff.ts @@ -0,0 +1,95 @@ +import type { + ApiPlanV1, + CreatePlanItemParamsV1, + PlanItemFilter, +} from "@autumn/shared"; +import type { DiffedCustomizePlanV1 } from "./diffPlanV1.js"; + +export type ApplyDiffOutput = { + price: ApiPlanV1["price"]; + items: ApiPlanV1["items"]; + free_trial: ApiPlanV1["free_trial"]; +}; + +type ApiPlanItem = ApiPlanV1["items"][number]; + +const applyPrice = ( + base: ApiPlanV1["price"], + diff: DiffedCustomizePlanV1["price"], +): ApiPlanV1["price"] => { + if (diff === undefined) return base; + if (diff === null) return null; + return { ...diff }; +}; + +const itemMatchesFilter = ( + item: ApiPlanItem, + filter: PlanItemFilter, +): boolean => { + if (filter.feature_id !== undefined && item.feature_id !== filter.feature_id) + return false; + if (filter.billing_method !== undefined) { + if (item.price?.billing_method !== filter.billing_method) + return false; + } else if (item.price?.billing_method !== undefined) { + return false; + } + if (filter.interval !== undefined) { + const itemInterval = item.price?.interval ?? item.reset?.interval; + if (String(itemInterval) !== String(filter.interval)) return false; + } + if (filter.interval_count !== undefined) { + const itemCount = + item.price?.interval_count ?? item.reset?.interval_count; + if ((itemCount ?? 1) !== filter.interval_count) return false; + } + return true; +}; + +const removeItems = ( + items: ApiPlanV1["items"], + removeFilters: PlanItemFilter[], +): ApiPlanV1["items"] => { + return items.filter( + (item) => !removeFilters.some((filter) => itemMatchesFilter(item, filter)), + ); +}; + +const toApiPlanItem = (params: CreatePlanItemParamsV1): ApiPlanItem => { + return { ...params } as ApiPlanItem; +}; + +const applyItems = ( + baseItems: ApiPlanV1["items"], + diff: DiffedCustomizePlanV1, +): ApiPlanV1["items"] => { + let items = [...baseItems]; + if (diff.remove_items) { + items = removeItems(items, diff.remove_items); + } + if (diff.add_items) { + items = [...items, ...diff.add_items.map(toApiPlanItem)]; + } + return items; +}; + +const applyFreeTrial = ( + base: ApiPlanV1["free_trial"], + diff: DiffedCustomizePlanV1["free_trial"], +): ApiPlanV1["free_trial"] => { + if (diff === undefined) return base; + if (diff === null) return undefined; + return { ...diff } as ApiPlanV1["free_trial"]; +}; + +export const applyDiff = ({ + base, + diff, +}: { + base: ApiPlanV1; + diff: DiffedCustomizePlanV1; +}): ApplyDiffOutput => ({ + price: applyPrice(base.price, diff.price), + items: applyItems(base.items, diff), + free_trial: applyFreeTrial(base.free_trial, diff.free_trial), +}); diff --git a/shared/utils/planV1Utils/diff/diffPlanV1.ts b/shared/utils/planV1Utils/diff/diffPlanV1.ts new file mode 100644 index 000000000..e7661c867 --- /dev/null +++ b/shared/utils/planV1Utils/diff/diffPlanV1.ts @@ -0,0 +1,142 @@ +import type { BasePriceParams } from "@api/products/components/basePrice/basePrice.js"; +import { + type ApiPlanV1, + type CreatePlanItemParamsV1, + CustomizePlanV1Schema, + type PlanItemFilter, +} from "@autumn/shared"; +import type { z } from "zod/v4"; + +export const DiffedCustomizePlanV1Schema = CustomizePlanV1Schema.omit({ + items: true, +}); + +export type DiffedCustomizePlanV1 = z.infer; + +type ApiPlanItem = ApiPlanV1["items"][number]; + +const toBasePriceParams = ( + price: NonNullable, +): BasePriceParams => ({ + amount: price.amount, + interval: price.interval, + ...(price.interval_count !== undefined + ? { interval_count: price.interval_count } + : {}), +}); + +const toCreatePlanItemParams = (item: ApiPlanItem): CreatePlanItemParamsV1 => { + const out: CreatePlanItemParamsV1 = { feature_id: item.feature_id }; + if (item.included !== undefined && item.included !== null) + out.included = item.included; + if (item.unlimited !== undefined && item.unlimited !== null) + out.unlimited = item.unlimited; + if (item.reset) out.reset = item.reset; + if (item.price) out.price = item.price as CreatePlanItemParamsV1["price"]; + if (item.rollover) { + out.rollover = { + expiry_duration_type: item.rollover.expiry_duration_type, + ...(item.rollover.max != null ? { max: item.rollover.max } : {}), + ...(item.rollover.max_percentage != null + ? { max_percentage: item.rollover.max_percentage } + : {}), + ...(item.rollover.expiry_duration_length !== undefined + ? { expiry_duration_length: item.rollover.expiry_duration_length } + : {}), + }; + } + return out; +}; + +const composeMatchKey = (item: ApiPlanItem): string => { + const billingMethod = item.price?.billing_method ?? ""; + const interval = item.price?.interval ?? item.reset?.interval ?? ""; + const intervalCount = + item.price?.interval_count ?? item.reset?.interval_count ?? ""; + return `${item.feature_id}|${billingMethod}|${interval}|${intervalCount}`; +}; + +const buildRemoveFilter = (item: ApiPlanItem): PlanItemFilter => { + const filter: PlanItemFilter = { feature_id: item.feature_id }; + if (item.price?.billing_method !== undefined) + filter.billing_method = item.price.billing_method; + const interval = item.price?.interval ?? item.reset?.interval; + if (interval !== undefined) + filter.interval = interval as PlanItemFilter["interval"]; + const intervalCount = + item.price?.interval_count ?? item.reset?.interval_count; + if (intervalCount !== undefined) filter.interval_count = intervalCount; + return filter; +}; + +const pricesEqual = (a: ApiPlanV1["price"], b: ApiPlanV1["price"]): boolean => { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + return ( + a.amount === b.amount && + a.interval === b.interval && + (a.interval_count ?? 1) === (b.interval_count ?? 1) + ); +}; + +const freeTrialsEqual = ( + a: ApiPlanV1["free_trial"], + b: ApiPlanV1["free_trial"], +): boolean => { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return JSON.stringify(a) === JSON.stringify(b); +}; + +// Equality ignores `display` (UI-derived) and `feature` (join, not user input). +const itemsEqual = (a: ApiPlanItem, b: ApiPlanItem): boolean => { + const strip = ({ display: _d, feature: _f, ...rest }: ApiPlanItem) => rest; + return JSON.stringify(strip(a)) === JSON.stringify(strip(b)); +}; + +// Modify-in-place is expressed as remove + add ("out with the old, in with the new"). +export const diffPlanV1 = ({ + from, + to, +}: { + from: ApiPlanV1; + to: ApiPlanV1; +}): DiffedCustomizePlanV1 => { + const diff: DiffedCustomizePlanV1 = {}; + + if (!pricesEqual(from.price, to.price)) { + diff.price = to.price === null ? null : toBasePriceParams(to.price); + } + + const fromByKey = new Map(from.items.map((i) => [composeMatchKey(i), i])); + const toByKey = new Map(to.items.map((i) => [composeMatchKey(i), i])); + + const addItems: CreatePlanItemParamsV1[] = []; + for (const toItem of to.items) { + const fromItem = fromByKey.get(composeMatchKey(toItem)); + if (!fromItem || !itemsEqual(fromItem, toItem)) { + addItems.push(toCreatePlanItemParams(toItem)); + } + } + if (addItems.length > 0) diff.add_items = addItems; + + const removeItems: PlanItemFilter[] = []; + for (const fromItem of from.items) { + const toItem = toByKey.get(composeMatchKey(fromItem)); + if (!toItem || !itemsEqual(fromItem, toItem)) { + removeItems.push(buildRemoveFilter(fromItem)); + } + } + if (removeItems.length > 0) diff.remove_items = removeItems; + + if (!freeTrialsEqual(from.free_trial, to.free_trial)) { + if (to.free_trial == null) { + diff.free_trial = null; + } else { + const { on_end, ...rest } = to.free_trial; + diff.free_trial = on_end == null ? rest : { ...rest, on_end }; + } + } + + return diff; +}; diff --git a/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts b/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts index d6db73a01..b4bdbe606 100644 --- a/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts +++ b/shared/utils/productV2Utils/productItemUtils/matchPlanItem.ts @@ -34,5 +34,11 @@ export const matchesPlanItemFilter = ({ ) return false; + if ( + filter.interval_count !== undefined && + (item.interval_count ?? 1) !== filter.interval_count + ) + return false; + return true; }; diff --git a/vite/src/components/v2/dialogs/Dialog.tsx b/vite/src/components/v2/dialogs/Dialog.tsx index 3b0d7c9b1..5ebd9bc31 100644 --- a/vite/src/components/v2/dialogs/Dialog.tsx +++ b/vite/src/components/v2/dialogs/Dialog.tsx @@ -88,7 +88,7 @@ const DialogContent = React.forwardRef< ref={ref} data-slot="dialog-content" className={cn( - "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[40%] left-[50%] z-[180] grid translate-x-[-50%] translate-y-[-50%] rounded-lg shadow-lg ring-1 ring-foreground/10 duration-200", + "data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 fixed top-[50%] left-[50%] z-[180] grid translate-x-[-50%] translate-y-[-50%] rounded-lg shadow-lg ring-1 ring-foreground/10 duration-200", "w-full max-w-md gap-3 bg-background", "p-4", className, diff --git a/vite/src/hooks/queries/useMigrationRunsQuery.ts b/vite/src/hooks/queries/useMigrationRunsQuery.ts index e1152af5a..e9b7d264b 100644 --- a/vite/src/hooks/queries/useMigrationRunsQuery.ts +++ b/vite/src/hooks/queries/useMigrationRunsQuery.ts @@ -55,20 +55,24 @@ function findActiveRun( export const useMigrationRunsQuery = ({ migrationId, migrationRunId, + itemIds, enabled = true, }: { migrationId: string; migrationRunId?: string; + itemIds?: string[]; enabled?: boolean; }) => { const axiosInstance = useAxiosInstance(); const queryClient = useQueryClient(); const buildKey = useQueryKeyFactory(); const runsQueryKey = buildKey(["migration-runs", migrationId]); + const stableItemIds = itemIds ? [...itemIds].sort().join(",") : "all"; const eventsQueryKey = buildKey([ "migration-item-events", migrationId, migrationRunId ?? "all", + stableItemIds, ]); const runsQuery = useQuery<{ list: MigrationRunWithItemCounts[] }>({ @@ -93,7 +97,11 @@ export const useMigrationRunsQuery = ({ queryFn: async () => { const { data } = await axiosInstance.post<{ list: MigrationItemEvent[]; - }>("/migrations.item_events.list", { migrationId, migrationRunId }); + }>("/migrations.item_events.list", { + migrationId, + migrationRunId, + itemIds, + }); return data; }, enabled, diff --git a/vite/src/services/products/ProductService.tsx b/vite/src/services/products/ProductService.tsx index fc22d28e6..c65381a6e 100644 --- a/vite/src/services/products/ProductService.tsx +++ b/vite/src/services/products/ProductService.tsx @@ -11,12 +11,11 @@ export class ProductService { axiosInstance: AxiosInstance, productId: string, data: any, - options?: { version?: number; disableVersion?: boolean }, + options?: { version?: number }, ) { const params = new URLSearchParams(); if (notNullish(options?.version)) params.set("version", String(options.version)); - if (options?.disableVersion) params.set("disable_version", "true"); const qs = params.toString(); const url = qs ? `/v1/products/${productId}?${qs}` diff --git a/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx b/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx index 10066301b..4ca39fe46 100644 --- a/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx +++ b/vite/src/views/migrations/migration/live/CustomerRunSheet.tsx @@ -9,7 +9,7 @@ import { } from "@phosphor-icons/react"; import { format } from "date-fns"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Link } from "react-router"; +import { useNavigate } from "react-router"; import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; import { @@ -24,6 +24,7 @@ import { InfoRow } from "@/components/v2/InfoRow"; import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; import type { MigrationPreviewCustomer } from "@/hooks/queries/useMigrationFilterPreview"; import type { MigrationItemEvent } from "@/hooks/queries/useMigrationRunsQuery"; +import { navigateTo } from "@/utils/genUtils"; import { ActiveRunDot, ItemEventStatusBadge } from "../runs/RunStatusBadge"; import { RunSummaryRows } from "../shared/RunSummaryRows"; import { EventResultDetail } from "./EventResultDetail"; @@ -116,6 +117,7 @@ export function CustomerRunSheet({ operations: Operations; noBillingChanges: boolean; }) { + const navigate = useNavigate(); const customerId = customer.id ?? customer.internal_id; const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const lastActionRef = useRef<"dry" | "live" | null>(null); @@ -158,17 +160,14 @@ export function CustomerRunSheet({ - navigateTo(`/customers/${customerId}`, navigate)} + className="inline-flex items-center gap-1.5 hover:text-primary transition-colors cursor-pointer" > {customer.name || customerId} - - + + {isActive && } } diff --git a/vite/src/views/migrations/migration/live/EventResultDetail.tsx b/vite/src/views/migrations/migration/live/EventResultDetail.tsx index e3b6c0881..56feeb556 100644 --- a/vite/src/views/migrations/migration/live/EventResultDetail.tsx +++ b/vite/src/views/migrations/migration/live/EventResultDetail.tsx @@ -4,10 +4,17 @@ import type { CustomerPlanItemChange, } from "@autumn/shared/api/billing/common/customerPlanChange"; import { PackageIcon } from "@phosphor-icons/react"; +import { SubscriptionItemRow } from "@/components/forms/update-subscription-v2/components/SubscriptionItemRow"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import type { MigrationItemEvent } from "@/hooks/queries/useMigrationRunsQuery"; import { cn } from "@/lib/utils"; import { getFeatureIconConfig } from "@/views/products/features/utils/getFeatureIcon"; +import { migrationItemToProductItem } from "../shared/migrationItemUtils"; type ItemChange = Partial; type PlanChange = Partial & { @@ -108,35 +115,10 @@ function StatusDot({ action }: { action: string }) { "size-2 rounded-full shrink-0", DOT_COLORS[action] ?? "bg-tertiary-foreground", )} - title={ACTION_LABELS[action] ?? action} /> ); } -function FeatureIcon({ - featureId, - features, -}: { - featureId: string | undefined; - features: Feature[]; -}) { - const feature = features.find((f) => f.id === featureId); - const config = feature - ? getFeatureIconConfig(feature.type, feature.config?.usage_type, 14) - : getFeatureIconConfig(null, null, 14); - - return {config.icon}; -} - -const ROW_TINTS: Record = { - activated: "border-green-500/20 bg-green-500/5", - scheduled: "border-blue-500/20 bg-blue-500/5", - created: "border-green-500/20 bg-green-500/5", - updated: "border-amber-500/20 bg-amber-500/5", - expired: "border-red-500/20 bg-red-500/5", - removed: "border-red-500/20 bg-red-500/5", - deleted: "border-red-500/20 bg-red-500/5", -}; function getPlanId(change: PlanChange): string | undefined { return change.subscription?.plan_id ?? change.purchase?.plan_id ?? change.plan_id; @@ -146,20 +128,6 @@ function getPlanStatus(change: PlanChange): string | undefined { return change.subscription?.status ?? change.purchase?.status; } -const BALANCE_FIELDS = [ - "granted", - "remaining", - "usage", - "unlimited", - "next_reset_at", -] as const; - -function formatBalanceValue(value: BalanceSnapshot[keyof BalanceSnapshot]) { - if (value === null) return "None"; - if (typeof value === "boolean") return value ? "Yes" : "No"; - if (typeof value === "number") return value.toLocaleString(); - return "Unknown"; -} function ChangeRow({ action, @@ -173,8 +141,7 @@ function ChangeRow({ return (
@@ -183,17 +150,118 @@ function ChangeRow({ ); } -function PlanChangeRows({ - change, - features, +function buildItemTooltipLines( + apiItem: Record, + feature: Feature | undefined, +): string[] { + const lines: string[] = []; + if (feature?.name) lines.push(feature.name); + if (apiItem.unlimited === true) lines.push("Unlimited"); + else if (typeof apiItem.included === "number") + lines.push(`Included: ${(apiItem.included as number).toLocaleString()}`); + + const reset = apiItem.reset as { interval?: string } | undefined; + if (reset?.interval) lines.push(`Resets: ${reset.interval}`); + + const price = apiItem.price as { + amount?: number; + interval?: string; + billing_method?: string; + } | null; + if (price) { + const parts: string[] = []; + if (price.billing_method) parts.push(price.billing_method.replaceAll("_", " ")); + if (typeof price.amount === "number") parts.push(`$${price.amount}`); + if (price.interval) parts.push(`per ${price.interval}`); + if (parts.length > 0) lines.push(parts.join(" · ")); + } + return lines; +} + +function ItemChangeRow({ + item, }: { - change: PlanChange; - features: Feature[]; + item: ItemChange; }) { + const { features } = useFeaturesQuery(); + const action = item.action ?? "unknown"; + + const apiItem = item.item as Record | undefined; + const productItem = apiItem + ? migrationItemToProductItem(apiItem, features) + : null; + + const feature = features.find((f) => f.id === item.feature_id); + const isDeleted = action === "deleted"; + const isCreated = action === "created"; + + const tooltipLines = apiItem + ? buildItemTooltipLines(apiItem, feature) + : []; + + const row = productItem ? ( +
+ +
+ ) : ( + + + + + {feature?.name ?? item.feature_id} + + + ); + + if (tooltipLines.length === 0) return row; + + return ( + + {row} + + {tooltipLines.map((line) => ( +
{line}
+ ))} +
+
+ ); +} + +function FeatureIconByFeatureId({ featureId }: { featureId: string | undefined }) { + const { features } = useFeaturesQuery(); + const feature = features.find((f) => f.id === featureId); + const config = feature + ? getFeatureIconConfig(feature.type, feature.config?.usage_type, 14) + : getFeatureIconConfig(null, null, 14); + return {config.icon}; +} + + +function balanceToItemChange(bc: BalanceChange, action = "updated"): ItemChange { + const balance = bc.balance ?? {}; + const item: Record = { feature_id: bc.feature_id }; + if (balance.unlimited) item.unlimited = true; + else if (balance.granted !== undefined) item.included = balance.granted; + else if (bc.granted !== undefined) item.included = bc.granted; + return { action, feature_id: bc.feature_id, item }; +} + +function flagToItemChange(fc: FlagChange, action?: string): ItemChange { + return { action: action ?? fc.action ?? "updated", feature_id: fc.feature_id, item: { feature_id: fc.feature_id } }; +} + +function PlanChangeRows({ change, absorbedBalances, absorbedFlags }: { change: PlanChange; absorbedBalances?: BalanceChange[]; absorbedFlags?: FlagChange[] }) { const action = change.action ?? "unknown"; const items = change.item_changes ?? []; const planId = getPlanId(change); const status = getPlanStatus(change); + const hasAbsorbed = (absorbedBalances?.length ?? 0) > 0 || (absorbedFlags?.length ?? 0) > 0; return ( <> @@ -211,23 +279,19 @@ function PlanChangeRows({ )} {items.map((item, i) => ( - - - - {ACTION_LABELS[item.action ?? "unknown"] ?? item.action} - - - - {features.find((f) => f.id === item.feature_id)?.name ?? - item.feature_id} - - + ))} - {items.length === 0 && action === "updated" && ( + {items.length === 0 && hasAbsorbed && ( + <> + {absorbedFlags?.map((fc, i) => ( + + ))} + {absorbedBalances?.map((bc) => ( + + ))} + + )} + {items.length === 0 && !hasAbsorbed && action === "updated" && (
Price, version, or settings changed @@ -238,96 +302,58 @@ function PlanChangeRows({ ); } -function BalanceChangeRow({ - change, - features, -}: { - change: BalanceChange; - features: Feature[]; -}) { - const feature = features.find((f) => f.id === change.feature_id); - const balance = change.balance ?? {}; - const previous = change.previous_attributes ?? change.before ?? {}; - const field = BALANCE_FIELDS.find((key) => previous[key] !== undefined); - const currentValue = - field === undefined ? (change.granted ?? balance.granted) : balance[field]; - const previousValue = field === undefined ? undefined : previous[field]; - - return ( - - - Updated - - - {feature?.name ?? change.feature_id} - - - {field && {field.replaceAll("_", " ")}} - {previousValue !== undefined ? ( - <> - {formatBalanceValue(previousValue)} - - - {formatBalanceValue(currentValue)} - - - ) : ( - - {formatBalanceValue(currentValue)} - - )} - - - ); -} - -function FlagChangeRow({ - change, - features, -}: { - change: FlagChange; - features: Feature[]; -}) { - const feature = features.find((f) => f.id === change.feature_id); - - const action = change.action ?? "unknown"; - return ( - - - - {ACTION_LABELS[action] ?? action} - - - - {feature?.name ?? change.feature_id} - - - ); -} - function PreviewSummary({ preview }: { preview: MigrationPreview }) { - const { features } = useFeaturesQuery(); const planChanges = parseList(preview.plan_changes); - const balanceChanges = parseList(preview.balance_changes); - const flagChanges = parseList(preview.flag_changes); + const allBalanceChanges = parseList(preview.balance_changes); + const allFlagChanges = parseList(preview.flag_changes); - if (planChanges.length + balanceChanges.length + flagChanges.length === 0) + const itemChangeFeatureIds = new Set(); + for (const pc of planChanges) { + for (const ic of pc.item_changes ?? []) { + if (ic.feature_id) itemChangeFeatureIds.add(ic.feature_id); + } + } + + const standaloneBalanceChanges = allBalanceChanges.filter( + (bc) => bc.feature_id && !itemChangeFeatureIds.has(bc.feature_id), + ); + const standaloneFlagChanges = allFlagChanges.filter( + (fc) => fc.feature_id && !itemChangeFeatureIds.has(fc.feature_id), + ); + + // New plans without item_changes absorb standalone balance/flag changes as children + const newPlanIndex = planChanges.findIndex( + (pc) => + (pc.action === "activated" || pc.action === "created") && + !(pc.item_changes?.length), + ); + const absorbed = + newPlanIndex >= 0 && + (standaloneBalanceChanges.length > 0 || standaloneFlagChanges.length > 0); + + const total = + planChanges.length + + standaloneBalanceChanges.length + + standaloneFlagChanges.length; + + if (total === 0) return No changes; return (
{planChanges.map((c, i) => ( - - ))} - {balanceChanges.map((c, i) => ( - ))} - {flagChanges.map((c, i) => ( - + {!absorbed && standaloneBalanceChanges.map((c) => ( + + ))} + {!absorbed && standaloneFlagChanges.map((c, i) => ( + ))}
); diff --git a/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx b/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx index 5037cac59..51561d28d 100644 --- a/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx +++ b/vite/src/views/migrations/migration/live/MigrationCustomerSheet.tsx @@ -22,7 +22,10 @@ export function MigrationCustomerSheet({ isActive, activeRunDryRun, invalidate: invalidateRuns, - } = useMigrationRunsQuery({ migrationId }); + } = useMigrationRunsQuery({ + migrationId, + itemIds: [customer.internal_id], + }); const { subscriptions: realtimeSubscriptions, diff --git a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx index ee3ce032e..af99bdf7e 100644 --- a/vite/src/views/migrations/migration/live/MigrationLiveView.tsx +++ b/vite/src/views/migrations/migration/live/MigrationLiveView.tsx @@ -15,7 +15,7 @@ import { } from "@phosphor-icons/react"; import type { ColumnDef, PaginationState, Row } from "@tanstack/react-table"; import { debounce } from "lodash"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { Table } from "@/components/general/table"; @@ -23,7 +23,7 @@ import { Badge } from "@/components/v2/badges/Badge"; import { Button } from "@/components/v2/buttons/Button"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; -import { Checkbox } from "@/components/v2/checkboxes/Checkbox"; +import { Separator } from "@/components/v2/separator"; import { Dialog, DialogContent, @@ -39,6 +39,7 @@ import { DropdownMenuTrigger, } from "@/components/v2/dropdowns/DropdownMenu"; import { Input } from "@/components/v2/inputs/Input"; +import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, @@ -60,7 +61,6 @@ import { } from "@/hooks/queries/useMigrationsQuery"; import { cn } from "@/lib/utils"; import { pushPage } from "@/utils/genUtils"; -import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { useCustomerFilters } from "@/views/customers/hooks/useCustomerFilters"; import { createCustomerListColumns } from "@/views/customers2/components/table/customer-list/CustomerListColumns"; import { CustomerListFilterButton } from "@/views/customers2/components/table/customer-list/CustomerListFilterButton"; @@ -83,7 +83,6 @@ const PAGE_SIZE_OPTIONS = [10, 50, 100, 250]; type ActiveRunStatus = "queued" | "running" | null; type AdminRunControls = { lazyRun: boolean; - concurrency: string; retryErrored: boolean; retrySkipped: boolean; }; @@ -105,13 +104,6 @@ function buildEventsByCustomer(itemEvents: MigrationItemEvent[]) { return map; } -function parseConcurrency(value: string) { - const trimmed = value.trim(); - if (!trimmed) return undefined; - const parsed = Number(trimmed); - return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; -} - function buildRetryItemStatuses({ retryErrored, retrySkipped, @@ -249,7 +241,6 @@ export function MigrationLiveView({ const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); const [runControls, setRunControls] = useState({ lazyRun: true, - concurrency: "", retryErrored: false, retrySkipped: false, }); @@ -261,18 +252,11 @@ export function MigrationLiveView({ running: null as "dry" | "live" | null, }); const { cancelRun, isCanceling } = useMigrationsQuery(); - const { isAdmin } = useAdmin(); - const hasInvalidConcurrency = - runControls.concurrency.trim() !== "" && - parseConcurrency(runControls.concurrency) === undefined; - const adminRunControls = isAdmin - ? { - lazyRun: runControls.lazyRun, - concurrency: parseConcurrency(runControls.concurrency), - retryItemStatuses: buildRetryItemStatuses(runControls), - } - : undefined; + const resolvedRunControls = { + lazyRun: runControls.lazyRun, + retryItemStatuses: buildRetryItemStatuses(runControls), + }; const debouncedSetSearch = useMemo( () => debounce((q: string) => setDebouncedSearch(q), 350), @@ -340,14 +324,6 @@ export function MigrationLiveView({ ); const progressRun = activeRun ?? latestRun; const progressCounts = progressRun?.item_run_counts; - const runScopedTarget = - progressRun?.only_ids?.length ?? - (progressRun?.target_limit as number | null) ?? - undefined; - const progressTarget = - progressCounts && runScopedTarget && progressCounts.total > runScopedTarget - ? (count ?? progressCounts.total) - : (runScopedTarget ?? count ?? undefined); const activeRunStatus: ActiveRunStatus = hasRealtimeActive ? "running" : ((activeRun?.status as ActiveRunStatus) ?? null); @@ -478,19 +454,7 @@ export function MigrationLiveView({
)} - - ) : null, - }} - > + {activeRun && (
)} - {isAdmin && ( - - )} + 0} + hasSkippedItems={(progressCounts?.skipped ?? 0) > 0} + /> ({ ...s, running: null, open: false })); @@ -769,7 +729,6 @@ export function MigrationLiveView({ isLoading={sample.running === "live"} disabled={ sample.running !== null || - hasInvalidConcurrency || (sample.mode === "limit" ? !sample.limit || Number(sample.limit) < 1 : sample.customerIds.length === 0) @@ -780,13 +739,13 @@ export function MigrationLiveView({ await triggerRun({ dryRun: false, limit: Number(sample.limit), - ...adminRunControls, + ...resolvedRunControls, }); } else { await triggerRun({ dryRun: false, only: sample.customerIds, - ...adminRunControls, + ...resolvedRunControls, }); } setSample((s) => ({ ...s, running: null, open: false })); @@ -871,6 +830,12 @@ export function MigrationLiveView({ ))} + {progressCounts && ( + + )} @@ -899,122 +864,93 @@ export function MigrationLiveView({ function ExecutionProgressBadge({ completed, running, - target, }: { completed: number; running: number; - target?: number; }) { if (completed === 0 && running === 0) return null; - const completedLabel = target - ? `${completed.toLocaleString()} / ${target.toLocaleString()}` - : completed.toLocaleString(); - return ( - - {completedLabel} done + + {completed.toLocaleString()} run {running > 0 && `, ${running.toLocaleString()} running`} - + ); } -function AdminMigrationRunControls({ +function MigrationRunControls({ value, onChange, - invalidConcurrency, lazyDisabled = false, + hasFailedItems = false, + hasSkippedItems = false, }: { value: AdminRunControls; onChange: (value: AdminRunControls) => void; - invalidConcurrency: boolean; + invalidConcurrency?: boolean; lazyDisabled?: boolean; + hasFailedItems?: boolean; + hasSkippedItems?: boolean; }) { - const concurrencyInputId = useId(); - const retryErroredInputId = useId(); - const retrySkippedInputId = useId(); - return ( -
-
- Admin run controls -
-
-
- - onChange({ ...value, lazyRun: checked === true }) - } - className="mt-0.5" - /> - - Lazy run - - Background run also migrates customers on request. - +
+ +
+
+ Lazy run + + Also migrates customers on request.
-
- - - onChange({ ...value, concurrency: event.target.value }) - } - placeholder="Default" - className={cn(invalidConcurrency && "border-red-500")} - /> - {invalidConcurrency && ( - - Use a whole number >= 1 - - )} -
+ + onChange({ ...value, lazyRun: checked === true }) + } + />
-
- -
+ )}
); } diff --git a/vite/src/views/migrations/migration/shared/migrationItemUtils.ts b/vite/src/views/migrations/migration/shared/migrationItemUtils.ts index 82494a7a8..7e7807819 100644 --- a/vite/src/views/migrations/migration/shared/migrationItemUtils.ts +++ b/vite/src/views/migrations/migration/shared/migrationItemUtils.ts @@ -4,9 +4,14 @@ import type { ProductItemInterval, UsageModel, } from "@autumn/shared"; -import { Infinite } from "@autumn/shared"; +import { Infinite, ProductItemFeatureType } from "@autumn/shared"; import { getDefaultItem } from "@/views/products/plan/utils/getDefaultItem"; +const BOOLEAN_TYPES = new Set([ + ProductItemFeatureType.Static, + ProductItemFeatureType.Boolean, +]); + export function migrationItemToProductItem( migItem: Record, features: Feature[], @@ -17,15 +22,22 @@ export function migrationItemToProductItem( ? (getDefaultItem({ feature }) as ProductItem) : ({ feature_id: featureId } as ProductItem); - if (migItem.unlimited === true) { - base.included_usage = Infinite; - } else if (migItem.included !== undefined) { - base.included_usage = migItem.included as number; - } + const isBooleanItem = BOOLEAN_TYPES.has(base.feature_type as string); + const price = migItem.price as Record | undefined; - if (price) { + const hasPrice = !!price; + + if (!isBooleanItem) { + if (migItem.unlimited === true) { + base.included_usage = Infinite; + base.interval = null; + } else if (migItem.included !== undefined) { + base.included_usage = migItem.included as number; + } + } + + if (hasPrice) { base.tiers = [{ to: "inf", amount: Number(price.amount ?? 0) }]; - // null interval in ProductItem means one-off; the API uses "one_off" base.interval = price.interval && price.interval !== "one_off" ? (price.interval as ProductItemInterval) @@ -37,9 +49,6 @@ export function migrationItemToProductItem( const reset = migItem.reset as Record | undefined; if (reset?.interval) { base.interval = reset.interval as ProductItemInterval; - } else if (!price) { - // No price and no reset means one-off entitlement - base.interval = null; } } return base; diff --git a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx index 58f9ff1fc..676374515 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/PriceTiers.tsx @@ -223,7 +223,7 @@ export function PriceTiers({ const amountValue = isFlatMode ? (tier.flat_amount ?? 0) : tier.amount; return ( -
+
{Number(includedUsage) === 0 && index === 0 ? "first" diff --git a/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx b/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx index b7bfbcd5c..95d9e9087 100644 --- a/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx +++ b/vite/src/views/products/plan/versioning/PlanChangeDialog.tsx @@ -1,14 +1,13 @@ 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 { useMemo, 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 { Switch } from "@/components/ui/switch"; import { Button } from "@/components/v2/buttons/Button"; -import { cn } from "@/lib/utils"; import { Dialog, DialogContent, @@ -18,6 +17,8 @@ import { DialogTitle, } from "@/components/v2/dialogs/Dialog"; import { Input } from "@/components/v2/inputs/Input"; +import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; +import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; import { useOrg } from "@/hooks/common/useOrg"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useMigrationsQuery } from "@/hooks/queries/useMigrationsQuery"; @@ -25,9 +26,17 @@ import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useProductStore } from "@/hooks/stores/useProductStore"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr, navigateTo } from "@/utils/genUtils"; -import { useProductQuery } from "../../product/hooks/useProductQuery"; +import { + useProductQuery, + useProductQueryState, +} from "../../product/hooks/useProductQuery"; import { updateProduct } from "../../product/utils/updateProduct"; -import { buildMigrationDraft, type MigrationDraft } from "./buildMigrationDraft"; +import { + buildMigrationDraft, + type MigrationScope, +} from "./buildMigrationDraft"; + +type MigrationChoice = "keep" | MigrationScope; function usePriceChange( baseProduct: FrontendProduct | null, @@ -37,13 +46,20 @@ function usePriceChange( return useMemo(() => { if (!baseProduct) return null; - const oldDisplay = getProductPriceDisplay({ product: baseProduct, currency }); + const oldDisplay = getProductPriceDisplay({ + product: baseProduct, + currency, + }); const newDisplay = getProductPriceDisplay({ product, currency }); - 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; + 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; if (oldPrice === newPrice && oldInterval === newInterval) return null; @@ -55,7 +71,8 @@ function usePriceChange( newPrice, oldIntervalText: oldInterval !== newInterval ? oldInterval : null, newIntervalText: newInterval, - isUpgrade: (currentPriceItem?.price ?? 0) > (originalPriceItem?.price ?? 0), + isUpgrade: + (currentPriceItem?.price ?? 0) > (originalPriceItem?.price ?? 0), }; }, [baseProduct, product.items, currency]); } @@ -71,22 +88,30 @@ export default function PlanChangeDialog({ const navigate = useNavigate(); const product = useProductStore((s) => s.product); const baseProduct = useProductStore((s) => s.baseProduct); + const setBaseProduct = useProductStore((s) => s.setBaseProduct); const { features = [] } = useFeaturesQuery(); const { refetch } = useProductQuery(); + const { setQueryStates } = useProductQueryState(); const { invalidate: invalidateProducts } = useProductsQuery(); - const { createMigration, invalidate: invalidateMigrations } = 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" | "migrate" | null + const [createVersion, setCreateVersion] = useState(true); + const [migrationChoice, setMigrationChoice] = + useState("keep"); + const [step, setStep] = useState<"confirm" | "done">("confirm"); + const [createdMigrationId, setCreatedMigrationId] = useState< + string | null >(null); - const [step, setStep] = useState<"confirm" | "plan-updated">("confirm"); - const migrationDraftRef = useRef(null); const currency = org?.default_currency ?? "USD"; const priceChange = usePriceChange(baseProduct, product, currency); + const { products } = useProductsQuery(); + const latestVersion = products.find((p) => p.id === product.id)?.version; + const hasMultipleVersions = (latestVersion ?? 1) > 1; const hasChanges = useMemo(() => { if (!baseProduct || features.length === 0) return false; @@ -100,32 +125,38 @@ export default function PlanChangeDialog({ const confirmed = confirmText === product.id; - const handleNewVersion = async () => { - if (!confirmed) { - toast.error("Confirmation text is incorrect"); - return; - } + let effectiveMigrationScope: MigrationScope | null; + if (migrationChoice !== "keep") { + effectiveMigrationScope = migrationChoice; + } else { + effectiveMigrationScope = createVersion ? null : "this_version"; + } - setIsLoading(true); - setLoadingAction("new-version"); - await updateProduct({ - axiosInstance, - productId: product.id, - product, - version: baseProduct?.version, - onSuccess: async () => { - await refetch(); - invalidateProducts(); - }, - }); - toast.success("New version created"); - setIsLoading(false); - setLoadingAction(null); - setOpen(false); + const resetState = () => { setConfirmText(""); + setCreateVersion(true); + setMigrationChoice("keep"); + setStep("confirm"); + setCreatedMigrationId(null); }; - const handleUpdatePlan = async () => { + const syncToLatestVersion = async () => { + await setQueryStates({ version: null }); + await refetch(); + invalidateProducts(); + }; + + const setProduct = useProductStore((s) => s.setProduct); + + const markSaved = () => { + setBaseProduct(product as FrontendProduct); + }; + + const discardEdits = () => { + if (baseProduct) setProduct(baseProduct); + }; + + const handleSave = async () => { if (!confirmed) { toast.error("Confirmation text is incorrect"); return; @@ -133,50 +164,39 @@ export default function PlanChangeDialog({ if (!baseProduct) return; setIsLoading(true); - setLoadingAction("update"); try { - migrationDraftRef.current = buildMigrationDraft({ - baseProduct, - editedProduct: product, - features, - }); + if (createVersion) { + const result = await updateProduct({ + axiosInstance, + productId: product.id, + product, + onSuccess: async () => { + invalidateProducts(); + }, + }); - const result = await updateProduct({ - axiosInstance, - productId: product.id, - product, - version: baseProduct.version, - disableVersion: true, - onSuccess: async () => { - await refetch(); - invalidateProducts(); - }, - }); + if (!result) return; + markSaved(); + } else { + discardEdits(); + } - if (!result) { - migrationDraftRef.current = null; + if (!effectiveMigrationScope) { + toast.success("New version created"); + setOpen(false); + resetState(); + syncToLatestVersion(); return; } - setStep("plan-updated"); - } catch (error) { - toast.error(getBackendErr(error, "Failed to update plan")); - migrationDraftRef.current = null; - } finally { - setIsLoading(false); - setLoadingAction(null); - } - }; + const draft = buildMigrationDraft({ + baseProduct, + editedProduct: product, + features, + scope: effectiveMigrationScope, + }); - 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, @@ -186,45 +206,58 @@ export default function PlanChangeDialog({ await invalidateMigrations(); - setOpen(false); - setConfirmText(""); - setStep("confirm"); - migrationDraftRef.current = null; - toast.success("Migration created from plan changes"); - navigateTo(`/migrations/${migration.id}?step=operations`, navigate); + setCreatedMigrationId(migration.id); + setStep("done"); + toast.success( + createVersion + ? "New version created with migration" + : "Migration created", + ); } catch (error) { - toast.error(getBackendErr(error, "Failed to create migration")); + toast.error(getBackendErr(error, "Failed to save plan")); } finally { setIsLoading(false); - setLoadingAction(null); } }; + const handleClose = () => { + setOpen(false); + resetState(); + if (createVersion) syncToLatestVersion(); + }; + + const handleGoToMigration = () => { + if (!createdMigrationId) return; + setOpen(false); + resetState(); + navigateTo( + `/migrations/${createdMigrationId}?step=operations`, + navigate, + ); + }; + const handleOpenChange = (nextOpen: boolean) => { if (!isLoading) { setOpen(nextOpen); if (!nextOpen) { - setConfirmText(""); - setStep("confirm"); - migrationDraftRef.current = null; + resetState(); + if (createVersion) syncToLatestVersion(); } } }; return ( - + {step === "confirm" ? ( <> Save plan changes + + +
-

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

- {hasChanges && ( )} +
+
+ + Create a new plan version + + + New customers will get this + version. Disable to update + existing customers only. + +
+ +
+ +
+

+ Existing customers +

+ + setMigrationChoice( + val as MigrationChoice, + ) + } + > + {createVersion && ( + + )} + + {hasMultipleVersions && ( + + )} + +
+

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

@@ -260,23 +346,20 @@ export default function PlanChangeDialog({
- +
- - + ) : ( @@ -288,23 +371,31 @@ export default function PlanChangeDialog({ weight="fill" className="text-green-500" /> - Plan updated + + {createVersion + ? "Version created with migration" + : "Migration created"} +
- Create a migration to move existing customers to - the new plan configuration. + Your migration is ready to review and run. - + + @@ -313,38 +404,3 @@ export default function PlanChangeDialog({ ); } - -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 55aec1248..51787a2c2 100644 --- a/vite/src/views/products/plan/versioning/buildMigrationDraft.ts +++ b/vite/src/views/products/plan/versioning/buildMigrationDraft.ts @@ -1,10 +1,17 @@ -import type { Feature, FrontendProduct, ProductItem } from "@autumn/shared"; -import { - findSimilarItem, - Infinite, - isPriceItem, - productsAreSame, +import type { + ApiPlanV1, + Feature, + FrontendProduct, } from "@autumn/shared"; +import { + diffPlanV1, + itemToBillingInterval, + productItemsToPlanItemsV1, + productV2ToBasePrice, + productV2ToFeatureItems, + sortProductItems, +} from "@autumn/shared"; +import type { DiffedCustomizePlanV1 } from "@autumn/shared/utils/planV1Utils/diff/diffPlanV1.js"; import type { MigrationFilter } from "@autumn/shared/api/migrations/filters/migrationFilter.js"; import type { Operations } from "@autumn/shared/api/migrations/operations/operations.js"; @@ -15,124 +22,111 @@ export interface MigrationDraft { no_billing_changes: boolean; } -function productItemToAddItem(item: ProductItem): Record { - const result: Record = { feature_id: item.feature_id }; +function frontendProductToApiPlanV1( + product: FrontendProduct, + features: Feature[], +): ApiPlanV1 { + const sorted = sortProductItems(product.items, features); + const basePriceItem = productV2ToBasePrice({ product: product as any }); + const featureItems = productV2ToFeatureItems({ + items: sorted, + withBasePrice: false, + }); + const planItems = productItemsToPlanItemsV1({ + items: featureItems, + features, + }); - if (item.included_usage != null) { - if (item.included_usage === Infinite) { - result.unlimited = true; - } else { - result.included = Number(item.included_usage); - } - } + const basePrice: ApiPlanV1["price"] = basePriceItem + ? { + amount: basePriceItem.price, + interval: itemToBillingInterval({ item: basePriceItem }), + ...(basePriceItem.interval_count !== 1 && + typeof basePriceItem.interval_count === "number" + ? { interval_count: basePriceItem.interval_count } + : {}), + } + : null; - if (item.tiers && item.tiers.length > 0) { - const priceObj: Record = { - amount: item.tiers[0].amount ?? 0, - interval: item.interval ?? "one_off", - }; - if (item.usage_model) priceObj.billing_method = item.usage_model; - result.price = priceObj; - } else if (item.interval) { - result.reset = { interval: item.interval }; - } + const freeTrial: ApiPlanV1["free_trial"] = product.free_trial + ? { + duration_type: product.free_trial.duration, + duration_length: product.free_trial.length, + card_required: product.free_trial.card_required ?? false, + ...(product.free_trial.on_end + ? { on_end: product.free_trial.on_end } + : {}), + } + : undefined; - return result; + return { + id: product.id, + name: product.name || "", + description: product.description || null, + group: product.group || null, + version: product.version, + add_on: product.is_add_on, + auto_enable: product.is_default, + price: basePrice, + items: planItems, + free_trial: freeTrial, + created_at: product.created_at, + env: product.env, + archived: product.archived ?? false, + base_variant_id: null, + config: product.config ?? { ignore_past_due: false }, + } satisfies ApiPlanV1; } -function getIntervalFilter(item: ProductItem): string | undefined { - return (item.interval as string) ?? undefined; +function diffHasBillingChanges(diff: DiffedCustomizePlanV1): boolean { + if (diff.price !== undefined) return true; + if (diff.add_items?.some((i) => i.price != null)) return true; + return false; } -function buildItemFilter(item: ProductItem): Record { - const filter: Record = { feature_id: item.feature_id }; - const interval = getIntervalFilter(item); - if (interval) filter.interval = interval; - return filter; -} +export type MigrationScope = "this_version" | "all_customers"; -/** - * Diffs baseProduct vs editedProduct and returns a migration draft - * with a single `update_plan` operation containing `remove_items` - * and `add_items` to bring existing customers to the new shape. - */ export function buildMigrationDraft({ baseProduct, editedProduct, features, + scope, }: { baseProduct: FrontendProduct; editedProduct: FrontendProduct; features: Feature[]; + scope: MigrationScope; }): MigrationDraft { - const { newItems, removedItems, onlyEntsChanged } = productsAreSame({ - curProductV2: baseProduct, - newProductV2: editedProduct, - features, - }); + const from = frontendProductToApiPlanV1(baseProduct, features); + const to = frontendProductToApiPlanV1(editedProduct, features); + const diff = diffPlanV1({ from, to }); - const addItems: Record[] = []; - const removeItems: Record[] = []; - - // New or replaced items. 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; - - const replacedItem = findSimilarItem({ item, items: removedItems }); - if (replacedItem) { - removeItems.push(buildItemFilter(replacedItem)); - } - addItems.push(productItemToAddItem(item)); - } - - // Purely removed items (no replacement in the new product). - for (const item of removedItems) { - if (!item.feature_id) continue; - 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); - - const customize: Record = {}; - if (addItems.length > 0) customize.add_items = addItems; - if (removeItems.length > 0) customize.remove_items = removeItems; - if (basePriceChanged && newBase) { - customize.price = { - amount: - (newBase as Record).price ?? - newBase.tiers?.[0]?.amount ?? - 0, - interval: newBase.interval ?? "month", - }; - } - - const hasCustomize = Object.keys(customize).length > 0; + const hasCustomize = Object.keys(diff).length > 0; + const customize = hasCustomize ? diff : undefined; const updatePlanOp = { type: "update_plan" as const, plan_filter: { plan_id: baseProduct.id }, - ...(hasCustomize ? { customize } : {}), + ...(customize ? { customize } : {}), }; + const planFilter = + scope === "this_version" + ? { plan_id: baseProduct.id, version: baseProduct.version } + : { plan_id: baseProduct.id }; + const filter: MigrationFilter = { - customer: { - plan: { plan_id: baseProduct.id, version: baseProduct.version }, - }, + customer: { plan: planFilter }, }; + const suffix = + scope === "all_customers" ? "update-all" : "update"; const timestamp = Math.floor(Date.now() / 1000); return { - id: `${baseProduct.id}-update-${timestamp}`, + id: `${baseProduct.id}-${suffix}-${timestamp}`, filter, operations: { customer: [updatePlanOp] } as unknown as Operations, - no_billing_changes: onlyEntsChanged, + no_billing_changes: !diffHasBillingChanges(diff), }; } diff --git a/vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx b/vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx deleted file mode 100644 index ed64f9632..000000000 --- a/vite/src/views/products/product/hooks/queries/useMigrationsQuery.tsx.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; - -export const useMigrationsQuery = () => { - const axiosInstance = useAxiosInstance(); - const buildKey = useQueryKeyFactory(); - - const fetchProductMigrations = async () => { - const { data } = await axiosInstance.get("/products/migrations"); - return data; - }; - - const { data, isLoading, error, refetch } = useQuery({ - queryKey: buildKey(["migrations"]), - queryFn: fetchProductMigrations, - retry: false, // Don't retry on error - }); - - return { migrations: data?.migrations || [], isLoading, error, refetch }; -}; diff --git a/vite/src/views/products/product/hooks/useProductQuery.tsx b/vite/src/views/products/product/hooks/useProductQuery.tsx index b33d918b7..20d38c908 100644 --- a/vite/src/views/products/product/hooks/useProductQuery.tsx +++ b/vite/src/views/products/product/hooks/useProductQuery.tsx @@ -9,7 +9,6 @@ import { useAxiosInstance } from "@/services/useAxiosInstance"; import { throwBackendError } from "@/utils/genUtils"; import { useCachedProduct } from "./getCachedProduct"; -import { useMigrationsQuery } from "./queries/useMigrationsQuery.tsx"; import { useProductCountsQuery } from "./queries/useProductCountsQuery"; // Product query state... @@ -71,7 +70,6 @@ export const useProductQuery = () => { }); const { refetch: refetchCounts } = useProductCountsQuery(); - const { refetch: refetchMigrations } = useMigrationsQuery(); const product = data?.product || cachedProduct; const isLoadingWithCache = cachedProduct ? false : isLoading; @@ -93,7 +91,10 @@ export const useProductQuery = () => { isLoading: isLoadingWithCache, refetch: async () => { await refetch(); - await Promise.all([refetchMigrations(), refetchCounts()]); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["migrations"] }), + refetchCounts(), + ]); }, invalidate, error, diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts index 12a5f42d9..fdc5445a8 100644 --- a/vite/src/views/products/product/utils/updateProduct.ts +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -16,14 +16,12 @@ export const updateProduct = async ({ productId, product, onSuccess, - disableVersion, version, }: { axiosInstance: AxiosInstance; productId: string; product: UpdateProductV2Params; onSuccess: () => Promise; - disableVersion?: boolean; version?: number; }) => { const validated = validateItemsBeforeSave( @@ -42,10 +40,7 @@ export const updateProduct = async ({ free_trial: product.free_trial, }); - const options = - disableVersion || version - ? { disableVersion, version } - : undefined; + const options = version ? { version } : undefined; const updatedProduct = await ProductService.updateProduct( axiosInstance,