1import {
2 parseUsagePriceForm,
3 usagePriceForm,
4 USAGE_PRICE_FIELDS,
5 type UsagePriceForm,
6} from "./usagePriceForm";
7import type { UsagePriceChange, UsagePriceTarget } from "./usagePriceTargets";
8
9export type UsagePriceField = (typeof USAGE_PRICE_FIELDS)[number]["key"];
10
11export interface UsagePriceDraft {
12 readonly id: string;
13 readonly model: string;
14 readonly isNew: boolean;
15 readonly values: Partial<Pick<UsagePriceForm, UsagePriceField>>;
16 /** Edited "Map to" model. Absent keeps each environment's mapping; blank removes it. */
17 readonly alias?: string;
18 readonly removed?: boolean;
19}
20
21export function isEmptyUsagePriceDraft(draft: UsagePriceDraft) {
22 return (
23 draft.isNew &&
24 draft.model.trim() === "" &&
25 (draft.alias ?? "").trim() === "" &&
26 Object.values(draft.values).every((value) => value.trim() === "")
27 );
28}
29
30function modelPrice(target: UsagePriceTarget, model: string) {
31 return target.prices && Object.hasOwn(target.prices, model) ? target.prices[model] : undefined;
32}
33
34function modelAlias(target: UsagePriceTarget, model: string) {
35 return target.aliases && Object.hasOwn(target.aliases, model) ? target.aliases[model] : undefined;
36}
37
38/** The "Map to" cell across the selected environments, read like a price cell. */
39export function usageAliasCell(targets: readonly UsagePriceTarget[], model: string) {
40 if (targets.some((target) => target.aliases === null))
41 return { value: "", placeholder: "Unavailable" };
42 const values = targets.map((target) => modelAlias(target, model) ?? "");
43 if (values.some((value) => value !== values[0])) return { value: "", placeholder: "Mixed" };
44 return { value: values[0] ?? "", placeholder: "None" };
45}
46
47export function usagePriceCell(
48 targets: readonly UsagePriceTarget[],
49 model: string,
50 field: UsagePriceField,
51) {
52 const optional = USAGE_PRICE_FIELDS.find((entry) => entry.key === field)!.optional;
53 const values = targets.map((target) =>
54 modelPrice(target, model) ? usagePriceForm(model, modelPrice(target, model))[field] : null,
55 );
56 if (targets.some((target) => target.prices === null))
57 return { value: "", placeholder: "Unavailable" };
58 if (values.some((value) => value !== values[0])) return { value: "", placeholder: "Mixed" };
59 return {
60 value: values[0] ?? "",
61 placeholder: values[0] === null ? "Automatic" : optional ? "Input rate" : "0.00",
62 };
63}
64
65/** Only edited cells replace rates; untouched cells retain each environment's own values. */
66export function usagePriceTableChanges(
67 target: UsagePriceTarget,
68 drafts: readonly UsagePriceDraft[],
69) {
70 const changes: UsagePriceChange[] = [];
71 const errors = new Map<string, string>();
72 for (const draft of drafts) {
73 if (isEmptyUsagePriceDraft(draft)) continue;
74 const model = draft.model.trim();
75 const currentAlias = modelAlias(target, model);
76 if (draft.removed) {
77 if (modelPrice(target, model)) changes.push({ model, price: null });
78 if (currentAlias !== undefined) changes.push({ model, alias: null });
79 continue;
80 }
81 // A mapped model is priced as its target, so mapping it drops its own price.
82 const alias = (draft.alias ?? currentAlias ?? "").trim();
83 if (alias !== "") {
84 if (model === "") errors.set(draft.id, "Enter a model ID.");
85 else if (alias === model) errors.set(draft.id, "Map to a different model.");
86 else {
87 if (alias !== currentAlias) changes.push({ model, alias });
88 if (modelPrice(target, model)) changes.push({ model, price: null });
89 }
90 continue;
91 }
92 if (currentAlias !== undefined) {
93 changes.push({ model, alias: null });
94 // Clearing a mapping without entering prices returns to automatic pricing.
95 if (
96 !modelPrice(target, model) &&
97 Object.values(draft.values).every((value) => value.trim() === "")
98 )
99 continue;
100 }
101 const original = usagePriceForm(model, modelPrice(target, model));
102 const form = { ...original, ...draft.values };
103 const parsed = parseUsagePriceForm(form);
104 if (parsed === null) {
105 const missing = USAGE_PRICE_FIELDS.find(
106 (field) => !field.optional && form[field.key].trim() === "",
107 );
108 errors.set(
109 draft.id,
110 model === ""
111 ? "Enter a model ID."
112 : missing
113 ? `${missing.label} is required on ${target.label}.`
114 : "Use non-negative numbers for prices.",
115 );
116 continue;
117 }
118 const next = usagePriceForm(model, parsed.price);
119 if (USAGE_PRICE_FIELDS.some((field) => original[field.key] !== next[field.key]))
120 changes.push(parsed);
121 }
122 return { changes, errors };
123}
124
125/** Unavailable destinations report a save failure without blocking writable environments. */
126export function usagePriceTableErrors(
127 targets: readonly UsagePriceTarget[],
128 drafts: readonly UsagePriceDraft[],
129) {
130 return new Map(
131 targets
132 .filter((target) => target.unavailable === null && target.prices !== null)
133 .flatMap((target) => [...usagePriceTableChanges(target, drafts).errors]),
134 );
135}