Files
cfw-autumn/shared/api/customers/changes/V1.1_FeaturesArrayToObject.ts
John Yeo 3ee3beb420 wip
2025-12-30 17:58:54 -08:00

205 lines
6.7 KiB
TypeScript

import { ApiFeatureType } from "@api/features/prevVersions/apiFeatureV0.js";
import { ApiVersion } from "@api/versionUtils/ApiVersion.js";
import {
AffectedResource,
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import type { EntInterval } from "@models/productModels/intervals/entitlementInterval.js";
import { z } from "zod/v4";
import {
type ApiCusFeatureV2,
ApiCusFeatureV2Schema,
} from "../cusFeatures/previousVersions/apiCusFeatureV2.js";
import type { ApiCusFeatureV3BreakdownSchema } from "../cusFeatures/previousVersions/apiCusFeatureV3.js";
import { ApiCustomerV3Schema } from "../previousVersions/apiCustomerV3.js";
/**
* V1_2: Features changed from array to object AND merged multi-interval features
*
* V1_2+ format:
* - Features as object keyed by feature_id
* - Multi-interval features merged with breakdown array
*
* V1_1 format:
* - Features as array
* - Multi-interval features as separate array entries (no breakdown)
*/
// V1_2+ customer schema (features as object with breakdown)
const V1_2_CustomerSchema = ApiCustomerV3Schema;
// V1_1 customer schema (features as array without breakdown)
const V1_1_CustomerSchema = ApiCustomerV3Schema.extend({
features: z.array(ApiCusFeatureV2Schema),
});
type MergedBreakdownItem = {
interval: EntInterval | null | undefined;
interval_count: number | null | undefined;
balance: number;
usage: number;
included_usage: number;
next_reset_at: number | null | undefined;
usage_limit: number | null | undefined;
overage_allowed: boolean;
};
/**
* Merges breakdown items by legacy breakdown key.
* Items with the same key (interval:interval_count:overage_allowed) are merged:
* - Numeric fields are summed
* - next_reset_at takes the earliest value
* - usage_limit is summed
* - overage_allowed prioritizes true (if any item has true, result is true)
*/
function mergeBreakdownItems({
breakdown,
}: {
breakdown: Array<z.infer<typeof ApiCusFeatureV3BreakdownSchema>>;
}): MergedBreakdownItem[] {
const mergedBreakdownMap = new Map<string, MergedBreakdownItem>();
for (const breakdownItem of breakdown) {
const legacyBreakdownKey = `${breakdownItem.interval}:${breakdownItem.interval_count ?? 1}:${breakdownItem.overage_allowed}`;
const existing = mergedBreakdownMap.get(legacyBreakdownKey);
if (existing) {
// Merge: sum numeric fields, take earliest next_reset_at, sum usage_limit
existing.balance = (existing.balance ?? 0) + (breakdownItem.balance ?? 0);
existing.usage = (existing.usage ?? 0) + (breakdownItem.usage ?? 0);
existing.included_usage =
(existing.included_usage ?? 0) + (breakdownItem.included_usage ?? 0);
if (
breakdownItem.next_reset_at !== null &&
breakdownItem.next_reset_at !== undefined
) {
if (
existing.next_reset_at === null ||
existing.next_reset_at === undefined ||
breakdownItem.next_reset_at < existing.next_reset_at
) {
existing.next_reset_at = breakdownItem.next_reset_at;
}
}
// Sum usage_limit (use included_usage as fallback if usage_limit is null/undefined)
const itemUsageLimit =
breakdownItem.usage_limit ?? breakdownItem.included_usage ?? 0;
const existingUsageLimit =
existing.usage_limit ?? existing.included_usage ?? 0;
existing.usage_limit = existingUsageLimit + itemUsageLimit;
// overage_allowed: true takes priority
if (breakdownItem.overage_allowed === true) {
existing.overage_allowed = true;
}
} else {
// First item with this key
mergedBreakdownMap.set(legacyBreakdownKey, {
interval: breakdownItem.interval ?? null,
interval_count: breakdownItem.interval_count ?? null,
balance: breakdownItem.balance ?? 0,
usage: breakdownItem.usage ?? 0,
included_usage: breakdownItem.included_usage ?? 0,
next_reset_at: breakdownItem.next_reset_at ?? null,
usage_limit:
breakdownItem.usage_limit || breakdownItem.included_usage || null,
overage_allowed: breakdownItem.overage_allowed ?? false,
});
}
}
return Array.from(mergedBreakdownMap.values());
}
export const V1_1_FeaturesArrayToObject = defineVersionChange({
name: "V1_1_FeaturesArrayToObject",
newVersion: ApiVersion.V1_2,
oldVersion: ApiVersion.V1_1,
description: [
"Features: object with breakdown → array with expanded intervals",
],
affectedResources: [AffectedResource.Customer],
newSchema: V1_2_CustomerSchema,
oldSchema: V1_1_CustomerSchema,
affectsRequest: false,
affectsResponse: true,
hasSideEffects: false,
// Response: V1_2 Customer → V1_1 Customer
// 1. Expand features with breakdown into separate entries
// 2. Convert object to array
transformResponse: ({
input,
}: {
input: z.infer<typeof V1_2_CustomerSchema>;
}) => {
const v1_1_features: ApiCusFeatureV2[] = [];
for (const [_featureId, feature] of Object.entries(input.features)) {
// If feature has breakdown, expand into separate entries
if (feature.breakdown && feature.breakdown.length > 0) {
const mergedBreakdownItems = mergeBreakdownItems({
breakdown: feature.breakdown,
});
// Expand merged breakdown items into separate entries
for (const mergedItem of mergedBreakdownItems) {
v1_1_features.push({
feature_id: feature.id,
interval: mergedItem.interval,
interval_count: mergedItem.interval_count,
balance: mergedItem.balance,
usage: mergedItem.usage,
included_usage: mergedItem.included_usage,
next_reset_at: mergedItem.next_reset_at,
usage_limit: mergedItem.usage_limit,
unlimited: feature.unlimited, // inherit from parent
overage_allowed: mergedItem.overage_allowed,
} satisfies ApiCusFeatureV2);
}
} else {
if (feature.unlimited) {
v1_1_features.push({
feature_id: feature.id,
unlimited: true,
});
continue;
} else if (feature.type === ApiFeatureType.Static) {
v1_1_features.push({
feature_id: feature.id,
});
continue;
}
// No breakdown - just remove the breakdown field
v1_1_features.push({
feature_id: feature.id,
interval: feature.interval === "multiple" ? null : feature.interval,
interval_count: feature.interval_count,
balance: feature.balance,
usage: feature.usage,
included_usage: feature.included_usage,
next_reset_at: feature.next_reset_at,
usage_limit: feature.usage_limit || feature.included_usage,
rollovers: feature.rollovers,
unlimited: feature.unlimited,
overage_allowed: feature.overage_allowed,
// credit_schema: feature.credit_schema,
} satisfies ApiCusFeatureV2);
}
}
// Boolean / unlimited features don't have the rest of the fields
// Validation happens automatically in VersionChange base class
return {
...input,
features: v1_1_features,
} satisfies z.infer<typeof V1_1_CustomerSchema>;
},
});