good luck
This commit is contained in:
committed by
Charlie Lamb
parent
c98532c51b
commit
d77fd2b2bd
@@ -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)}}
|
||||
`,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
95
shared/utils/planV1Utils/diff/applyDiff.ts
Normal file
95
shared/utils/planV1Utils/diff/applyDiff.ts
Normal 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),
|
||||
});
|
||||
142
shared/utils/planV1Utils/diff/diffPlanV1.ts
Normal file
142
shared/utils/planV1Utils/diff/diffPlanV1.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,10 @@ export function MigrationCustomerSheet({
|
||||
isActive,
|
||||
activeRunDryRun,
|
||||
invalidate: invalidateRuns,
|
||||
} = useMigrationRunsQuery({ migrationId });
|
||||
} = useMigrationRunsQuery({
|
||||
migrationId,
|
||||
itemIds: [customer.internal_id],
|
||||
});
|
||||
|
||||
const {
|
||||
subscriptions: realtimeSubscriptions,
|
||||
|
||||
@@ -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 >= 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user