good luck

This commit is contained in:
Ayush Rodrigues
2026-06-03 16:39:31 +01:00
committed by Charlie Lamb
parent c98532c51b
commit d77fd2b2bd
22 changed files with 944 additions and 603 deletions

View File

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

View File

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

View File

@@ -38,10 +38,12 @@ export const listMigrationItemEvents = async ({
ctx,
migrationId,
migrationRunId,
itemIds,
}: {
ctx: RepoContext;
migrationId: string;
migrationRunId?: string;
itemIds?: string[];
}): Promise<TinybirdMigrationItemEvent[]> => {
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<TinybirdMigrationItemEvent[]> => {
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<TinybirdMigrationItemEvent>(sql);
const rows = result.data ?? [];
ctx.logger.info(
`listMigrationItemEventsBySql: got ${rows.length} results`,
);
return rows.map(normalizeMigrationItemEventJson);
};

View File

@@ -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<string>(Object.values(BillingInterval));
const AllIntervals = [
...Object.values(BillingInterval),
...Object.values(EntInterval).filter((v) => !billingSet.has(v)),
] as [string, ...string[]];
export const PlanItemFilterSchema = z
.object({
feature_id: z.string().optional().meta({
@@ -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({

View File

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

View File

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

View File

@@ -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<typeof DiffedCustomizePlanV1Schema>;
type ApiPlanItem = ApiPlanV1["items"][number];
const toBasePriceParams = (
price: NonNullable<ApiPlanV1["price"]>,
): 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;
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({
<SheetHeader
title={
<span className="flex items-center gap-2">
<Link
to={`/customers/${customerId}`}
className="inline-flex items-center gap-1.5 hover:text-primary transition-colors"
<button
type="button"
onClick={() => navigateTo(`/customers/${customerId}`, navigate)}
className="inline-flex items-center gap-1.5 hover:text-primary transition-colors cursor-pointer"
>
{customer.name || customerId}
<ArrowSquareOutIcon
size={14}
weight="bold"
className="opacity-50"
/>
</Link>
<ArrowSquareOutIcon size={14} weight="bold" className="opacity-50" />
</button>
{isActive && <ActiveRunDot />}
</span>
}

View File

@@ -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<CustomerPlanItemChange>;
type PlanChange = Partial<CustomerPlanChange> & {
@@ -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 <span className={cn("shrink-0", config.color)}>{config.icon}</span>;
}
const ROW_TINTS: Record<string, string> = {
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 (
<div
className={cn(
"flex items-center gap-2 h-8 px-3 rounded-xl border",
action ? (ROW_TINTS[action] ?? "input-base") : "input-base",
"flex items-center gap-2 h-10 px-3 rounded-xl input-base",
className,
)}
>
@@ -183,17 +150,118 @@ function ChangeRow({
);
}
function PlanChangeRows({
change,
features,
function buildItemTooltipLines(
apiItem: Record<string, unknown>,
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<string, unknown> | 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 ? (
<div className="ml-4">
<SubscriptionItemRow
item={productItem}
featureId={item.feature_id}
isDeleted={isDeleted}
isCreated={isCreated}
readOnly={!isDeleted}
/>
</div>
) : (
<ChangeRow className="ml-4">
<StatusDot action={action} />
<FeatureIconByFeatureId featureId={item.feature_id} />
<span className="text-body flex-1 min-w-0 truncate">
{feature?.name ?? item.feature_id}
</span>
</ChangeRow>
);
if (tooltipLines.length === 0) return row;
return (
<Tooltip>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{tooltipLines.map((line) => (
<div key={line}>{line}</div>
))}
</TooltipContent>
</Tooltip>
);
}
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 <span className={cn("shrink-0", config.color)}>{config.icon}</span>;
}
function balanceToItemChange(bc: BalanceChange, action = "updated"): ItemChange {
const balance = bc.balance ?? {};
const item: Record<string, unknown> = { 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({
)}
</ChangeRow>
{items.map((item, i) => (
<ChangeRow
key={item.feature_id ?? i}
action={item.action ?? "unknown"}
className="ml-4"
>
<StatusDot action={item.action ?? "unknown"} />
<span className="text-xs text-tertiary-foreground w-14 shrink-0">
{ACTION_LABELS[item.action ?? "unknown"] ?? item.action}
</span>
<FeatureIcon featureId={item.feature_id} features={features} />
<span className="text-body flex-1 min-w-0 truncate">
{features.find((f) => f.id === item.feature_id)?.name ??
item.feature_id}
</span>
</ChangeRow>
<ItemChangeRow key={item.feature_id ?? i} item={item} />
))}
{items.length === 0 && action === "updated" && (
{items.length === 0 && hasAbsorbed && (
<>
{absorbedFlags?.map((fc, i) => (
<ItemChangeRow key={fc.feature_id ?? i} item={flagToItemChange(fc, "created")} />
))}
{absorbedBalances?.map((bc) => (
<ItemChangeRow key={bc.feature_id} item={balanceToItemChange(bc, "created")} />
))}
</>
)}
{items.length === 0 && !hasAbsorbed && action === "updated" && (
<div className="ml-4 px-3 py-1">
<span className="text-body-secondary">
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 (
<ChangeRow action="updated">
<StatusDot action="updated" />
<span className="text-xs text-tertiary-foreground w-14 shrink-0">Updated</span>
<FeatureIcon featureId={change.feature_id} features={features} />
<span className="text-body flex-1 min-w-0 truncate">
{feature?.name ?? change.feature_id}
</span>
<span className="text-body-secondary shrink-0 tabular-nums">
{field && <span className="mr-1 capitalize">{field.replaceAll("_", " ")}</span>}
{previousValue !== undefined ? (
<>
{formatBalanceValue(previousValue)}
<span className="text-tertiary-foreground/50 mx-1"></span>
<span className="text-foreground font-semibold">
{formatBalanceValue(currentValue)}
</span>
</>
) : (
<span className="text-foreground font-semibold">
{formatBalanceValue(currentValue)}
</span>
)}
</span>
</ChangeRow>
);
}
function FlagChangeRow({
change,
features,
}: {
change: FlagChange;
features: Feature[];
}) {
const feature = features.find((f) => f.id === change.feature_id);
const action = change.action ?? "unknown";
return (
<ChangeRow action={action}>
<StatusDot action={action} />
<span className="text-xs text-tertiary-foreground w-14 shrink-0">
{ACTION_LABELS[action] ?? action}
</span>
<FeatureIcon featureId={change.feature_id} features={features} />
<span className="text-body flex-1 min-w-0 truncate">
{feature?.name ?? change.feature_id}
</span>
</ChangeRow>
);
}
function PreviewSummary({ preview }: { preview: MigrationPreview }) {
const { features } = useFeaturesQuery();
const planChanges = parseList<PlanChange>(preview.plan_changes);
const balanceChanges = parseList<BalanceChange>(preview.balance_changes);
const flagChanges = parseList<FlagChange>(preview.flag_changes);
const allBalanceChanges = parseList<BalanceChange>(preview.balance_changes);
const allFlagChanges = parseList<FlagChange>(preview.flag_changes);
if (planChanges.length + balanceChanges.length + flagChanges.length === 0)
const itemChangeFeatureIds = new Set<string>();
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 <span className="text-sm text-tertiary-foreground">No changes</span>;
return (
<div className="flex flex-col gap-1.5">
{planChanges.map((c, i) => (
<PlanChangeRows key={getPlanId(c) ?? i} change={c} features={features} />
))}
{balanceChanges.map((c, i) => (
<BalanceChangeRow
key={c.feature_id ?? i}
<PlanChangeRows
key={getPlanId(c) ?? i}
change={c}
features={features}
absorbedBalances={i === newPlanIndex ? standaloneBalanceChanges : undefined}
absorbedFlags={i === newPlanIndex ? standaloneFlagChanges : undefined}
/>
))}
{flagChanges.map((c, i) => (
<FlagChangeRow key={c.feature_id ?? i} change={c} features={features} />
{!absorbed && standaloneBalanceChanges.map((c) => (
<ItemChangeRow key={c.feature_id} item={balanceToItemChange(c)} />
))}
{!absorbed && standaloneFlagChanges.map((c, i) => (
<ItemChangeRow key={c.feature_id ?? i} item={flagToItemChange(c)} />
))}
</div>
);

View File

@@ -22,7 +22,10 @@ export function MigrationCustomerSheet({
isActive,
activeRunDryRun,
invalidate: invalidateRuns,
} = useMigrationRunsQuery({ migrationId });
} = useMigrationRunsQuery({
migrationId,
itemIds: [customer.internal_id],
});
const {
subscriptions: realtimeSubscriptions,

View File

@@ -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({
</div>
)}
<StepIndicator
step={step}
onStepChange={onStepChange}
stepMeta={{
live: progressCounts ? (
<ExecutionProgressBadge
completed={progressCounts.completed}
running={progressCounts.running}
target={progressTarget}
/>
) : null,
}}
>
<StepIndicator step={step} onStepChange={onStepChange}>
{activeRun && (
<Button
variant="secondary"
@@ -601,13 +565,12 @@ export function MigrationLiveView({
operations={operations}
noBillingChanges={noBillingChanges}
/>
{isAdmin && (
<AdminMigrationRunControls
value={runControls}
onChange={setRunControls}
invalidConcurrency={hasInvalidConcurrency}
/>
)}
<MigrationRunControls
value={runControls}
onChange={setRunControls}
hasFailedItems={(progressCounts?.failed ?? 0) > 0}
hasSkippedItems={(progressCounts?.skipped ?? 0) > 0}
/>
<DialogFooter>
<Button
variant="secondary"
@@ -617,10 +580,9 @@ export function MigrationLiveView({
</Button>
<Button
variant="primary"
disabled={hasInvalidConcurrency}
onClick={() => {
setIsRunDialogOpen(false);
triggerRun({ dryRun: false, ...adminRunControls });
triggerRun({ dryRun: false, ...resolvedRunControls });
}}
>
<PlayIcon size={14} weight="fill" />
@@ -714,14 +676,13 @@ export function MigrationLiveView({
</div>
)}
</div>
{isAdmin && (
<AdminMigrationRunControls
value={runControls}
onChange={setRunControls}
invalidConcurrency={hasInvalidConcurrency}
lazyDisabled={sample.mode === "select"}
/>
)}
<MigrationRunControls
value={runControls}
onChange={setRunControls}
lazyDisabled={sample.mode === "select"}
hasFailedItems={(progressCounts?.failed ?? 0) > 0}
hasSkippedItems={(progressCounts?.skipped ?? 0) > 0}
/>
</div>
<DialogFooter className="sm:flex-col gap-2">
<ShortcutButton
@@ -730,7 +691,6 @@ export function MigrationLiveView({
isLoading={sample.running === "dry"}
disabled={
sample.running !== null ||
hasInvalidConcurrency ||
(sample.mode === "limit"
? !sample.limit || Number(sample.limit) < 1
: sample.customerIds.length === 0)
@@ -745,13 +705,13 @@ export function MigrationLiveView({
await triggerRun({
dryRun: true,
only: topIds,
...adminRunControls,
...resolvedRunControls,
});
} else {
await triggerRun({
dryRun: true,
only: sample.customerIds,
...adminRunControls,
...resolvedRunControls,
});
}
setSample((s) => ({ ...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({
))}
</SelectContent>
</Select>
{progressCounts && (
<ExecutionProgressBadge
completed={progressCounts.completed}
running={progressCounts.running}
/>
)}
</div>
</div>
@@ -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 (
<Badge variant="muted" className="ml-1 text-[11px]">
{completedLabel} done
<span className="flex items-center h-7 px-2 text-[11px] text-tertiary-foreground">
{completed.toLocaleString()} run
{running > 0 && `, ${running.toLocaleString()} running`}
</Badge>
</span>
);
}
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 (
<div className="rounded-lg border border-border bg-muted/20 p-3">
<div className="mb-3 text-xs font-medium text-muted-foreground">
Admin run controls
</div>
<div className="grid gap-3 sm:grid-cols-[1fr_140px]">
<div
className={cn(
"flex items-start gap-2 text-sm",
lazyDisabled && "opacity-50",
)}
>
<Checkbox
checked={value.lazyRun && !lazyDisabled}
disabled={lazyDisabled}
onCheckedChange={(checked) =>
onChange({ ...value, lazyRun: checked === true })
}
className="mt-0.5"
/>
<span className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">Lazy run</span>
<span className="text-xs text-tertiary-foreground">
Background run also migrates customers on request.
</span>
<div className="flex flex-col gap-3">
<Separator />
<div
className={cn(
"flex items-center justify-between gap-4",
lazyDisabled && "opacity-50",
)}
>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">Lazy run</span>
<span className="text-xs text-tertiary-foreground">
Also migrates customers on request.
</span>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor={concurrencyInputId}
className="text-xs text-tertiary-foreground"
>
Concurrency
</label>
<Input
id={concurrencyInputId}
type="number"
min={1}
step={1}
value={value.concurrency}
onChange={(event) =>
onChange({ ...value, concurrency: event.target.value })
}
placeholder="Default"
className={cn(invalidConcurrency && "border-red-500")}
/>
{invalidConcurrency && (
<span className="text-xs text-red-500">
Use a whole number &gt;= 1
</span>
)}
</div>
<Switch
checked={value.lazyRun && !lazyDisabled}
disabled={lazyDisabled}
onCheckedChange={(checked) =>
onChange({ ...value, lazyRun: checked === true })
}
/>
</div>
<div className="mt-3 grid gap-2 border-t border-border pt-3 sm:grid-cols-2">
<label
htmlFor={retryErroredInputId}
className="flex items-center gap-2 text-sm"
>
<Checkbox
id={retryErroredInputId}
{hasFailedItems && (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
Retry failed
</span>
<span className="text-xs text-tertiary-foreground">
Re-run customers that previously errored.
</span>
</div>
<Switch
checked={value.retryErrored}
onCheckedChange={(checked) =>
onChange({ ...value, retryErrored: checked === true })
}
/>
<span className="font-medium text-foreground">Retry failed</span>
</label>
<label
htmlFor={retrySkippedInputId}
className="flex items-center gap-2 text-sm"
>
<Checkbox
id={retrySkippedInputId}
</div>
)}
{hasSkippedItems && (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
Retry skipped
</span>
<span className="text-xs text-tertiary-foreground">
Re-run customers that were skipped.
</span>
</div>
<Switch
checked={value.retrySkipped}
onCheckedChange={(checked) =>
onChange({ ...value, retrySkipped: checked === true })
}
/>
<span className="font-medium text-foreground">Retry skipped</span>
</label>
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string>([
ProductItemFeatureType.Static,
ProductItemFeatureType.Boolean,
]);
export function migrationItemToProductItem(
migItem: Record<string, unknown>,
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<string, unknown> | 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<string, unknown> | 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;

View File

@@ -223,7 +223,7 @@ export function PriceTiers({
const amountValue = isFlatMode ? (tier.flat_amount ?? 0) : tier.amount;
return (
<div key={index} className="flex gap-2 w-full items-center">
<div key={`${index}-${tier.to}`} className="flex gap-2 w-full items-center">
<span className="text-tertiary-foreground text-xs min-w-0 w-18 shrink-0 h-full">
{Number(includedUsage) === 0 && index === 0
? "first"

View File

@@ -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<MigrationChoice>("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<MigrationDraft | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
{step === "confirm" ? (
<>
<DialogHeader>
<DialogTitle>Save plan changes</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto min-h-0 flex-1">
<DialogDescription asChild>
<div className="text-sm flex flex-col gap-6">
<p>
This plan has existing customers. Choose how to
apply your changes.
</p>
{hasChanges && (
<PlanItemsSection
product={product}
@@ -241,10 +274,63 @@ export default function PlanChangeDialog({
/>
)}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">
Create a new plan version
</span>
<span className="text-xs text-muted-foreground">
New customers will get this
version. Disable to update
existing customers only.
</span>
</div>
<Switch
checked={createVersion}
onCheckedChange={setCreateVersion}
/>
</div>
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-foreground">
Existing customers
</p>
<RadioGroup
value={migrationChoice}
onValueChange={(val) =>
setMigrationChoice(
val as MigrationChoice,
)
}
>
{createVersion && (
<AreaRadioGroupItem
value="keep"
label="Keep as they are"
description="Existing customers stay on their current version."
/>
)}
<AreaRadioGroupItem
value="this_version"
label={`Apply changes to customers on v${baseProduct?.version ?? 1}`}
description="Create a migration to apply these changes to customers on this version."
/>
{hasMultipleVersions && (
<AreaRadioGroupItem
value="all_customers"
label="Apply changes to all customers"
description="Create a migration to apply these changes to all customers on this plan."
/>
)}
</RadioGroup>
</div>
<div className="flex flex-col gap-2">
<p>
Type{" "}
<code className="font-bold">{product.id}</code>{" "}
<code className="font-bold">
{product.id}
</code>{" "}
to continue.
</p>
@@ -260,23 +346,20 @@ export default function PlanChangeDialog({
</div>
</div>
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-col">
<ActionCard
title="Update existing plan"
description="Update the plan and create a migration to move existing customers to the new configuration."
onClick={handleUpdatePlan}
isLoading={loadingAction === "update"}
<DialogFooter>
<Button
variant="primary"
onClick={handleSave}
isLoading={isLoading}
disabled={isLoading || !confirmed}
/>
<ActionCard
title="Create new version"
description="Publish a new version for future customers. Existing customers stay on their current plan."
onClick={handleNewVersion}
isLoading={loadingAction === "new-version"}
disabled={isLoading || !confirmed}
/>
className="w-full"
>
{createVersion
? "Save changes"
: "Create migration"}
</Button>
</DialogFooter>
</>
) : (
@@ -288,23 +371,31 @@ export default function PlanChangeDialog({
weight="fill"
className="text-green-500"
/>
<DialogTitle>Plan updated</DialogTitle>
<DialogTitle>
{createVersion
? "Version created with migration"
: "Migration created"}
</DialogTitle>
</div>
<DialogDescription>
Create a migration to move existing customers to
the new plan configuration.
Your migration is ready to review and run.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogFooter className="flex gap-2 sm:flex-row">
<Button
variant="secondary"
onClick={handleClose}
className="flex-1"
>
Close
</Button>
<Button
variant="primary"
onClick={handleCreateMigration}
isLoading={loadingAction === "migrate"}
disabled={isLoading}
className="w-full"
onClick={handleGoToMigration}
className="flex-1"
>
Create migration
Go to migration
</Button>
</DialogFooter>
</>
@@ -313,38 +404,3 @@ export default function PlanChangeDialog({
</Dialog>
);
}
function ActionCard({
title,
description,
onClick,
isLoading,
disabled,
}: {
title: string;
description: string;
onClick: () => void;
isLoading: boolean;
disabled: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={cn(
"w-full text-left rounded-lg border p-3 transition-colors cursor-pointer",
"hover:border-primary/50 hover:bg-interactive-secondary",
"disabled:opacity-50 disabled:pointer-events-none",
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">{title}</span>
{isLoading && (
<LucideLoaderCircle className="animate-spin size-4 text-muted-foreground" />
)}
</div>
<p className="text-xs text-tertiary-foreground mt-0.5">{description}</p>
</button>
);
}

View File

@@ -1,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<string, unknown> {
const result: Record<string, unknown> = { 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<string, unknown> = {
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<string, unknown> {
const filter: Record<string, unknown> = { 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<string, unknown>[] = [];
const removeItems: Record<string, unknown>[] = [];
// 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<string, unknown> = {};
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<string, unknown>).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),
};
}

View File

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

View File

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

View File

@@ -16,14 +16,12 @@ export const updateProduct = async ({
productId,
product,
onSuccess,
disableVersion,
version,
}: {
axiosInstance: AxiosInstance;
productId: string;
product: UpdateProductV2Params;
onSuccess: () => Promise<void>;
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,