Files
cfw-autumn/shared/utils/planV1Utils/diff/diffPlanV1.ts
Ayush Rodrigues 6b51a81106 good luck
2026-06-03 16:39:31 +01:00

143 lines
4.6 KiB
TypeScript

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