From bab6fc5f3e3edbb477a91407d292931a1d627add Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 16:31:22 +0200 Subject: [PATCH] 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) --- src/domain/fx.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ src/domain/fx.ts | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/domain/fx.test.ts create mode 100644 src/domain/fx.ts diff --git a/src/domain/fx.test.ts b/src/domain/fx.test.ts new file mode 100644 index 0000000..612822a --- /dev/null +++ b/src/domain/fx.test.ts @@ -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); + }); +}); diff --git a/src/domain/fx.ts b/src/domain/fx.ts new file mode 100644 index 0000000..7b4ff24 --- /dev/null +++ b/src/domain/fx.ts @@ -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); +}