///
import { type Cell, Default, handler, lift, recipe, str } from "commontools";
interface CurrencyConversionArgs {
baseCurrency: Default;
amount: Default;
rates: Default, {
USD: 1;
EUR: 0.92;
GBP: 0.78;
}>;
targets: Default;
}
interface RateUpdateEvent {
currency?: string;
rate?: number;
}
interface AmountUpdateEvent {
amount?: number;
}
const roundTo = (value: number, places: number): number => {
const multiplier = 10 ** places;
return Math.round(value * multiplier) / multiplier;
};
const sanitizeCode = (code: unknown, fallback: string): string => {
if (typeof code === "string") {
const trimmed = code.trim().toUpperCase();
if (trimmed.length > 0) return trimmed;
}
return fallback;
};
const sanitizeAmount = (value: unknown, fallback: number): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return roundTo(value, 2);
};
const sanitizeRate = (value: unknown, fallback: number): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const normalized = roundTo(value, 4);
return normalized > 0 ? normalized : fallback;
};
const sanitizeRateMap = (
value: Record | undefined,
base: string,
): Record => {
const sanitized: Record = {};
if (value && typeof value === "object") {
for (const key of Object.keys(value)) {
const code = sanitizeCode(key, base);
sanitized[code] = code === base ? 1 : sanitizeRate(value[key], 1);
}
}
sanitized[base] = 1;
return sanitized;
};
const ensureTargetList = (
targets: readonly string[] | undefined,
base: string,
rateKeys: readonly string[],
): string[] => {
const codes = new Set();
codes.add(base);
if (targets) {
for (const entry of targets) {
codes.add(sanitizeCode(entry, base));
}
}
for (const key of rateKeys) {
codes.add(sanitizeCode(key, base));
}
const list = Array.from(codes);
list.sort((left, right) => left.localeCompare(right));
return list;
};
const computeConversions = (
data: {
amount: number;
base: string;
rates: Record;
codes: readonly string[];
},
): Record => {
const conversions: Record = {};
for (const code of data.codes) {
if (code === data.base) {
conversions[code] = roundTo(data.amount, 2);
continue;
}
const rate = sanitizeRate(data.rates[code], 1);
conversions[code] = roundTo(data.amount * rate, 2);
}
return conversions;
};
const setBaseAmount = handler(
(
event: AmountUpdateEvent | number | undefined,
context: { amount: Cell },
) => {
const fallback = sanitizeAmount(context.amount.get(), 0);
if (typeof event === "number") {
context.amount.set(sanitizeAmount(event, fallback));
return;
}
if (typeof event?.amount === "number") {
context.amount.set(sanitizeAmount(event.amount, fallback));
return;
}
if (event === undefined) {
context.amount.set(fallback);
}
},
);
const setConversionRate = handler(
(
event: RateUpdateEvent | undefined,
context: {
rates: Cell>;
targets: Cell;
baseCurrency: Cell;
},
) => {
const base = sanitizeCode(context.baseCurrency.get(), "USD");
const normalized = sanitizeRateMap(context.rates.get(), base);
const target = sanitizeCode(event?.currency, base);
const existing = normalized[target] ?? 1;
const nextRate = target === base ? 1 : sanitizeRate(event?.rate, existing);
normalized[target] = nextRate;
normalized[base] = 1;
context.rates.set({ ...normalized });
const updatedTargets = ensureTargetList(
context.targets.get(),
base,
Object.keys(normalized),
);
context.targets.set(updatedTargets);
},
);
export const currencyConversionPattern = recipe(
"Currency Conversion Pattern",
({ amount, baseCurrency, rates, targets }) => {
const baseCode = lift((value: string | undefined) =>
sanitizeCode(value, "USD")
)(baseCurrency);
const normalizedAmount = lift((value: number | undefined) =>
sanitizeAmount(value, 0)
)(amount);
const normalizedRates = lift((inputs: {
rates: Record | undefined;
base: string;
}) => sanitizeRateMap(inputs.rates, inputs.base))({
rates,
base: baseCode,
});
const currencyCodes = lift((inputs: {
targets: string[] | undefined;
base: string;
normalizedRates: Record;
}) =>
ensureTargetList(
inputs.targets,
inputs.base,
Object.keys(inputs.normalizedRates),
)
)({
targets,
base: baseCode,
normalizedRates,
});
const conversions = lift((inputs: {
amount: number;
base: string;
rates: Record;
codes: string[];
}) =>
computeConversions({
amount: inputs.amount,
base: inputs.base,
rates: inputs.rates,
codes: inputs.codes,
})
)({
amount: normalizedAmount,
base: baseCode,
rates: normalizedRates,
codes: currencyCodes,
});
const conversionList = lift((inputs: {
codes: string[];
conversions: Record;
}) =>
inputs.codes.map((code) => {
const value = inputs.conversions[code] ?? 0;
return `${code} ${value.toFixed(2)}`;
})
)({
codes: currencyCodes,
conversions,
});
const currencyCount = lift((codes: string[]) => codes.length)(
currencyCodes,
);
const amountLabel = lift((value: number) => value.toFixed(2))(
normalizedAmount,
);
const summary =
str`${amountLabel} ${baseCode} across ${currencyCount} currencies`;
return {
amount,
baseCurrency,
rates,
targets,
baseCode,
normalizedAmount,
normalizedRates,
currencyCodes,
conversions,
conversionList,
currencyCount,
summary,
setAmount: setBaseAmount({ amount }),
updateRate: setConversionRate({
rates,
targets,
baseCurrency,
}),
};
},
);