feat(domain): add currency conversion

Missing rates throw MissingRateError rather than silently treating the
value as base currency.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-01 16:31:22 +02:00
1 parent 261c6fa9ad
commit bab6fc5f3e
2 files changed
+81

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import { test, expect, describe } from "bun:test";
import { toBase, fromBase, MissingRateError } from "./fx.ts";
import type { FxRate } from "./types.ts";
const rates: FxRate[] = [
{ currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" },
{ currency: "USD", baseUnitsPerOne: 6.9, updatedAt: "2026-09-01", source: "manual" },
];
describe("toBase", () => {
test("passes base-currency values through untouched", () => {
expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates)).toBe(100);
});
test("converts using baseUnitsPerOne", () => {
expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates)).toBe(74.6);
});
test("rounds the conversion result", () => {
expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates)).toBe(22.98);
});
test("throws MissingRateError naming the currency", () => {
expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [])).toThrow(
MissingRateError,
);
try {
toBase({ amount: 1, currency: "USD" }, "DKK", []);
} catch (error) {
expect((error as MissingRateError).currency).toBe("USD");
}
});
});
describe("fromBase", () => {
test("passes base through untouched", () => {
expect(fromBase(100, "DKK", "DKK", rates)).toBe(100);
});
test("inverts the conversion", () => {
expect(fromBase(74.6, "EUR", "DKK", rates)).toBe(10);
});
});
+38
View File
@@ -0,0 +1,38 @@
import { roundMoney } from "./money.ts";
import type { Currency, FxRate, Money } from "./types.ts";
export class MissingRateError extends Error {
constructor(public readonly currency: Currency) {
super(`No exchange rate configured for ${currency}`);
this.name = "MissingRateError";
}
}
function rateFor(currency: Currency, rates: FxRate[]): number {
const found = rates.find((r) => r.currency === currency);
if (!found) throw new MissingRateError(currency);
return found.baseUnitsPerOne;
}
/** Converts a Money value into the base currency, rounded. */
export function toBase(
value: Money,
baseCurrency: Currency,
rates: FxRate[],
): number {
if (value.currency === baseCurrency) {
return roundMoney(value.amount, baseCurrency);
}
return roundMoney(value.amount * rateFor(value.currency, rates), baseCurrency);
}
/** Converts a base-currency amount into `target`, rounded. */
export function fromBase(
amount: number,
target: Currency,
baseCurrency: Currency,
rates: FxRate[],
): number {
if (target === baseCurrency) return roundMoney(amount, target);
return roundMoney(amount / rateFor(target, rates), target);
}