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:
1 parent
261c6fa9ad
commit
bab6fc5f3e
2 files changed
+81
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user