diff --git a/build.ts b/build.ts index ef10b75..e9e35ac 100644 --- a/build.ts +++ b/build.ts @@ -47,7 +47,7 @@ async function bakeFxRates(): Promise { }); if (!response.ok) throw new Error(`rate service returned ${response.status}`); - const rates = parseFxResponse(await response.json(), FX_BASE, buildDate); + const rates = parseFxResponse(await response.json(), FX_BASE, FX_TARGETS, buildDate); // A partial response would leave one currency unconvertible on a fresh // install, which is the whole problem this solves. Prefer the complete // constants over an incomplete live answer. diff --git a/src/domain/allocation.test.ts b/src/domain/allocation.test.ts index 6b7360b..19f5d08 100644 --- a/src/domain/allocation.test.ts +++ b/src/domain/allocation.test.ts @@ -10,6 +10,13 @@ import { import { foldBalances, figuresFor } from "./fold.ts"; import type { Dataset, FxRate, Post, Rule } from "./types.ts"; +/** Digits for the currencies these tests use; all real-world 2dp. */ +const TEST_CURRENCIES = [ + { code: "DKK", digits: 2, symbol: "kr" }, + { code: "USD", digits: 2, symbol: "$" }, + { code: "EUR", digits: 2, symbol: "\u20ac" }, +]; + 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" }, @@ -36,6 +43,7 @@ function post(overrides: Partial = {}): Post { function dataset(overrides: Partial = {}): Dataset { return { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 }, + currencies: TEST_CURRENCIES, fxRates: rates, posts: [post()], months: [ @@ -48,21 +56,21 @@ function dataset(overrides: Partial = {}): Dataset { describe("resolveRule", () => { test("a fixed rule in base currency is its amount", () => { - expect(resolveRule(fixed(400), 20000, "DKK", rates)).toBe(400); + expect(resolveRule(fixed(400), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(400); }); test("a fixed rule in a foreign currency converts to base", () => { expect( - resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates), + resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates, TEST_CURRENCIES), ).toBe(373); }); test("a percentage rule resolves against income", () => { - expect(resolveRule(pct(25), 20000, "DKK", rates)).toBe(5000); + expect(resolveRule(pct(25), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(5000); }); test("a percentage above 100 is permitted", () => { - expect(resolveRule(pct(150), 1000, "DKK", rates)).toBe(1500); + expect(resolveRule(pct(150), 1000, "DKK", rates, TEST_CURRENCIES)).toBe(1500); }); }); @@ -177,6 +185,7 @@ describe("the fold across a rule change", () => { // rollover carrying correctly across the boundary. const data: Dataset = { settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 }, + currencies: TEST_CURRENCIES, fxRates: [], posts: [ post({ diff --git a/src/domain/allocation.ts b/src/domain/allocation.ts index 0e2b186..f459999 100644 --- a/src/domain/allocation.ts +++ b/src/domain/allocation.ts @@ -1,8 +1,10 @@ import { toBase } from "./fx.ts"; import { roundMoney } from "./money.ts"; +import { digitsFor } from "./currencies.ts"; import { compareMonths } from "./months.ts"; import type { Currency, + CurrencyDef, Dataset, FxRate, Month, @@ -19,11 +21,12 @@ export function resolveRule( income: number, baseCurrency: Currency, rates: FxRate[], + currencies: readonly CurrencyDef[], ): number { if (rule.kind === "fixed") { - return toBase(rule.amount, baseCurrency, rates); + return toBase(rule.amount, baseCurrency, rates, currencies); } - return roundMoney((income * rule.percent) / 100, baseCurrency); + return roundMoney((income * rule.percent) / 100, digitsFor(currencies, baseCurrency)); } /** @@ -65,7 +68,12 @@ export function isOverridden(month: Month | undefined, postId: PostId): boolean export function incomeFor(dataset: Dataset, monthId: MonthId): number { const month = dataset.months.find((m) => m.id === monthId); if (!month) return 0; - return toBase(month.income, dataset.settings.baseCurrency, dataset.fxRates); + return toBase( + month.income, + dataset.settings.baseCurrency, + dataset.fxRates, + dataset.currencies, + ); } export function allocationFor( @@ -83,5 +91,6 @@ export function allocationFor( incomeFor(dataset, monthId), dataset.settings.baseCurrency, dataset.fxRates, + dataset.currencies, ); } diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index 70ea6d7..83b590e 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -8,6 +8,13 @@ import { import { roundMoney } from "./money.ts"; import type { Dataset, FxRate, Purchase } from "./types.ts"; +/** Digits for the currencies these tests use; all real-world 2dp. */ +const TEST_CURRENCIES = [ + { code: "DKK", digits: 2, symbol: "kr" }, + { code: "USD", digits: 2, symbol: "$" }, + { code: "EUR", digits: 2, symbol: "\u20ac" }, +]; + 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" }, @@ -28,7 +35,7 @@ function purchase(overrides: Partial = {}): Purchase { describe("splitPartsOfTotal", () => { test("a single split takes the whole total", () => { - expect(splitPartsOfTotal(purchase())).toEqual([200]); + expect(splitPartsOfTotal(purchase(), 2)).toEqual([200]); }); test("percent mode divides proportionally", () => { @@ -39,7 +46,8 @@ describe("splitPartsOfTotal", () => { { postId: "events", value: 40, absorbsRemainder: false }, ], }), - ); + 2, +); expect(parts).toEqual([120, 80]); }); @@ -52,7 +60,8 @@ describe("splitPartsOfTotal", () => { { postId: "events", value: 50, absorbsRemainder: true }, ], }), - ); + 2, +); expect(parts).toEqual([120, 80]); }); @@ -66,8 +75,9 @@ describe("splitPartsOfTotal", () => { { postId: "c", value: 33.34, absorbsRemainder: true }, ], }), - ); - expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(100); + 2, +); + expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(100); }); }); @@ -121,14 +131,14 @@ describe("sliceAmountForMonth", () => { describe("chargesForPurchaseInMonth", () => { test("a simple purchase charges one post", () => { - expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates)).toEqual([ + expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([ { postId: "food", amount: 200 }, ]); }); test("converts a foreign-currency purchase into base", () => { const eur = purchase({ total: { amount: 10, currency: "EUR" } }); - expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates)).toEqual([ + expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([ { postId: "food", amount: 74.6 }, ]); }); @@ -147,11 +157,11 @@ describe("chargesForPurchaseInMonth", () => { ], }, }); - expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates)).toEqual([ + expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([ { postId: "games", amount: 700 }, { postId: "events", amount: 300 }, ]); - expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates)).toEqual([ + expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates, TEST_CURRENCIES)).toEqual([ { postId: "games", amount: 1400 }, { postId: "events", amount: 600 }, ]); @@ -169,14 +179,14 @@ describe("chargesForPurchaseInMonth", () => { slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }], }, }); - expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates)).toEqual([ + expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([ { postId: "games", amount: 400 }, { postId: "events", amount: 200 }, ]); }); test("charges in a month the purchase does not touch are empty", () => { - expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates)).toEqual([]); + expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates, TEST_CURRENCIES)).toEqual([]); }); test("slice charges always sum to the slice total", () => { @@ -188,8 +198,8 @@ describe("chargesForPurchaseInMonth", () => { { postId: "c", value: 1, absorbsRemainder: true }, ], }); - const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates); - expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0))).toBe(100); + const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates, TEST_CURRENCIES); + expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0), 2)).toBe(100); }); }); @@ -197,6 +207,7 @@ describe("chargesForMonth", () => { test("sums charges per post across all purchases", () => { const data: Dataset = { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + currencies: TEST_CURRENCIES, fxRates: rates, posts: [], months: [], @@ -214,6 +225,7 @@ describe("chargesForMonth", () => { test("a month with no purchases yields an empty map", () => { const data: Dataset = { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + currencies: TEST_CURRENCIES, fxRates: rates, posts: [], months: [], diff --git a/src/domain/charges.ts b/src/domain/charges.ts index 4d726ed..d6a8252 100644 --- a/src/domain/charges.ts +++ b/src/domain/charges.ts @@ -1,8 +1,10 @@ import { toBase } from "./fx.ts"; +import { digitsFor } from "./currencies.ts"; import { distributeByAmount, distributeByWeight, roundMoney } from "./money.ts"; import { compareMonths, monthOf } from "./months.ts"; import type { Currency, + CurrencyDef, Dataset, FxRate, Money, @@ -31,14 +33,13 @@ export function remainderIndexOf(purchase: Purchase): number { * How the whole purchase divides across its posts, in the purchase's own * currency. Parts sum exactly to the total. */ -export function splitPartsOfTotal(purchase: Purchase): number[] { +export function splitPartsOfTotal(purchase: Purchase, digits: number): number[] { const index = remainderIndexOf(purchase); const values = purchase.splits.map((s) => s.value); const total = purchase.total.amount; - const currency = purchase.total.currency; return purchase.splitMode === "percent" - ? distributeByWeight(total, values, index, currency) - : distributeByAmount(total, values, index, currency); + ? distributeByWeight(total, values, index, digits) + : distributeByAmount(total, values, index, digits); } /** @@ -72,14 +73,18 @@ export function chargesForPurchaseInMonth( monthId: MonthId, baseCurrency: Currency, rates: FxRate[], + currencies: readonly CurrencyDef[], ): Charge[] { const slice = sliceAmountForMonth(purchase, monthId); if (!slice) return []; - const sliceBase = toBase(slice, baseCurrency, rates); + const sliceBase = toBase(slice, baseCurrency, rates, currencies); const index = remainderIndexOf(purchase); - const weights = splitPartsOfTotal(purchase); - const parts = distributeByWeight(sliceBase, weights, index, baseCurrency); + // Split proportions are computed in the PURCHASE's own currency, then the + // month's slice is distributed to those proportions in the BASE currency — + // so the two roundings use different digits. + const weights = splitPartsOfTotal(purchase, digitsFor(currencies, purchase.total.currency)); + const parts = distributeByWeight(sliceBase, weights, index, digitsFor(currencies, baseCurrency)); return purchase.splits.map((split, i) => ({ postId: split.postId, @@ -93,18 +98,20 @@ export function chargesForMonth( monthId: MonthId, ): Map { const totals = new Map(); + const baseDigits = digitsFor(dataset.currencies, dataset.settings.baseCurrency); for (const purchase of dataset.purchases) { const charges = chargesForPurchaseInMonth( purchase, monthId, dataset.settings.baseCurrency, dataset.fxRates, + dataset.currencies, ); for (const charge of charges) { const previous = totals.get(charge.postId) ?? 0; totals.set( charge.postId, - roundMoney(previous + charge.amount, dataset.settings.baseCurrency), + roundMoney(previous + charge.amount, baseDigits), ); } } diff --git a/src/domain/currencies.test.ts b/src/domain/currencies.test.ts new file mode 100644 index 0000000..bb95eaf --- /dev/null +++ b/src/domain/currencies.test.ts @@ -0,0 +1,43 @@ +import { test, expect, describe } from "bun:test"; +import { digitsFor, currencyCodes, findCurrency, normalizeCurrencyCode } from "./currencies.ts"; +import type { CurrencyDef } from "./types.ts"; + +const TABLE: CurrencyDef[] = [ + { code: "DKK", digits: 2, symbol: "kr" }, + { code: "JPY", digits: 0, symbol: "¥" }, + { code: "KWD", digits: 3 }, +]; + +describe("digitsFor", () => { + test("reads the digits the owner recorded, not an assumed 2", () => { + expect(digitsFor(TABLE, "DKK")).toBe(2); + expect(digitsFor(TABLE, "JPY")).toBe(0); + expect(digitsFor(TABLE, "KWD")).toBe(3); + }); + + /** + * Deep inside the fold a throw would take the whole balance view down over + * a data problem the import validator already rejects at the boundary. Two + * places is right for almost every currency in existence. + */ + test("an unknown currency falls back to two places rather than throwing", () => { + expect(digitsFor(TABLE, "XYZ")).toBe(2); + expect(digitsFor([], "DKK")).toBe(2); + }); +}); + +describe("normalizeCurrencyCode", () => { + test("codes are identity, so case and padding cannot fork one currency in two", () => { + expect(normalizeCurrencyCode(" usd ")).toBe("USD"); + expect(normalizeCurrencyCode("Jpy")).toBe("JPY"); + }); +}); + +test("currencyCodes keeps the owner's order", () => { + expect(currencyCodes(TABLE)).toEqual(["DKK", "JPY", "KWD"]); +}); + +test("findCurrency returns the definition, or nothing", () => { + expect(findCurrency(TABLE, "JPY")?.symbol).toBe("¥"); + expect(findCurrency(TABLE, "XYZ")).toBeUndefined(); +}); diff --git a/src/domain/currencies.ts b/src/domain/currencies.ts new file mode 100644 index 0000000..7bf2ddc --- /dev/null +++ b/src/domain/currencies.ts @@ -0,0 +1,92 @@ +import { + DEFAULT_CURRENCY_DIGITS, + type Currency, + type CurrencyDef, + type Dataset, +} from "./types.ts"; + +/** + * How many decimals a currency rounds to. + * + * Digits used to be a module-level constant table, which made "add a + * currency" a code change. They now live in the dataset, so every rounding + * site has to be told which digits to use — that threading is deliberate, and + * the reason `roundMoney` takes a number rather than a code. + * + * An undefined currency falls back to two places rather than throwing. A + * missing definition is a data problem the import validator rejects at the + * boundary; deep inside the fold, two decimals is right for almost every + * currency and a throw would take the whole balance view down. + */ +export function digitsFor( + currencies: readonly CurrencyDef[], + code: Currency, +): number { + const found = currencies.find((currency) => currency.code === code); + return found ? found.digits : DEFAULT_CURRENCY_DIGITS; +} + +/** The codes, in the order the owner arranged them. */ +export function currencyCodes(currencies: readonly CurrencyDef[]): Currency[] { + return currencies.map((currency) => currency.code); +} + +export function findCurrency( + currencies: readonly CurrencyDef[], + code: Currency, +): CurrencyDef | undefined { + return currencies.find((currency) => currency.code === code); +} + +/** + * Uppercased and trimmed. Codes are identity here — they key the FX table and + * every stored Money — so "usd" and "USD" must never become two currencies. + */ +export function normalizeCurrencyCode(raw: string): Currency { + return raw.trim().toUpperCase(); +} + +/** + * Everywhere `code` is referenced by stored data, described for a human. + * + * The same reasoning as posts being archived rather than deleted: stored Money + * carries the code, so removing a currency in use would leave amounts pointing + * at a definition that no longer exists — and `parseDatasetJson` would then + * refuse the owner's own exported file. Returning WHERE it is used lets the UI + * explain the refusal instead of just denying it. + */ +export function currencyUsage(dataset: Dataset, code: Currency): string[] { + const used: string[] = []; + + if (dataset.settings.baseCurrency === code) used.push("the base currency"); + + for (const post of dataset.posts) { + if (post.currency === code) used.push(`post "${post.name}"`); + for (const version of post.rules) { + if (version.rule.kind === "fixed" && version.rule.amount.currency === code) { + used.push(`post "${post.name}" rule from ${version.from}`); + } + } + } + + for (const month of dataset.months) { + if (month.income.currency === code) used.push(`month ${month.id}`); + for (const [postId, rule] of Object.entries(month.ruleOverrides)) { + if (rule.kind === "fixed" && rule.amount.currency === code) { + const name = dataset.posts.find((p) => p.id === postId)?.name ?? postId; + used.push(`${month.id} override for "${name}"`); + } + } + } + + for (const purchase of dataset.purchases) { + if (purchase.total.currency === code) used.push(`purchase "${purchase.description}"`); + for (const slice of purchase.schedule?.slices ?? []) { + if (slice.amount.currency === code) { + used.push(`purchase "${purchase.description}" slice ${slice.month}`); + } + } + } + + return [...new Set(used)]; +} diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts new file mode 100644 index 0000000..b5cb1b6 --- /dev/null +++ b/src/domain/currencyDigits.test.ts @@ -0,0 +1,119 @@ +import { test, expect, describe } from "bun:test"; +import { roundMoney, distributeByWeight, distributeByAmount } from "./money.ts"; +import { toBase, fromBase } from "./fx.ts"; +import { equalSlices, sliceTotal } from "./plans.ts"; +import { resolveRule } from "./allocation.ts"; +import { foldBalances, figuresFor } from "./fold.ts"; +import { monthView } from "./views.ts"; +import type { CurrencyDef, Dataset } from "./types.ts"; + +/** + * Every currency the app shipped with has two decimal places, so code that + * hardcodes 2 is indistinguishable from code that reads the dataset. Now that + * the owner can define currencies, that stops being true — and a zero-decimal + * currency is the only thing that can prove digits actually flow from the + * dataset to each rounding site rather than being assumed. + * + * JPY is the real-world case: yen have no minor unit, so ¥3333.3 is not a + * number that can exist. + */ +const JPY: CurrencyDef = { code: "JPY", digits: 0, symbol: "¥" }; +const USD: CurrencyDef = { code: "USD", digits: 2, symbol: "$" }; +const CURRENCIES = [JPY, USD]; + +describe("rounding takes its digits from the dataset", () => { + test("roundMoney rounds to the digits it is given", () => { + expect(roundMoney(3333.3, 0)).toBe(3333); + expect(roundMoney(3333.335, 2)).toBe(3333.34); + // 1.234, not 1.235: the double nearest 1.2345 is slightly BELOW it, and + // toFixed rounds the double's actual value. That is the documented + // trade-off in money.ts — predictable, if occasionally surprising. + expect(roundMoney(1.2345, 3)).toBe(1.234); + }); + + test("a percentage allocation in a zero-decimal currency yields whole units", () => { + const rate = resolveRule( + { kind: "percentOfIncome", percent: 33.333 }, + 10_000, + "JPY", + [], + CURRENCIES, + ); + expect(rate).toBe(3333); + expect(Number.isInteger(rate)).toBe(true); + }); + + test("an FX conversion into a zero-decimal base yields whole units", () => { + const rates = [ + { currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" as const }, + ]; + expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, CURRENCIES)).toBe(1512); + // And back out: 1512 / 151.234567 = 9.9977..., rounded to USD's TWO + // decimals, so 10 — not the three-decimal 9.998. + expect(fromBase(1512, "USD", "JPY", rates, CURRENCIES)).toBe(10); + }); + + test("a split distributed in a zero-decimal currency still sums exactly", () => { + const parts = distributeByWeight(1000, [1, 1, 1], 2, 0); + expect(parts).toEqual([333, 333, 334]); + expect(parts.reduce((a, b) => a + b, 0)).toBe(1000); + expect(parts.every(Number.isInteger)).toBe(true); + }); + + test("fixed amounts distributed in a zero-decimal currency still sum exactly", () => { + const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, 0); + expect(parts.reduce((a, b) => a + b, 0)).toBe(1000); + expect(parts.every(Number.isInteger)).toBe(true); + }); + + test("a finance plan divides into whole units and sums exactly", () => { + const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, 0); + expect(slices.map((s) => s.amount.amount)).toEqual([333, 333, 334]); + expect(sliceTotal(slices, 0)).toBe(1000); + }); +}); + +describe("the fold in a zero-decimal currency", () => { + function dataset(): Dataset { + return { + settings: { baseCurrency: "JPY", foldStartMonth: "2026-01", schemaVersion: 3 }, + currencies: CURRENCIES, + fxRates: [], + posts: [ + { + id: "food", + name: "Food", + order: 0, + archived: false, + currency: "JPY", + rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 33.333 } }], + }, + ], + months: [ + { id: "2026-01", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} }, + { id: "2026-02", income: { amount: 10_000, currency: "JPY" }, ruleOverrides: {} }, + ], + purchases: [], + }; + } + + test("no figure the fold produces has a fractional yen in it", () => { + const fold = foldBalances(dataset(), "2026-02"); + for (const month of ["2026-01", "2026-02"]) { + const figures = figuresFor(fold, "food", month); + for (const [name, value] of Object.entries(figures)) { + expect(Number.isInteger(value)).toBe(true); + expect(`${month} ${name} = ${value}`).toBe(`${month} ${name} = ${Math.round(value)}`); + } + } + expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333); + expect(figuresFor(fold, "food", "2026-02").carriedIn).toBe(3333); + }); + + test("the month view's totals are whole units too", () => { + const view = monthView(dataset(), "2026-01"); + expect(Number.isInteger(view.totalAllocation)).toBe(true); + expect(Number.isInteger(view.unallocated)).toBe(true); + expect(view.unallocated).toBe(10_000 - 3333); + }); +}); diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index 73232e5..dc2933f 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test"; import { foldBalances, figuresFor } from "./fold.ts"; import type { Dataset, Post, Purchase } from "./types.ts"; +/** Digits for the currencies these tests use; all real-world 2dp. */ +const TEST_CURRENCIES = [ + { code: "DKK", digits: 2, symbol: "kr" }, + { code: "USD", digits: 2, symbol: "$" }, + { code: "EUR", digits: 2, symbol: "\u20ac" }, +]; + function post(id: string, fixed: number, archived = false): Post { return { id, @@ -28,6 +35,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha function dataset(posts: Post[], purchases: Purchase[]): Dataset { return { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + currencies: TEST_CURRENCIES, fxRates: [], posts, months: [ diff --git a/src/domain/fold.ts b/src/domain/fold.ts index 9135459..042b913 100644 --- a/src/domain/fold.ts +++ b/src/domain/fold.ts @@ -1,4 +1,5 @@ import { allocationFor } from "./allocation.ts"; +import { digitsFor } from "./currencies.ts"; import { chargesForMonth } from "./charges.ts"; import { roundMoney } from "./money.ts"; import { compareMonths, monthRange } from "./months.ts"; @@ -36,6 +37,7 @@ export type Fold = Map>; */ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold { const { foldStartMonth, baseCurrency } = dataset.settings; + const baseDigits = digitsFor(dataset.currencies, baseCurrency); const fold: Fold = new Map(); if (compareMonths(upToMonth, foldStartMonth) < 0) return fold; @@ -50,7 +52,7 @@ export function foldBalances(dataset: Dataset, upToMonth: MonthId): Fold { const carriedIn = carried.get(post.id) ?? 0; const allocation = allocationFor(dataset, post.id, monthId); const spent = charges.get(post.id) ?? 0; - const remaining = roundMoney(carriedIn + allocation - spent, baseCurrency); + const remaining = roundMoney(carriedIn + allocation - spent, baseDigits); monthFigures.set(post.id, { carriedIn, diff --git a/src/domain/fx.test.ts b/src/domain/fx.test.ts index 612822a..9820dc7 100644 --- a/src/domain/fx.test.ts +++ b/src/domain/fx.test.ts @@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test"; import { toBase, fromBase, MissingRateError } from "./fx.ts"; import type { FxRate } from "./types.ts"; +/** Digits for the currencies these tests use; all real-world 2dp. */ +const TEST_CURRENCIES = [ + { code: "DKK", digits: 2, symbol: "kr" }, + { code: "USD", digits: 2, symbol: "$" }, + { code: "EUR", digits: 2, symbol: "\u20ac" }, +]; + 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" }, @@ -9,23 +16,23 @@ const rates: FxRate[] = [ describe("toBase", () => { test("passes base-currency values through untouched", () => { - expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates)).toBe(100); + expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates, TEST_CURRENCIES)).toBe(100); }); test("converts using baseUnitsPerOne", () => { - expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates)).toBe(74.6); + expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates, TEST_CURRENCIES)).toBe(74.6); }); test("rounds the conversion result", () => { - expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates)).toBe(22.98); + expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates, TEST_CURRENCIES)).toBe(22.98); }); test("throws MissingRateError naming the currency", () => { - expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [])).toThrow( + expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES)).toThrow( MissingRateError, ); try { - toBase({ amount: 1, currency: "USD" }, "DKK", []); + toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES); } catch (error) { expect((error as MissingRateError).currency).toBe("USD"); } @@ -34,10 +41,10 @@ describe("toBase", () => { describe("fromBase", () => { test("passes base through untouched", () => { - expect(fromBase(100, "DKK", "DKK", rates)).toBe(100); + expect(fromBase(100, "DKK", "DKK", rates, TEST_CURRENCIES)).toBe(100); }); test("inverts the conversion", () => { - expect(fromBase(74.6, "EUR", "DKK", rates)).toBe(10); + expect(fromBase(74.6, "EUR", "DKK", rates, TEST_CURRENCIES)).toBe(10); }); }); diff --git a/src/domain/fx.ts b/src/domain/fx.ts index 7b4ff24..edc1906 100644 --- a/src/domain/fx.ts +++ b/src/domain/fx.ts @@ -1,5 +1,6 @@ import { roundMoney } from "./money.ts"; -import type { Currency, FxRate, Money } from "./types.ts"; +import { digitsFor } from "./currencies.ts"; +import type { Currency, CurrencyDef, FxRate, Money } from "./types.ts"; export class MissingRateError extends Error { constructor(public readonly currency: Currency) { @@ -19,11 +20,13 @@ export function toBase( value: Money, baseCurrency: Currency, rates: FxRate[], + currencies: readonly CurrencyDef[], ): number { + const digits = digitsFor(currencies, baseCurrency); if (value.currency === baseCurrency) { - return roundMoney(value.amount, baseCurrency); + return roundMoney(value.amount, digits); } - return roundMoney(value.amount * rateFor(value.currency, rates), baseCurrency); + return roundMoney(value.amount * rateFor(value.currency, rates), digits); } /** Converts a base-currency amount into `target`, rounded. */ @@ -32,7 +35,9 @@ export function fromBase( target: Currency, baseCurrency: Currency, rates: FxRate[], + currencies: readonly CurrencyDef[], ): number { - if (target === baseCurrency) return roundMoney(amount, target); - return roundMoney(amount / rateFor(target, rates), target); + const digits = digitsFor(currencies, target); + if (target === baseCurrency) return roundMoney(amount, digits); + return roundMoney(amount / rateFor(target, rates), digits); } diff --git a/src/domain/money.test.ts b/src/domain/money.test.ts index 195f0b6..0b176c5 100644 --- a/src/domain/money.test.ts +++ b/src/domain/money.test.ts @@ -7,62 +7,62 @@ import { describe("roundMoney", () => { test("rounds to 2 decimal places", () => { - expect(roundMoney(1.234)).toBe(1.23); - expect(roundMoney(1.235)).toBe(1.24); - expect(roundMoney(0.1 + 0.2)).toBe(0.3); + expect(roundMoney(1.234, 2)).toBe(1.23); + expect(roundMoney(1.235, 2)).toBe(1.24); + expect(roundMoney(0.1 + 0.2, 2)).toBe(0.3); }); test("handles negatives symmetrically", () => { - expect(roundMoney(-1.235)).toBe(-1.24); + expect(roundMoney(-1.235, 2)).toBe(-1.24); }); }); describe("distributeByWeight", () => { test("splits proportionally", () => { - expect(distributeByWeight(200, [60, 40], 0)).toEqual([120, 80]); + expect(distributeByWeight(200, [60, 40], 0, 2)).toEqual([120, 80]); }); test("thirds sum exactly to the total", () => { - const parts = distributeByWeight(100, [1, 1, 1], 2); + const parts = distributeByWeight(100, [1, 1, 1], 2, 2); expect(parts).toEqual([33.33, 33.33, 33.34]); - expect(roundMoney(parts[0]! + parts[1]! + parts[2]!)).toBe(100); + expect(roundMoney(parts[0]! + parts[1]! + parts[2]!, 2)).toBe(100); }); test("the remainder index absorbs the rounding drift", () => { - const parts = distributeByWeight(100, [1, 1, 1], 0); + const parts = distributeByWeight(100, [1, 1, 1], 0, 2); expect(parts).toEqual([33.34, 33.33, 33.33]); }); test("a single split takes the whole total", () => { - expect(distributeByWeight(99.99, [100], 0)).toEqual([99.99]); + expect(distributeByWeight(99.99, [100], 0, 2)).toEqual([99.99]); }); test("weights that do not sum to 100 are still proportional", () => { - expect(distributeByWeight(100, [1, 3], 1)).toEqual([25, 75]); + expect(distributeByWeight(100, [1, 3], 1, 2)).toEqual([25, 75]); }); test("all-zero weights put everything on the remainder rather than throwing", () => { // Reachable from a zero-value purchase; must not divide by zero. - expect(distributeByWeight(100, [0, 0], 1)).toEqual([0, 100]); - expect(distributeByWeight(0, [0, 0], 0)).toEqual([0, 0]); + expect(distributeByWeight(100, [0, 0], 1, 2)).toEqual([0, 100]); + expect(distributeByWeight(0, [0, 0], 0, 2)).toEqual([0, 0]); }); }); describe("distributeByAmount", () => { test("uses the given amounts as-is", () => { - expect(distributeByAmount(200, [120, 80], 1)).toEqual([120, 80]); + expect(distributeByAmount(200, [120, 80], 1, 2)).toEqual([120, 80]); }); test("the remainder index absorbs a shortfall", () => { - expect(distributeByAmount(200, [120, 50], 1)).toEqual([120, 80]); + expect(distributeByAmount(200, [120, 50], 1, 2)).toEqual([120, 80]); }); test("the remainder index absorbs an excess, even going negative", () => { - expect(distributeByAmount(100, [120, 50], 1)).toEqual([120, -20]); + expect(distributeByAmount(100, [120, 50], 1, 2)).toEqual([120, -20]); }); test("parts always sum exactly to the total", () => { - const parts = distributeByAmount(10, [3.33, 3.33, 0], 2); - expect(roundMoney(parts.reduce((a, b) => a + b, 0))).toBe(10); + const parts = distributeByAmount(10, [3.33, 3.33, 0], 2, 2); + expect(roundMoney(parts.reduce((a, b) => a + b, 0), 2)).toBe(10); }); }); diff --git a/src/domain/money.ts b/src/domain/money.ts index fd806df..8b36361 100644 --- a/src/domain/money.ts +++ b/src/domain/money.ts @@ -1,13 +1,21 @@ -import { CURRENCY_DIGITS, type Currency } from "./types.ts"; +/** + * Money is a float. Two rules make that safe, and both are about WHERE + * rounding happens rather than how. + * + * Digits are passed in, never looked up from a table here. They live in the + * dataset because the owner can define currencies, so every call site has to + * resolve them with `digitsFor` — see src/domain/currencies.ts. That is + * deliberate friction: a default of 2 would silently mis-round every + * zero-decimal currency and no existing test would notice. + */ /** - * Rounds a float to its currency's minor unit. `toFixed` rounds the double's - * actual decimal value, which is the most predictable option available for - * binary floats. Call this after every division and FX conversion, and before + * Rounds to a currency's minor unit. `toFixed` rounds the double's actual + * decimal value, which is the most predictable option available for binary + * floats. Call this after every division and FX conversion, and before * persisting. */ -export function roundMoney(amount: number, currency: Currency = "DKK"): number { - const digits = CURRENCY_DIGITS[currency]; +export function roundMoney(amount: number, digits: number): number { return Number(amount.toFixed(digits)); } @@ -15,7 +23,7 @@ function withRemainder( total: number, parts: number[], remainderIndex: number, - currency: Currency, + digits: number, ): number[] { const result = [...parts]; let othersSum = 0; @@ -24,7 +32,7 @@ function withRemainder( } // Never round this independently — subtraction is what makes the parts sum // exactly to the total. - result[remainderIndex] = roundMoney(total - othersSum, currency); + result[remainderIndex] = roundMoney(total - othersSum, digits); return result; } @@ -33,7 +41,7 @@ export function distributeByWeight( total: number, weights: number[], remainderIndex: number, - currency: Currency = "DKK", + digits: number, ): number[] { const weightSum = weights.reduce((a, b) => a + b, 0); // All-zero weights would divide by zero. A zero-total purchase is legal (and @@ -42,9 +50,9 @@ export function distributeByWeight( const parts = weights.map((w, i) => i === remainderIndex || weightSum === 0 ? 0 - : roundMoney((total * w) / weightSum, currency), + : roundMoney((total * w) / weightSum, digits), ); - return withRemainder(total, parts, remainderIndex, currency); + return withRemainder(total, parts, remainderIndex, digits); } /** Uses `amounts` as-is; `remainderIndex` absorbs any shortfall or excess. */ @@ -52,10 +60,10 @@ export function distributeByAmount( total: number, amounts: number[], remainderIndex: number, - currency: Currency = "DKK", + digits: number, ): number[] { const parts = amounts.map((a, i) => - i === remainderIndex ? 0 : roundMoney(a, currency), + i === remainderIndex ? 0 : roundMoney(a, digits), ); - return withRemainder(total, parts, remainderIndex, currency); + return withRemainder(total, parts, remainderIndex, digits); } diff --git a/src/domain/plans.test.ts b/src/domain/plans.test.ts index 78ae5a8..4931062 100644 --- a/src/domain/plans.test.ts +++ b/src/domain/plans.test.ts @@ -1,64 +1,52 @@ import { test, expect } from "bun:test"; import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts"; -import { CURRENCY_DIGITS } from "./types.ts"; /** - * Every currency this app supports today happens to have 2 decimal places, so - * code that hardcodes 2 is indistinguishable from code that reads - * CURRENCY_DIGITS. This forces the table for the duration of one test, which - * is the only way to prove the table is actually consulted without making the - * product decision to add a non-2dp currency. + * Digits are passed in rather than looked up from a table, so a currency + * with a different minor unit is expressed directly instead of by + * monkey-patching a constant, which is what this file used to have to do. */ -function withDigits(currency: "DKK" | "USD" | "EUR", digits: number, body: () => void) { - const original = CURRENCY_DIGITS[currency]; - CURRENCY_DIGITS[currency] = digits; - try { - body(); - } finally { - CURRENCY_DIGITS[currency] = original; - } -} +const DKK = 2; test("equal slices divide the total across consecutive months", () => { - const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6); + const slices = equalSlices({ amount: 3000, currency: "DKK" }, "2026-10", 6, DKK); expect(slices).toHaveLength(6); expect(slices[0]).toEqual({ month: "2026-10", amount: { amount: 500, currency: "DKK" } }); expect(slices[5]!.month).toBe("2027-03"); }); test("uneven totals still sum exactly, the last slice absorbing the drift", () => { - const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3); + const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 3, DKK); expect(slices.map((s) => s.amount.amount)).toEqual([33.33, 33.33, 33.34]); - expect(sliceTotal(slices)).toBe(100); + expect(sliceTotal(slices, DKK)).toBe(100); }); test("a single slice takes the whole total", () => { - const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1); + const slices = equalSlices({ amount: 99.99, currency: "DKK" }, "2026-01", 1, DKK); expect(slices).toEqual([ { month: "2026-01", amount: { amount: 99.99, currency: "DKK" } }, ]); }); test("a count below one is rejected", () => { - expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0)).toThrow( + expect(() => equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 0, DKK)).toThrow( /at least one month/i, ); }); test("slicesBalance reports the shortfall the editor must show", () => { - const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2); - expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(0); + const slices = equalSlices({ amount: 100, currency: "DKK" }, "2026-01", 2, DKK); + expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(0); slices[0]!.amount.amount = 30; - expect(slicesBalance({ amount: 100, currency: "DKK" }, slices)).toBe(20); + expect(slicesBalance({ amount: 100, currency: "DKK" }, slices, DKK)).toBe(20); }); -test("sliceTotal rounds to the slices' own currency, not always to 2 places", () => { - withDigits("EUR", 0, () => { - const slices = [ - { month: "2026-01" as const, amount: { amount: 10.4, currency: "EUR" as const } }, - { month: "2026-02" as const, amount: { amount: 10.4, currency: "EUR" as const } }, - ]; - expect(sliceTotal(slices)).toBe(21); - }); +test("sliceTotal rounds to the digits it is given, not always to 2 places", () => { + const slices = [ + { month: "2026-01", amount: { amount: 10.4, currency: "EUR" } }, + { month: "2026-02", amount: { amount: 10.4, currency: "EUR" } }, + ]; + expect(sliceTotal(slices, 0)).toBe(21); + expect(sliceTotal(slices, 2)).toBe(20.8); }); diff --git a/src/domain/plans.ts b/src/domain/plans.ts index 3e363c3..ff3d5bb 100644 --- a/src/domain/plans.ts +++ b/src/domain/plans.ts @@ -7,17 +7,13 @@ export function equalSlices( total: Money, startMonth: MonthId, count: number, + digits: number, ): ScheduleSlice[] { if (!Number.isInteger(count) || count < 1) { throw new Error("A finance plan needs at least one month"); } const weights = new Array(count).fill(1); - const amounts = distributeByWeight( - total.amount, - weights, - count - 1, - total.currency, - ); + const amounts = distributeByWeight(total.amount, weights, count - 1, digits); return amounts.map((amount, i) => ({ month: addMonths(startMonth, i), amount: { amount, currency: total.currency }, @@ -29,12 +25,16 @@ export function equalSlices( * names the currency to round in. Rounding to a hardcoded 2 places would be * wrong for any currency whose minor unit is not hundredths. */ -export function sliceTotal(slices: ScheduleSlice[]): number { +export function sliceTotal(slices: ScheduleSlice[], digits: number): number { const sum = slices.reduce((total, s) => total + s.amount.amount, 0); - return roundMoney(sum, slices[0]?.amount.currency); + return roundMoney(sum, digits); } /** total - sum(slices). Positive means the plan is short of the total. */ -export function slicesBalance(total: Money, slices: ScheduleSlice[]): number { - return roundMoney(total.amount - sliceTotal(slices), total.currency); +export function slicesBalance( + total: Money, + slices: ScheduleSlice[], + digits: number, +): number { + return roundMoney(total.amount - sliceTotal(slices, digits), digits); } diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 4eadc1d..36915e5 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,6 +1,7 @@ -import type { Dataset, FxRate, MonthId, Post } from "./types.ts"; +import { SEED_CURRENCIES } from "./types.ts"; +import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 2; +export const SCHEMA_VERSION = 3; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; @@ -17,6 +18,7 @@ export function newId(): string { export function createSeedDataset( startMonth: MonthId, fxRates: readonly FxRate[] = [], + currencies: readonly CurrencyDef[] = SEED_CURRENCIES, ): Dataset { const posts: Post[] = SEED_POST_NAMES.map((name, order) => ({ id: newId(), @@ -36,7 +38,8 @@ export function createSeedDataset( schemaVersion: SCHEMA_VERSION, }, // Copied, so a caller passing shared constants cannot have them edited - // out from under it the first time the user changes a rate. + // out from under it the first time the user changes a rate or a currency. + currencies: currencies.map((currency) => ({ ...currency })), fxRates: fxRates.map((rate) => ({ ...rate })), posts, months: [ diff --git a/src/domain/types.test.ts b/src/domain/types.test.ts index e5f3641..e91db56 100644 --- a/src/domain/types.test.ts +++ b/src/domain/types.test.ts @@ -1,12 +1,24 @@ import { test, expect } from "bun:test"; -import { CURRENCIES, CURRENCY_DIGITS } from "./types.ts"; +import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts"; -test("supported currencies are exactly DKK, USD, EUR", () => { - expect(CURRENCIES).toEqual(["DKK", "USD", "EUR"]); +/** + * This file used to assert "the supported currencies are exactly DKK, USD, + * EUR". That is no longer a property of the code: the owner defines + * currencies, so the dataset is the authority and there is nothing global to + * assert. What remains true is what a NEW dataset starts with. + */ +test("a new dataset starts with the three currencies the app shipped with", () => { + expect(SEED_CURRENCIES.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); }); -test("every supported currency has 2 decimal digits", () => { - for (const c of CURRENCIES) { - expect(CURRENCY_DIGITS[c]).toBe(2); +test("each seeded currency carries its decimals and a symbol to type", () => { + for (const currency of SEED_CURRENCIES) { + expect(currency.digits).toBe(2); + expect(currency.symbol).toBeTruthy(); + expect(currency.name).toBeTruthy(); } }); + +test("the fallback for an undefined currency is two places", () => { + expect(DEFAULT_CURRENCY_DIGITS).toBe(2); +}); diff --git a/src/domain/types.ts b/src/domain/types.ts index c929e42..8d0649e 100644 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -1,11 +1,37 @@ -export const CURRENCIES = ["DKK", "USD", "EUR"] as const; -export type Currency = (typeof CURRENCIES)[number]; +/** + * A currency CODE, uppercase, normally ISO 4217 ("DKK", "JPY"). Open rather + * than a closed union because the owner can define their own currencies: + * a union would make that a code change. + * + * The safety a union gave up is replaced by validation at the boundaries — + * `parseDatasetJson` rejects a code no currency defines, and the UI only ever + * offers codes from the dataset's own table. + */ +export type Currency = string; -export const CURRENCY_DIGITS: Record = { - DKK: 2, - USD: 2, - EUR: 2, -}; +/** What the owner has told the app about one currency. */ +export interface CurrencyDef { + code: Currency; + /** Decimal places in its minor unit: 2 for DKK, 0 for JPY, 3 for KWD. */ + digits: number; + /** "kr", "$", "€". Optional — a currency can be typed by code alone. */ + symbol?: string; + /** "Danish krone". Shown in Settings; never used for identity. */ + name?: string; +} + +/** + * The currencies a brand-new dataset starts with. NOT the set of valid + * currencies — that lives in the dataset, because the owner can add more. + */ +export const SEED_CURRENCIES: readonly CurrencyDef[] = [ + { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, + { code: "USD", digits: 2, symbol: "$", name: "US dollar" }, + { code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" }, +]; + +/** Used when a currency has no definition. Two places is right almost always. */ +export const DEFAULT_CURRENCY_DIGITS = 2; /** A float amount paired with its currency. See CLAUDE.md for rounding rules. */ export interface Money { @@ -106,6 +132,12 @@ export interface Settings { export interface Dataset { settings: Settings; + /** + * Every currency this dataset knows, including the base. The owner can add + * to it, so it — not a compile-time union — is the authority on what codes + * are valid, what their symbols are, and how many decimals they round to. + */ + currencies: CurrencyDef[]; fxRates: FxRate[]; posts: Post[]; months: Month[]; diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index e28c2f0..bab9337 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -2,6 +2,13 @@ import { test, expect, describe } from "bun:test"; import { monthView, yearView, summaryView, datasetMonthSpan } from "./views.ts"; import type { Dataset, Post, Purchase } from "./types.ts"; +/** Digits for the currencies these tests use; all real-world 2dp. */ +const TEST_CURRENCIES = [ + { code: "DKK", digits: 2, symbol: "kr" }, + { code: "USD", digits: 2, symbol: "$" }, + { code: "EUR", digits: 2, symbol: "\u20ac" }, +]; + function post(id: string, order: number, percent: number, archived = false): Post { return { id, @@ -27,6 +34,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha const data: Dataset = { settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + currencies: TEST_CURRENCIES, fxRates: [], posts: [post("food", 0, 20), post("games", 1, 10)], months: [ diff --git a/src/domain/views.ts b/src/domain/views.ts index 5282e37..9a13b71 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -1,4 +1,5 @@ import { incomeFor, isOverridden } from "./allocation.ts"; +import { digitsFor } from "./currencies.ts"; import { figuresFor, foldBalances, type Fold, type PostMonthFigures } from "./fold.ts"; import { roundMoney } from "./money.ts"; import { compareMonths, monthOf, monthRange, monthsOfYear } from "./months.ts"; @@ -44,13 +45,14 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { } const base = dataset.settings.baseCurrency; + const baseDigits = digitsFor(dataset.currencies, base); const totalAllocation = roundMoney( rows.reduce((sum, r) => sum + r.figures.allocation, 0), - base, + baseDigits, ); const totalCharges = roundMoney( rows.reduce((sum, r) => sum + r.figures.charges, 0), - base, + baseDigits, ); return { @@ -58,7 +60,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { income, totalAllocation, totalCharges, - unallocated: roundMoney(income - totalAllocation, base), + unallocated: roundMoney(income - totalAllocation, baseDigits), rows, }; } @@ -86,6 +88,7 @@ export function yearView(dataset: Dataset, year: number): YearViewModel { const months = monthsOfYear(year); const fold = foldBalances(dataset, months[11]!); const base = dataset.settings.baseCurrency; + const baseDigits = digitsFor(dataset.currencies, base); const incomeByMonth = months.map((m) => incomeFor(dataset, m)); @@ -96,11 +99,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel { byMonth, totalAllocation: roundMoney( byMonth.reduce((sum, f) => sum + f.allocation, 0), - base, + baseDigits, ), totalCharges: roundMoney( byMonth.reduce((sum, f) => sum + f.charges, 0), - base, + baseDigits, ), closingBalance: byMonth[11]!.remaining, }; @@ -112,11 +115,11 @@ export function yearView(dataset: Dataset, year: number): YearViewModel { incomeByMonth, totalIncome: roundMoney( incomeByMonth.reduce((a, b) => a + b, 0), - base, + baseDigits, ), totalCharges: roundMoney( rows.reduce((sum, r) => sum + r.totalCharges, 0), - base, + baseDigits, ), rows, }; @@ -138,6 +141,7 @@ export function summaryView( ): SummaryViewModel { const months = monthRange(from, to); const base = dataset.settings.baseCurrency; + const baseDigits = digitsFor(dataset.currencies, base); const fold: Fold = months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map(); @@ -145,7 +149,7 @@ export function summaryView( post, charges: roundMoney( months.reduce((sum, m) => sum + figuresFor(fold, post.id, m).charges, 0), - base, + baseDigits, ), })); @@ -156,7 +160,7 @@ export function summaryView( (sum, p) => sum + figuresFor(fold, p.id, monthId).charges, 0, ), - base, + baseDigits, ), })); @@ -167,11 +171,11 @@ export function summaryView( byMonth, totalCharges: roundMoney( byMonth.reduce((sum, m) => sum + m.charges, 0), - base, + baseDigits, ), totalIncome: roundMoney( months.reduce((sum, m) => sum + incomeFor(dataset, m), 0), - base, + baseDigits, ), }; } diff --git a/src/export/json.ts b/src/export/json.ts index c2dbf81..7d5519c 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -1,4 +1,4 @@ -import { CURRENCIES, type Currency, type Dataset, type MonthId } from "../domain/types.ts"; +import type { Currency, CurrencyDef, Dataset, MonthId } from "../domain/types.ts"; import { migrate } from "../store/migrations.ts"; export class ImportValidationError extends Error { @@ -36,11 +36,80 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; */ const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/; -function requireCurrency(value: unknown, where: string): Currency { - if (!CURRENCIES.includes(value as Currency)) { - throw new ImportValidationError(`Unsupported currency ${String(value)} in ${where}`); +/** + * `Currency` is an open string now, so this — not the type system — is what + * stops a code the dataset never defined reaching the fold, where it would + * throw MissingRateError or round at a guessed number of decimals. + */ +function requireCurrency( + value: unknown, + where: string, + defined: ReadonlySet, +): Currency { + if (typeof value !== "string" || !defined.has(value)) { + throw new ImportValidationError( + `Unsupported currency ${String(value)} in ${where}. Define it under Settings first.`, + ); } - return value as Currency; + return value; +} + +/** Codes are identity: they key the FX table and every stored Money. */ +const CURRENCY_CODE = /^[A-Z]{2,8}$/; + +/** + * Validates the currency table itself, before anything is checked against it. + * A table with a duplicate code would make `digitsFor` resolve arbitrarily, + * and a non-integer or absurd `digits` would round money to nonsense. + */ +function requireCurrencyTable(data: Record): CurrencyDef[] { + const raw = data.currencies; + if (!Array.isArray(raw)) { + throw new ImportValidationError('Dataset field "currencies" is missing or not an array'); + } + if (raw.length === 0) { + throw new ImportValidationError("A dataset must define at least one currency"); + } + + const seen = new Set(); + const table: CurrencyDef[] = []; + + for (const entry of raw as Array>) { + const code = entry?.code; + if (typeof code !== "string" || !CURRENCY_CODE.test(code)) { + throw new ImportValidationError( + `Currency code ${JSON.stringify(code)} is not 2-8 uppercase letters`, + ); + } + if (seen.has(code)) { + throw new ImportValidationError( + `Currency "${code}" is defined twice; which decimals apply would be arbitrary`, + ); + } + seen.add(code); + + const digits = entry.digits; + if (typeof digits !== "number" || !Number.isInteger(digits) || digits < 0 || digits > 4) { + throw new ImportValidationError( + `Currency "${code}" has invalid decimal places ${String(digits)}; expected an integer 0-4`, + ); + } + if (entry.symbol !== undefined && typeof entry.symbol !== "string") { + throw new ImportValidationError(`Currency "${code}" has a non-string symbol`); + } + if (entry.name !== undefined && typeof entry.name !== "string") { + throw new ImportValidationError(`Currency "${code}" has a non-string name`); + } + + table.push({ + code, + digits, + ...(typeof entry.symbol === "string" ? { symbol: entry.symbol } : {}), + ...(typeof entry.name === "string" ? { name: entry.name } : {}), + }); + } + + return table; } function requireArray(data: Record, key: string): unknown[] { @@ -75,12 +144,14 @@ export function parseDatasetJson(text: string): Dataset { } const data = dataset as unknown as Record; + const currencies = requireCurrencyTable(data); + const defined = new Set(currencies.map((currency) => currency.code)); requireArray(data, "posts"); requireArray(data, "months"); requireArray(data, "purchases"); requireArray(data, "fxRates"); - requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency"); + requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency", defined); if (!MONTH_ID.test(dataset.settings.foldStartMonth)) { throw new ImportValidationError( `Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`, @@ -89,7 +160,7 @@ export function parseDatasetJson(text: string): Dataset { const postIds = new Set(dataset.posts.map((p) => p.id)); for (const post of dataset.posts) { - requireCurrency(post.currency, `post "${post.name}"`); + requireCurrency(post.currency, `post "${post.name}"`, defined); const rules = (post as unknown as Record).rules; if (!Array.isArray(rules)) { @@ -120,7 +191,7 @@ export function parseDatasetJson(text: string): Dataset { amount?: { currency?: unknown }; }; if (rule.kind === "fixed") { - requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`); + requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`, defined); } else if (rule.kind !== "percentOfIncome") { throw new ImportValidationError( `Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`, @@ -133,12 +204,12 @@ export function parseDatasetJson(text: string): Dataset { if (!MONTH_ID.test(month.id)) { throw new ImportValidationError(`Invalid month id "${month.id}"`); } - requireCurrency(month.income.currency, `month ${month.id} income`); + requireCurrency(month.income.currency, `month ${month.id} income`, defined); } for (const purchase of dataset.purchases) { const label = `purchase "${purchase.description}"`; - requireCurrency(purchase.total.currency, label); + requireCurrency(purchase.total.currency, label, defined); if (!PURCHASE_DATE.test(purchase.date)) { throw new ImportValidationError(`${label} has an invalid date "${purchase.date}"`); @@ -163,7 +234,7 @@ export function parseDatasetJson(text: string): Dataset { if (!MONTH_ID.test(slice.month)) { throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`); } - requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`); + requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`, defined); } } diff --git a/src/export/ods.ts b/src/export/ods.ts index 4bb0ffe..66164c3 100644 --- a/src/export/ods.ts +++ b/src/export/ods.ts @@ -2,6 +2,7 @@ import { zipSync, strToU8 } from "fflate"; import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../domain/charges.ts"; import { monthRange } from "../domain/months.ts"; import { roundMoney } from "../domain/money.ts"; +import { digitsFor } from "../domain/currencies.ts"; import { datasetMonthSpan, monthView, summaryView } from "../domain/views.ts"; import type { MonthViewModel } from "../domain/views.ts"; import type { PostMonthFigures } from "../domain/fold.ts"; @@ -31,7 +32,7 @@ function summarySheet(dataset: Dataset, months: MonthId[], byMonth: FiguresByMon rows.push([ text(post.name), ...perMonth.map(num), - num(roundMoney(perMonth.reduce((a, b) => a + b, 0), base)), + num(roundMoney(perMonth.reduce((a, b) => a + b, 0), digitsFor(dataset.currencies, base))), ]); } @@ -82,7 +83,13 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet { for (const purchase of dataset.purchases) { const slice = sliceAmountForMonth(purchase, monthId); if (!slice) continue; - const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates); + const charges = chargesForPurchaseInMonth( + purchase, + monthId, + base, + dataset.fxRates, + dataset.currencies, + ); for (const charge of charges) { rows.push([ text(monthId), diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 7fa0bb8..1b5418d 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -2,26 +2,30 @@ import { test, expect, describe } from "bun:test"; import * as actions from "./actions.ts"; import { createSeedDataset } from "../domain/seed.ts"; import type { Dataset } from "../domain/types.ts"; -import { CURRENCY_DIGITS } from "../domain/types.ts"; /** - * Every currency this app supports today has 2 decimal places, so code that - * hardcodes 2 is indistinguishable from code that reads CURRENCY_DIGITS. This - * forces the table for one test, which is the only way to prove the table is - * consulted without making the product decision to add a non-2dp currency. + * Digits now live in the dataset, so a currency with a different minor unit is + * expressed by DEFINING one — no monkey-patching a module constant, which is + * what this file used to need. */ -function withDigits(currency: "DKK" | "USD" | "EUR", digits: number, body: () => void) { - const original = CURRENCY_DIGITS[currency]; - CURRENCY_DIGITS[currency] = digits; +function withDigits(currency: string, digits: number, body: () => void) { + const original = zeroDigitDataset; + zeroDigitDataset = { currency, digits }; try { body(); } finally { - CURRENCY_DIGITS[currency] = original; + zeroDigitDataset = original; } } +let zeroDigitDataset: { currency: string; digits: number } | null = null; function draft(): Dataset { - return createSeedDataset("2026-09"); + const data = createSeedDataset("2026-09"); + if (zeroDigitDataset) { + const target = data.currencies.find((c) => c.code === zeroDigitDataset!.currency); + if (target) target.digits = zeroDigitDataset.digits; + } + return data; } describe("months", () => { diff --git a/src/store/actions.ts b/src/store/actions.ts index 149deb1..6850ffc 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,8 +1,10 @@ import { newId } from "../domain/seed.ts"; import { compareMonths, monthOf } from "../domain/months.ts"; import { roundMoney } from "../domain/money.ts"; +import { currencyUsage, digitsFor, normalizeCurrencyCode } from "../domain/currencies.ts"; import type { Currency, + CurrencyDef, Dataset, FxRate, Money, @@ -46,13 +48,16 @@ function round2(value: number): number { return Number(value.toFixed(2)); } -function roundMoneyValue(money: Money): Money { - return { amount: roundMoney(money.amount, money.currency), currency: money.currency }; +function roundMoneyValue(draft: Dataset, money: Money): Money { + return { + amount: roundMoney(money.amount, digitsFor(draft.currencies, money.currency)), + currency: money.currency, + }; } -function roundRule(rule: Rule): Rule { +function roundRule(draft: Dataset, rule: Rule): Rule { if (rule.kind === "fixed") { - return { kind: "fixed", amount: roundMoneyValue(rule.amount) }; + return { kind: "fixed", amount: roundMoneyValue(draft, rule.amount) }; } return { kind: "percentOfIncome", percent: round2(rule.percent) }; } @@ -65,24 +70,25 @@ function roundRule(rule: Rule): Rule { * currency happens to use hundredths. */ function roundSplits( + draft: Dataset, splits: Split[], splitMode: Purchase["splitMode"], currency: Currency, ): Split[] { + const digits = digitsFor(draft.currencies, currency); return splits.map((split) => ({ ...split, - value: - splitMode === "fixed" ? roundMoney(split.value, currency) : round2(split.value), + value: splitMode === "fixed" ? roundMoney(split.value, digits) : round2(split.value), })); } -function roundSchedule(schedule: Schedule | null): Schedule | null { +function roundSchedule(draft: Dataset, schedule: Schedule | null): Schedule | null { if (!schedule) return null; return { ...schedule, slices: schedule.slices.map((slice) => ({ month: slice.month, - amount: roundMoneyValue(slice.amount), + amount: roundMoneyValue(draft, slice.amount), })), }; } @@ -101,7 +107,7 @@ export function ensureMonth(draft: Dataset, monthId: MonthId): Month { } export function setIncome(draft: Dataset, monthId: MonthId, income: Money): void { - ensureMonth(draft, monthId).income = roundMoneyValue(income); + ensureMonth(draft, monthId).income = roundMoneyValue(draft, income); } export function setRuleOverride( @@ -115,7 +121,7 @@ export function setRuleOverride( delete month.ruleOverrides[postId]; return; } - month.ruleOverrides[postId] = roundRule(rule); + month.ruleOverrides[postId] = roundRule(draft, rule); } export function addPost(draft: Dataset, name: string, currency: Currency): Post { @@ -173,7 +179,7 @@ export function setRuleFrom( rule: Rule, ): void { const post = requirePost(draft, postId); - const version: RuleVersion = { from, rule: roundRule(rule) }; + const version: RuleVersion = { from, rule: roundRule(draft, rule) }; const existing = post.rules.findIndex((v) => v.from === from); if (existing === -1) post.rules.push(version); else post.rules[existing] = version; @@ -196,9 +202,9 @@ export function addPurchase( const created: Purchase = { ...purchase, id: newId(), - total: roundMoneyValue(purchase.total), - splits: roundSplits(purchase.splits, purchase.splitMode, purchase.total.currency), - schedule: roundSchedule(purchase.schedule), + total: roundMoneyValue(draft, purchase.total), + splits: roundSplits(draft, purchase.splits, purchase.splitMode, purchase.total.currency), + schedule: roundSchedule(draft, purchase.schedule), }; draft.purchases.push(created); // Make sure every month the purchase touches exists, so income can be @@ -218,20 +224,21 @@ export function updatePurchase( const purchase = requirePurchase(draft, purchaseId); const resolved: Partial> = { ...changes }; if (changes.total) { - resolved.total = roundMoneyValue(changes.total); + resolved.total = roundMoneyValue(draft, changes.total); } if (changes.splits) { // Either field may be absent from a partial update; the stored purchase // supplies whichever one is, so the mode and currency always agree with // the values being rounded. resolved.splits = roundSplits( + draft, changes.splits, changes.splitMode ?? purchase.splitMode, (resolved.total ?? purchase.total).currency, ); } if (changes.schedule !== undefined) { - resolved.schedule = roundSchedule(changes.schedule); + resolved.schedule = roundSchedule(draft, changes.schedule); } Object.assign(purchase, resolved); } @@ -257,6 +264,67 @@ export function cancelScheduleFrom( * kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding to a * currency's minor unit would quantize a rate below 0.01 straight to zero. */ +/** Codes are identity, so they are validated the same way on every path. */ +const CURRENCY_CODE = /^[A-Z]{2,8}$/; + +function requireCurrencyDef(def: CurrencyDef): CurrencyDef { + const code = normalizeCurrencyCode(def.code); + if (!CURRENCY_CODE.test(code)) { + throw new Error(`Currency code "${def.code}" must be 2-8 letters`); + } + if (!Number.isInteger(def.digits) || def.digits < 0 || def.digits > 4) { + throw new Error( + `Currency "${code}" needs a whole number of decimal places between 0 and 4`, + ); + } + return { + code, + digits: def.digits, + ...(def.symbol?.trim() ? { symbol: def.symbol.trim() } : {}), + ...(def.name?.trim() ? { name: def.name.trim() } : {}), + }; +} + +export function addCurrency(draft: Dataset, def: CurrencyDef): CurrencyDef { + const currency = requireCurrencyDef(def); + if (draft.currencies.some((existing) => existing.code === currency.code)) { + throw new Error(`Currency ${currency.code} is already defined`); + } + draft.currencies.push(currency); + return currency; +} + +export function updateCurrency( + draft: Dataset, + code: Currency, + changes: Partial>, +): void { + const existing = draft.currencies.find((currency) => currency.code === code); + if (!existing) throw new Error(`Unknown currency: ${code}`); + // The code is identity — it keys the FX table and every stored Money — so it + // is deliberately not editable. Renaming one means adding the new code, + // moving what refers to it, and removing the old. + Object.assign(existing, requireCurrencyDef({ ...existing, ...changes, code })); +} + +/** + * Refuses to remove a currency anything still refers to, naming what refers to + * it. Its exchange rate goes with it, so no orphan row survives. + */ +export function removeCurrency(draft: Dataset, code: Currency): void { + const used = currencyUsage(draft, code); + if (used.length > 0) { + const isBase = draft.settings.baseCurrency === code; + throw new Error( + isBase + ? `${code} is the base currency; choose another base before removing it` + : `${code} is in use by ${used.slice(0, 3).join(", ")}${used.length > 3 ? ` and ${used.length - 3} more` : ""}`, + ); + } + draft.currencies = draft.currencies.filter((currency) => currency.code !== code); + draft.fxRates = draft.fxRates.filter((rate) => rate.currency !== code); +} + export function setFxRate(draft: Dataset, rate: FxRate): void { const index = draft.fxRates.findIndex((r) => r.currency === rate.currency); if (index === -1) draft.fxRates.push(rate); diff --git a/src/store/bakedRates.test.ts b/src/store/bakedRates.test.ts index c00d295..7b19e21 100644 --- a/src/store/bakedRates.test.ts +++ b/src/store/bakedRates.test.ts @@ -70,7 +70,12 @@ describe("what baked rates are allowed to do", () => { const data = createSeedDataset("2026-09", FALLBACK_FX_RATES); data.fxRates = data.fxRates.filter((r) => r.currency !== "EUR"); expect(() => - toBase({ amount: 100, currency: "EUR" }, data.settings.baseCurrency, data.fxRates), + toBase( + { amount: 100, currency: "EUR" }, + data.settings.baseCurrency, + data.fxRates, + data.currencies, + ), ).toThrow(MissingRateError); }); }); diff --git a/src/store/bakedRates.ts b/src/store/bakedRates.ts index c114e25..d4057bc 100644 --- a/src/store/bakedRates.ts +++ b/src/store/bakedRates.ts @@ -1,4 +1,4 @@ -import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"; +import { SEED_CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"; /** * Exchange rates baked in at build time so a fresh install can convert a EUR @@ -39,14 +39,19 @@ export const FALLBACK_FX_RATES: readonly FxRate[] = Object.freeze([ }), ]); -/** The currencies a baked payload must cover: everything but the base. */ -const REQUIRED = CURRENCIES.filter((c) => c !== "DKK"); +/** + * The currencies a baked payload must cover: the seed table minus the base. + * Only the SEED currencies — a dataset that already exists is never seeded, so + * currencies the owner added later are irrelevant here. + */ +const REQUIRED: Currency[] = SEED_CURRENCIES.map((c) => c.code).filter((c) => c !== "DKK"); +const SEED_CODES: Currency[] = SEED_CURRENCIES.map((c) => c.code); function isUsableRate(value: unknown): value is FxRate { if (typeof value !== "object" || value === null) return false; const rate = value as Record; return ( - CURRENCIES.includes(rate.currency as Currency) && + SEED_CODES.includes(rate.currency as Currency) && typeof rate.baseUnitsPerOne === "number" && Number.isFinite(rate.baseUnitsPerOne) && rate.baseUnitsPerOne > 0 && diff --git a/src/store/currencyActions.test.ts b/src/store/currencyActions.test.ts new file mode 100644 index 0000000..1304be0 --- /dev/null +++ b/src/store/currencyActions.test.ts @@ -0,0 +1,128 @@ +import { test, expect, describe } from "bun:test"; +import * as actions from "./actions.ts"; +import { createSeedDataset } from "../domain/seed.ts"; +import { currencyUsage } from "../domain/currencies.ts"; +import type { Dataset } from "../domain/types.ts"; + +const draft = (): Dataset => createSeedDataset("2026-09"); + +describe("adding a currency", () => { + test("appends it with the digits and symbol given", () => { + const data = draft(); + actions.addCurrency(data, { code: "JPY", digits: 0, symbol: "¥", name: "Japanese yen" }); + expect(data.currencies.at(-1)).toEqual({ + code: "JPY", + digits: 0, + symbol: "¥", + name: "Japanese yen", + }); + }); + + test("the code is normalised, because a code is identity", () => { + // "usd" and "USD" must never become two currencies: the code keys the FX + // table and every stored Money. + const data = draft(); + actions.addCurrency(data, { code: " jpy ", digits: 0 }); + expect(data.currencies.at(-1)!.code).toBe("JPY"); + }); + + test("a duplicate is refused rather than shadowing the original", () => { + const data = draft(); + expect(() => actions.addCurrency(data, { code: "usd", digits: 2 })).toThrow(/USD/); + expect(data.currencies.filter((c) => c.code === "USD")).toHaveLength(1); + }); + + test.each([ + ["not letters", "US1"], + ["too short", "U"], + ["empty", " "], + ])("a code that is %s is refused", (_label, code) => { + expect(() => actions.addCurrency(draft(), { code, digits: 2 })).toThrow(); + }); + + test.each([[-1], [5], [1.5]])("invalid decimal places (%p) are refused", (digits) => { + expect(() => actions.addCurrency(draft(), { code: "JPY", digits })).toThrow(/decimal/i); + }); +}); + +describe("editing a currency", () => { + test("changes the digits, symbol and name but never the code", () => { + const data = draft(); + actions.updateCurrency(data, "USD", { digits: 3, symbol: "US$" }); + const usd = data.currencies.find((c) => c.code === "USD")!; + expect(usd).toEqual({ code: "USD", digits: 3, symbol: "US$", name: "US dollar" }); + }); + + test("an unknown code throws", () => { + expect(() => actions.updateCurrency(draft(), "JPY", { digits: 0 })).toThrow(/JPY/); + }); +}); + +describe("removing a currency", () => { + test("removes one nothing references", () => { + const data = draft(); + actions.addCurrency(data, { code: "JPY", digits: 0 }); + actions.removeCurrency(data, "JPY"); + expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); + }); + + test("its exchange rate goes with it, so no orphan row is left behind", () => { + const data = draft(); + actions.addCurrency(data, { code: "JPY", digits: 0 }); + actions.setFxRate(data, { + currency: "JPY", + baseUnitsPerOne: 0.045, + updatedAt: "2026-09-01", + source: "manual", + }); + actions.removeCurrency(data, "JPY"); + expect(data.fxRates.find((r) => r.currency === "JPY")).toBeUndefined(); + }); + + test("the base currency cannot be removed", () => { + expect(() => actions.removeCurrency(draft(), "DKK")).toThrow(/base/i); + }); + + /** + * The same reasoning as posts being archived rather than deleted: stored + * Money references the code. Removing a currency in use would leave amounts + * pointing at a definition that no longer exists — and the import validator + * would then refuse the owner's own exported file. + */ + test("a currency still referenced by a purchase cannot be removed", () => { + const data = draft(); + actions.addPurchase(data, { + date: "2026-09-04", + description: "Indie bundle", + total: { amount: 27.5, currency: "EUR" }, + splitMode: "percent", + splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }], + schedule: null, + }); + expect(() => actions.removeCurrency(data, "EUR")).toThrow(/in use/i); + }); + + test("a currency referenced only by a post's display setting cannot be removed", () => { + const data = draft(); + actions.updatePost(data, data.posts[0]!.id, { currency: "USD" }); + expect(() => actions.removeCurrency(data, "USD")).toThrow(/in use/i); + }); +}); + +describe("currencyUsage", () => { + test("names where a currency is referenced, so the UI can say why", () => { + const data = draft(); + actions.setIncome(data, "2026-10", { amount: 100, currency: "USD" }); + expect(currencyUsage(data, "USD")).toContain("month 2026-10"); + expect(currencyUsage(data, "JPY")).toEqual([]); + }); + + test("a fixed allocation rule counts as a reference", () => { + const data = draft(); + actions.setRuleFrom(data, data.posts[0]!.id, "2026-09", { + kind: "fixed", + amount: { amount: 50, currency: "EUR" }, + }); + expect(currencyUsage(data, "EUR").join(" ")).toMatch(/rule/i); + }); +}); diff --git a/src/store/fxApi.test.ts b/src/store/fxApi.test.ts index 0b01ce9..b4499bd 100644 --- a/src/store/fxApi.test.ts +++ b/src/store/fxApi.test.ts @@ -1,6 +1,9 @@ import { test, expect, describe } from "bun:test"; import { buildFxUrl, parseFxResponse, DEFAULT_FX_API_URL } from "./fxApi.ts"; +/** What the caller asked the service for, and so what it will accept back. */ +const ALLOWED = ["DKK", "USD", "EUR"]; + describe("buildFxUrl", () => { test("substitutes the base and target placeholders", () => { expect(buildFxUrl(DEFAULT_FX_API_URL, "DKK", ["USD", "EUR"])).toBe( @@ -13,36 +16,36 @@ describe("parseFxResponse", () => { test("inverts the rates into base units per one unit", () => { // The API returns how many target units 1 base unit buys; we store the // inverse, so 1 EUR = 7.46 DKK becomes baseUnitsPerOne 7.46. - const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK"); + const rates = parseFxResponse({ rates: { EUR: 0.134048, USD: 0.144928 } }, "DKK", ALLOWED); expect(rates.find((r) => r.currency === "EUR")!.baseUnitsPerOne).toBeCloseTo(7.46, 2); expect(rates.every((r) => r.source === "api")).toBe(true); expect(rates.every((r) => r.updatedAt !== "")).toBe(true); }); test("skips the base currency's own row if the API returns one", () => { - const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK"); + const rates = parseFxResponse({ rates: { DKK: 1, EUR: 0.134 } }, "DKK", ALLOWED); expect(rates.map((r) => r.currency)).toEqual(["EUR"]); }); - test("ignores unsupported currencies", () => { - const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK"); + test("ignores currencies this dataset does not define", () => { + const rates = parseFxResponse({ rates: { GBP: 0.11, EUR: 0.134 } }, "DKK", ALLOWED); expect(rates.map((r) => r.currency)).toEqual(["EUR"]); }); test("rejects a response with no rates object", () => { - expect(() => parseFxResponse({}, "DKK")).toThrow(/rates/); - expect(() => parseFxResponse("nope", "DKK")).toThrow(/rates/); + expect(() => parseFxResponse({}, "DKK", ALLOWED)).toThrow(/rates/); + expect(() => parseFxResponse("nope", "DKK", ALLOWED)).toThrow(/rates/); }); test("rejects a zero or negative rate rather than storing an infinity", () => { - expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK")).toThrow(/EUR/); + expect(() => parseFxResponse({ rates: { EUR: 0 } }, "DKK", ALLOWED)).toThrow(/EUR/); }); }); test("an explicit updatedAt is used instead of the clock", () => { // The build bakes rates in with the BUILD date, not the date the browser // happens to run parseFxResponse. - const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", "2026-09-01"); + const rates = parseFxResponse({ rates: { EUR: 0.13378, USD: 0.15505 } }, "DKK", ALLOWED, "2026-09-01"); expect(rates.map((r) => r.updatedAt)).toEqual(["2026-09-01", "2026-09-01"]); expect(rates.every((r) => r.source === "api")).toBe(true); }); diff --git a/src/store/fxApi.ts b/src/store/fxApi.ts index 0b6abc9..9f050d5 100644 --- a/src/store/fxApi.ts +++ b/src/store/fxApi.ts @@ -1,4 +1,4 @@ -import { CURRENCIES, type Currency, type FxRate } from "../domain/types.ts"; +import type { Currency, FxRate } from "../domain/types.ts"; /** Frankfurter needs no API key. The URL is user-editable in settings. */ export const DEFAULT_FX_API_URL = "https://api.frankfurter.app/latest?from={base}&to={targets}"; @@ -16,6 +16,12 @@ export function buildFxUrl( export function parseFxResponse( body: unknown, base: Currency, + /** + * The currencies this dataset knows. Anything else in the response is + * skipped: a rate service returns dozens, and storing one for a currency the + * owner has not defined would put an unreachable row in their table. + */ + allowed: readonly Currency[], /** Defaults to now. The build passes its own date when baking rates in. */ updatedAt: string = new Date().toISOString(), ): FxRate[] { @@ -31,7 +37,7 @@ export function parseFxResponse( for (const [currency, value] of Object.entries(rates as Record)) { if (currency === base) continue; - if (!CURRENCIES.includes(currency as Currency)) continue; + if (!allowed.includes(currency)) continue; if (typeof value !== "number" || !(value > 0)) { throw new Error(`Exchange rate for ${currency} is not a positive number`); } @@ -56,5 +62,6 @@ export async function fetchFxRates( if (!response.ok) { throw new Error(`Rate service returned ${response.status}`); } - return parseFxResponse(await response.json(), base); + // The targets we asked for are exactly the ones we will accept back. + return parseFxResponse(await response.json(), base, targets); } diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 1bc6535..7649a72 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe } from "bun:test"; +import { SCHEMA_VERSION, createSeedDataset } from "../domain/seed.ts"; import { migrate, UnsupportedSchemaError } from "./migrations.ts"; -import { createSeedDataset, SCHEMA_VERSION } from "../domain/seed.ts"; describe("migrate", () => { test("passes a current-version dataset through unchanged", () => { @@ -63,7 +63,10 @@ function v1Dataset() { describe("v1 -> v2: rules become a dated series", () => { test("each standingRule becomes one version starting at the fold start", () => { const migrated = migrate(v1Dataset()); - expect(migrated.settings.schemaVersion).toBe(2); + // migrate() runs every step to the current SCHEMA_VERSION, so this lands + // at 3 now that currencies became data. The rule conversion below is what + // this test is actually about. + expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION); expect(migrated.posts[0]!.rules).toEqual([ { from: "2026-01", rule: { kind: "percentOfIncome", percent: 20 } }, ]); @@ -135,3 +138,98 @@ describe("v1 -> v2: rules become a dated series", () => { ]); }); }); + +describe("v2 -> v3: currencies become data", () => { + /** A realistic v2 dataset: no `currencies` field existed. */ + function v2(): any { + return { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 }, + fxRates: [ + { currency: "EUR", baseUnitsPerOne: 7.4, updatedAt: "2026-01-01", source: "manual" }, + ], + posts: [ + { + id: "p1", + name: "Food", + order: 0, + archived: false, + currency: "DKK", + rules: [{ from: "2026-01", rule: { kind: "percentOfIncome", percent: 12 } }], + }, + ], + months: [ + { id: "2026-01", income: { amount: 20000, currency: "DKK" }, ruleOverrides: {} }, + ], + purchases: [], + }; + } + + test("the three shipped currencies are added, with their decimals", () => { + const migrated = migrate(v2()); + expect(migrated.settings.schemaVersion).toBe(3); + expect(migrated.currencies).toEqual([ + { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, + { code: "USD", digits: 2, symbol: "$", name: "US dollar" }, + { code: "EUR", digits: 2, symbol: "€", name: "Euro" }, + ]); + }); + + /** + * The whole point of the migration being safe: every currency that existed + * had two decimal places, and the seeded table says two, so not one stored + * amount can round differently than it did before. + */ + test("no amount can round differently than it did under v2", () => { + const migrated = migrate(v2()); + for (const currency of migrated.currencies) { + expect(currency.digits).toBe(2); + } + }); + + test("nothing else about the dataset is touched", () => { + const before = v2(); + const migrated = migrate(structuredClone(before)); + expect(migrated.posts).toEqual(before.posts); + expect(migrated.months).toEqual(before.months); + expect(migrated.fxRates).toEqual(before.fxRates); + expect(migrated.settings.baseCurrency).toBe("DKK"); + expect(migrated.settings.foldStartMonth).toBe("2026-01"); + }); + + test("a v1 dataset migrates all the way through to v3", () => { + const v1 = { + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + fxRates: [], + posts: [ + { + id: "p1", + name: "Food", + order: 0, + archived: false, + currency: "DKK", + standingRule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } }, + }, + ], + months: [], + purchases: [], + }; + const migrated = migrate(v1); + expect(migrated.settings.schemaVersion).toBe(3); + expect(migrated.posts[0]!.rules).toEqual([ + { from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "DKK" } } }, + ]); + expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR"]); + }); + + /** + * A dataset can only reach v3 through this step, but an owner could have a + * hand-edited file that already has the field. Overwriting it would silently + * discard currencies they added. + */ + test("an existing currencies field is kept, not overwritten", () => { + const data = v2(); + data.currencies = [{ code: "DKK", digits: 2 }, { code: "JPY", digits: 0 }]; + const migrated = migrate(data); + expect(migrated.currencies.map((c) => c.code)).toEqual(["DKK", "JPY"]); + }); +}); diff --git a/src/store/migrations.ts b/src/store/migrations.ts index 348c416..e5a99bd 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -1,4 +1,5 @@ import { SCHEMA_VERSION } from "../domain/seed.ts"; +import { SEED_CURRENCIES } from "../domain/types.ts"; import type { Dataset } from "../domain/types.ts"; export class UnsupportedSchemaError extends Error { @@ -37,6 +38,24 @@ const MIGRATIONS: Array<(data: any) => any> = [ }; }), }), + + // 2 -> 3: currencies become data. Until now the three supported currencies + // and their decimal places were compile-time constants, so adding one meant + // changing code; they now live in the dataset, which is also what lets the + // owner define their own. + // + // Behaviour-preserving by construction: every currency that existed had two + // decimal places, and the seeded table records two, so not one stored amount + // can round differently than it did before. An existing `currencies` field + // is left alone rather than overwritten — a hand-edited file could already + // carry currencies the owner added, and discarding them would be data loss. + (data: any) => ({ + ...data, + settings: { ...data.settings, schemaVersion: 3 }, + currencies: Array.isArray(data.currencies) + ? data.currencies + : SEED_CURRENCIES.map((currency) => ({ ...currency })), + }), ]; export function migrate(raw: unknown): Dataset { diff --git a/src/ui/bulkEntry.test.ts b/src/ui/bulkEntry.test.ts index 553dda6..d3dd6b4 100644 --- a/src/ui/bulkEntry.test.ts +++ b/src/ui/bulkEntry.test.ts @@ -45,15 +45,15 @@ describe("blank lines", () => { describe("bulkTotal", () => { test("sums the filled lines", () => { - expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }))).toBe(55.5); + expect(bulkTotal(draft({ lines: [line("Milk", 25), line("Bread", 30.5), line("", 0)] }), 2)).toBe(55.5); }); test("is zero when there is nothing filled in", () => { - expect(bulkTotal(draft())).toBe(0); + expect(bulkTotal(draft(), 2)).toBe(0); }); test("rounds the sum, so float tails never surface", () => { - expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }))).toBe(0.3); + expect(bulkTotal(draft({ lines: [line("a", 0.1), line("b", 0.2)] }), 2)).toBe(0.3); }); }); diff --git a/src/ui/bulkEntry.ts b/src/ui/bulkEntry.ts index 30533fd..690c8fc 100644 --- a/src/ui/bulkEntry.ts +++ b/src/ui/bulkEntry.ts @@ -69,10 +69,10 @@ export function filledLines(draft: BulkDraft): BulkLine[] { return draft.lines.filter((line) => !isBlankLine(line)); } -export function bulkTotal(draft: BulkDraft): number { +export function bulkTotal(draft: BulkDraft, digits: number): number { return roundMoney( filledLines(draft).reduce((sum, line) => sum + line.amount, 0), - draft.currency, + digits, ); } diff --git a/src/ui/components/BulkLines.tsx b/src/ui/components/BulkLines.tsx index 1c5ab64..66098a8 100644 --- a/src/ui/components/BulkLines.tsx +++ b/src/ui/components/BulkLines.tsx @@ -1,4 +1,6 @@ import { useRef } from "react"; +import { useDataset } from "../hooks/useDataset.ts"; +import { digitsFor } from "../../domain/currencies.ts"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatMoney } from "../format.ts"; @@ -24,6 +26,7 @@ interface Props { * on touch devices too. That is why there is no "Add line" button. */ export function BulkLines({ draft, onChange }: Props) { + const digits = digitsFor(useDataset().currencies, draft.currency); const descriptionRefs = useRef>([]); const noteRefs = useRef>([]); @@ -125,7 +128,7 @@ export function BulkLines({ draft, onChange }: Props) {

{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "} - {formatMoney(bulkTotal(draft), draft.currency)} + {formatMoney(bulkTotal(draft, digits), draft.currency)}

); diff --git a/src/ui/components/PlanEditor.tsx b/src/ui/components/PlanEditor.tsx index 66941ef..107f09b 100644 --- a/src/ui/components/PlanEditor.tsx +++ b/src/ui/components/PlanEditor.tsx @@ -1,4 +1,6 @@ import { Button } from "@/components/ui/button"; +import { useDataset } from "../hooks/useDataset.ts"; +import { digitsFor } from "../../domain/currencies.ts"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatAmount } from "../format.ts"; @@ -28,7 +30,10 @@ function hasValidDate(draft: PurchaseDraft): boolean { } export function PlanEditor({ draft, onChange }: Props) { - const balance = planBalance(draft); + // The purchase's own currency decides the rounding, not the base currency: + // a plan for a JPY purchase must divide into whole yen. + const digits = digitsFor(useDataset().currencies, draft.currency); + const balance = planBalance(draft, digits); if (!draft.plan) { const dateReady = hasValidDate(draft); @@ -41,7 +46,7 @@ export function PlanEditor({ draft, onChange }: Props) { disabled={!dateReady} onClick={() => { if (!dateReady) return; - onChange(withPlan(draft, monthOf(draft.date), 3)); + onChange(withPlan(draft, monthOf(draft.date), 3, digits)); }} > Spread this over several months @@ -70,7 +75,7 @@ export function PlanEditor({ draft, onChange }: Props) { onChange={(event) => { const months = Number(event.target.value); if (Number.isInteger(months) && months >= 1) { - onChange(withPlan(draft, draft.plan!.startMonth, months)); + onChange(withPlan(draft, draft.plan!.startMonth, months, digits)); } }} /> diff --git a/src/ui/components/PurchaseDialog.tsx b/src/ui/components/PurchaseDialog.tsx index 9448637..dd47c43 100644 --- a/src/ui/components/PurchaseDialog.tsx +++ b/src/ui/components/PurchaseDialog.tsx @@ -27,9 +27,10 @@ import { type BulkDraft, } from "../bulkEntry.ts"; import { useDataset } from "../hooks/useDataset.ts"; +import { parseMoneyInput } from "../moneyInput.ts"; import { useMutate } from "../hooks/useMutate.ts"; import { addPurchase, cancelScheduleFrom, updatePurchase } from "../../store/actions.ts"; -import { CURRENCIES, type MonthId, type Purchase } from "../../domain/types.ts"; +import type { MonthId, Purchase } from "../../domain/types.ts"; interface Props { monthId: MonthId; @@ -49,6 +50,11 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { const [draft, setDraft] = useState(() => purchase ? fromPurchase(purchase) : emptyDraft(monthId, activePosts[0]?.id ?? ""), ); + // The amount field keeps its own text so a half-typed "30." or "30 " stays + // exactly as typed; `draft.amount` only moves when the text parses. + const [amountText, setAmountText] = useState(() => + purchase ? String(purchase.total.amount) : "", + ); // "many" is only meaningful when adding — editing an existing purchase edits // exactly one, so the toggle is hidden in that case. const [mode, setMode] = useState<"one" | "many">("one"); @@ -179,7 +185,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { setBulk((b) => ({ ...b, currency })); }} > - {CURRENCIES.map((currency) => ( + {dataset.currencies.map(({ code: currency }) => ( @@ -247,15 +253,24 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) {
+ {/* type="text", not "number": a number input silently discards + anything non-numeric, so "30$" would never reach the parser. + Entry speed is the point — the currency is set from what was + typed rather than from a second control. */} - setDraft({ ...draft, amount: Number(event.target.value) || 0 }) - } + placeholder="30$" + value={amountText} + onChange={(event) => { + const typed = event.target.value; + setAmountText(typed); + const parsed = parseMoneyInput(typed, dataset.currencies, draft.currency); + if (parsed) { + setDraft({ ...draft, amount: parsed.amount, currency: parsed.currency }); + } + }} />
@@ -268,7 +283,7 @@ export function PurchaseDialog({ monthId, purchase, trigger }: Props) { setDraft({ ...draft, currency: event.target.value as typeof draft.currency }) } > - {CURRENCIES.map((currency) => ( + {dataset.currencies.map(({ code: currency }) => ( diff --git a/src/ui/components/SplitEditor.tsx b/src/ui/components/SplitEditor.tsx index da3d5ea..8ee36c3 100644 --- a/src/ui/components/SplitEditor.tsx +++ b/src/ui/components/SplitEditor.tsx @@ -1,4 +1,6 @@ import { Button } from "@/components/ui/button"; +import { useDataset } from "../hooks/useDataset.ts"; +import { digitsFor } from "../../domain/currencies.ts"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { formatAmount } from "../format.ts"; @@ -13,7 +15,7 @@ interface Props { } export function SplitEditor({ draft, posts, onChange }: Props) { - const balance = splitBalance(draft); + const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency)); const unit = draft.splitMode === "percent" ? "%" : draft.currency; function updateSplit(index: number, changes: Partial) { diff --git a/src/ui/moneyInput.test.ts b/src/ui/moneyInput.test.ts new file mode 100644 index 0000000..740dc14 --- /dev/null +++ b/src/ui/moneyInput.test.ts @@ -0,0 +1,139 @@ +import { test, expect, describe } from "bun:test"; +import { parseMoneyInput, type CurrencyOption } from "./moneyInput.ts"; + +const OPTIONS: CurrencyOption[] = [ + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, + { code: "EUR", symbol: "€" }, +]; +const parse = (text: string, fallback = "DKK") => + parseMoneyInput(text, OPTIONS, fallback); + +describe("reading the currency out of what was typed", () => { + test("a trailing symbol", () => { + expect(parse("30$")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("a leading symbol", () => { + expect(parse("$30")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("a symbol with a space either side", () => { + expect(parse("30 $")).toEqual({ amount: 30, currency: "USD" }); + expect(parse("$ 30")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("a three-letter code, in any case", () => { + expect(parse("30 usd")).toEqual({ amount: 30, currency: "USD" }); + expect(parse("USD 30")).toEqual({ amount: 30, currency: "USD" }); + expect(parse("30EUR")).toEqual({ amount: 30, currency: "EUR" }); + }); + + test("a multi-letter symbol", () => { + expect(parse("250 kr")).toEqual({ amount: 250, currency: "DKK" }); + expect(parse("250kr")).toEqual({ amount: 250, currency: "DKK" }); + }); + + test("no currency at all falls back to the one the field is in", () => { + expect(parse("30")).toEqual({ amount: 30, currency: "DKK" }); + expect(parse("30", "EUR")).toEqual({ amount: 30, currency: "EUR" }); + }); + + test("a code wins over a symbol when both appear", () => { + // "$30 USD" is contradictory; the explicit code is the stronger signal. + expect(parse("$30 USD")).toEqual({ amount: 30, currency: "USD" }); + }); + + test("an unknown currency is rejected rather than silently ignored", () => { + // Silently dropping "GBP" would book a GBP purchase as DKK. + expect(parse("30 GBP")).toBeNull(); + expect(parse("30 £")).toBeNull(); + }); +}); + +describe("reading the number out of what was typed", () => { + test("plain decimals", () => { + expect(parse("30.50")).toEqual({ amount: 30.5, currency: "DKK" }); + }); + + test("a comma decimal separator, as most of Europe writes it", () => { + expect(parse("30,50")).toEqual({ amount: 30.5, currency: "DKK" }); + expect(parse("€25,50")).toEqual({ amount: 25.5, currency: "EUR" }); + }); + + /** + * The genuinely ambiguous cases. The rule: when both separators appear, the + * LAST one is the decimal point. When only one appears, it is a thousands + * separator if exactly three digits follow it, and a decimal point + * otherwise. "1,500" is therefore one thousand five hundred, which is what + * someone typing a budget figure means. + */ + test("both separators: the last one is the decimal point", () => { + expect(parse("1.234,56")).toEqual({ amount: 1234.56, currency: "DKK" }); + expect(parse("1,234.56")).toEqual({ amount: 1234.56, currency: "DKK" }); + }); + + test("one separator with three digits after it groups thousands", () => { + expect(parse("1,500")).toEqual({ amount: 1500, currency: "DKK" }); + expect(parse("1.500")).toEqual({ amount: 1500, currency: "DKK" }); + }); + + test("one separator with one or two digits after it is a decimal point", () => { + expect(parse("1,5")).toEqual({ amount: 1.5, currency: "DKK" }); + expect(parse("1.50")).toEqual({ amount: 1.5, currency: "DKK" }); + }); + + test("spaces and non-breaking spaces group thousands", () => { + expect(parse("28 000")).toEqual({ amount: 28000, currency: "DKK" }); + expect(parse("28 000,50")).toEqual({ amount: 28000.5, currency: "DKK" }); + }); + + test("a negative amount is legal — a refund is a normal line", () => { + expect(parse("-500")).toEqual({ amount: -500, currency: "DKK" }); + expect(parse("-30$")).toEqual({ amount: -30, currency: "USD" }); + expect(parse("$-30")).toEqual({ amount: -30, currency: "USD" }); + }); + + test("a leading plus is accepted and means nothing", () => { + expect(parse("+30")).toEqual({ amount: 30, currency: "DKK" }); + }); +}); + +describe("what is not a money value", () => { + test.each([ + ["empty", ""], + ["only spaces", " "], + ["only a currency", "$"], + ["only a code", "USD"], + ["letters", "abc"], + ["two numbers", "30 40"], + ["a bare separator", ","], + ["a lone minus", "-"], + ])("%s is not a money value", (_label, text) => { + expect(parse(text)).toBeNull(); + }); + + test("a half-typed decimal is not yet a value, and must not become one", () => { + // Committing "30." as 30 would fight the user mid-keystroke. + expect(parse("30.")).toBeNull(); + expect(parse("30,")).toBeNull(); + }); +}); + +describe("symbols that collide", () => { + const withCollision: CurrencyOption[] = [ + { code: "USD", symbol: "$" }, + { code: "CAD", symbol: "$" }, + ]; + + test("the first currency defined with that symbol wins, and the code disambiguates", () => { + expect(parseMoneyInput("30$", withCollision, "USD")).toEqual({ + amount: 30, + currency: "USD", + }); + expect(parseMoneyInput("30 CAD", withCollision, "USD")).toEqual({ + amount: 30, + currency: "CAD", + }); + }); +}); diff --git a/src/ui/moneyInput.ts b/src/ui/moneyInput.ts new file mode 100644 index 0000000..d528bf9 --- /dev/null +++ b/src/ui/moneyInput.ts @@ -0,0 +1,143 @@ +import type { Currency } from "../domain/types.ts"; + +export interface CurrencyOption { + code: Currency; + /** "$", "kr", "€". Optional: a currency can be entered by code alone. */ + symbol?: string; +} + +export interface ParsedMoney { + amount: number; + currency: Currency; +} + +/** + * Reads an amount AND its currency out of one field, so "30$" means thirty + * dollars without touching a separate dropdown. Entry speed is a stated + * product feature; a second control per amount is the opposite of it. + * + * Returns null for anything that is not yet a money value, including a + * half-typed "30." — committing that as 30 would fight the user mid-keystroke. + * An unrecognised currency also returns null rather than falling back, because + * silently reading "30 GBP" as 30 DKK books money at the wrong currency. + */ +export function parseMoneyInput( + text: string, + options: readonly CurrencyOption[], + fallback: Currency, +): ParsedMoney | null { + const trimmed = text.trim(); + if (trimmed === "") return null; + + const currency = detectCurrency(trimmed, options); + if (currency === "unknown") return null; + + const amount = parseAmount(stripCurrency(trimmed, options)); + if (amount === null) return null; + + return { amount, currency: currency ?? fallback }; +} + +/** + * A three-letter code beats a symbol: "$30 USD" is contradictory, and the code + * is the more deliberate signal. Returns `null` when no currency was written + * at all, and "unknown" when something currency-shaped was written that this + * dataset has no currency for. + */ +function detectCurrency( + text: string, + options: readonly CurrencyOption[], +): Currency | null | "unknown" { + const codes = new Map(options.map((o) => [o.code.toUpperCase(), o.code])); + + // A bare three-letter word, not part of a longer word. + const words = text.toUpperCase().match(/[A-Z]{2,}/g) ?? []; + for (const word of words) { + const matched = codes.get(word); + if (matched) return matched; + } + + // Longest symbol first, so "kr." cannot be shadowed by "kr". + const symbols = options + .filter((o) => o.symbol) + .sort((a, b) => b.symbol!.length - a.symbol!.length); + for (const option of symbols) { + if (text.includes(option.symbol!)) return option.code; + } + + // Anything left that is neither digits, separators nor a sign is a currency + // this dataset does not define. + if (/[^\d\s.,+-]/.test(text)) return "unknown"; + + // A word that is not a known code is unknown too ("30 GBP"). + if (words.length > 0) return "unknown"; + + return null; +} + +function stripCurrency(text: string, options: readonly CurrencyOption[]): string { + let out = text; + for (const option of options) { + if (option.symbol) out = out.split(option.symbol).join(" "); + out = out.replace(new RegExp(option.code, "gi"), " "); + } + return out; +} + +/** + * Separator handling, which is where this earns its tests. When BOTH "." and + * "," appear, the last one is the decimal point — that covers "1.234,56" and + * "1,234.56" without knowing the user's locale. When only one appears, it + * groups thousands if exactly three digits follow and is a decimal point + * otherwise, so "1,500" is fifteen hundred and "1,5" is one and a half. + */ +function parseAmount(text: string): number | null { + // Normalise every kind of space to a plain one before splitting. + const spaced = text.replace(/\s+/g, " ").trim(); + if (spaced === "") return null; + + const sign = spaced.startsWith("-") ? -1 : 1; + const unsigned = spaced.replace(/^[+-]\s*/, ""); + + // A space only ever GROUPS thousands, so every group after the first must be + // exactly three digits. Without this, "30 40" silently becomes 3040 — two + // amounts typed by mistake, committed as one much larger one. + const groups = unsigned.split(" ").filter((group) => group !== ""); + if (groups.length === 0) return null; + if (groups.length > 1) { + const allGrouped = groups + .slice(1) + .every((group) => /^\d{3}([.,]\d+)?$/.test(group)); + if (!allGrouped) return null; + } + + const digits = groups.join(""); + if (!/^[\d.,]+$/.test(digits)) return null; + if (/^[.,]+$/.test(digits)) return null; + // A trailing separator means the number is still being typed. + if (/[.,]$/.test(digits)) return null; + + const lastDot = digits.lastIndexOf("."); + const lastComma = digits.lastIndexOf(","); + let decimalAt = -1; + + if (lastDot >= 0 && lastComma >= 0) { + decimalAt = Math.max(lastDot, lastComma); + } else if (lastDot >= 0 || lastComma >= 0) { + const only = Math.max(lastDot, lastComma); + if (digits.length - only - 1 !== 3) decimalAt = only; + } + + const whole = (decimalAt === -1 ? digits : digits.slice(0, decimalAt)).replace( + /[.,]/g, + "", + ); + const fraction = decimalAt === -1 ? "" : digits.slice(decimalAt + 1); + if (/[.,]/.test(fraction)) return null; + + const value = Number( + `${whole === "" ? "0" : whole}.${fraction === "" ? "0" : fraction}`, + ); + return Number.isFinite(value) ? sign * value : null; +} + diff --git a/src/ui/purchaseForm.test.ts b/src/ui/purchaseForm.test.ts index b8d7abe..f441ccf 100644 --- a/src/ui/purchaseForm.test.ts +++ b/src/ui/purchaseForm.test.ts @@ -30,7 +30,7 @@ describe("emptyDraft", () => { describe("splitBalance", () => { test("is zero when percentages total 100", () => { - expect(splitBalance(draft())).toBe(0); + expect(splitBalance(draft(), 2)).toBe(0); }); test("reports the missing percentage", () => { @@ -40,7 +40,7 @@ describe("splitBalance", () => { { postId: "events", value: 30, absorbsRemainder: false }, ], }); - expect(splitBalance(d)).toBe(10); + expect(splitBalance(d, 2)).toBe(10); }); test("reports the missing amount in fixed mode", () => { @@ -51,7 +51,7 @@ describe("splitBalance", () => { { postId: "events", value: 50, absorbsRemainder: false }, ], }); - expect(splitBalance(d)).toBe(30); + expect(splitBalance(d, 2)).toBe(30); }); }); @@ -123,7 +123,7 @@ describe("toPurchase and fromPurchase", () => { describe("finance plans", () => { test("withPlan generates equal slices from the given month", () => { - const d = withPlan(draft({ amount: 3000 }), "2026-10", 6); + const d = withPlan(draft({ amount: 3000 }), "2026-10", 6, 2); expect(d.plan!.slices).toHaveLength(6); expect(d.plan!.slices[0]).toEqual({ month: "2026-10", @@ -140,37 +140,37 @@ describe("finance plans", () => { { postId: "events", value: 30, absorbsRemainder: false }, ], }); - expect(withPlan(split, "2026-10", 2).splits).toEqual(split.splits); + expect(withPlan(split, "2026-10", 2, 2).splits).toEqual(split.splits); }); test("withoutPlan clears the schedule", () => { - expect(withoutPlan(withPlan(draft(), "2026-10", 3)).plan).toBeNull(); + expect(withoutPlan(withPlan(draft(), "2026-10", 3, 2)).plan).toBeNull(); }); test("setSliceAmount edits one slice and leaves the others alone", () => { - const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500); + const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500); expect(d.plan!.slices.map((s) => s.amount.amount)).toEqual([1500, 1000, 1000]); }); test("planBalance reports the difference between the slices and the total", () => { - const even = withPlan(draft({ amount: 3000 }), "2026-10", 3); - expect(planBalance(even)).toBe(0); - expect(planBalance(setSliceAmount(even, 0, 1500))).toBe(-500); + const even = withPlan(draft({ amount: 3000 }), "2026-10", 3, 2); + expect(planBalance(even, 2)).toBe(0); + expect(planBalance(setSliceAmount(even, 0, 1500), 2)).toBe(-500); }); test("planBalance is zero when there is no plan", () => { - expect(planBalance(draft())).toBe(0); + expect(planBalance(draft(), 2)).toBe(0); }); test("a plan with unbalanced slices is still valid", () => { // A deposit-then-instalments plan may legitimately not sum to the total // yet while the user is typing; the editor warns rather than blocking. - const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3), 0, 1500); + const d = setSliceAmount(withPlan(draft({ amount: 3000 }), "2026-10", 3, 2), 0, 1500); expect(validatePurchase(d)).toEqual([]); }); test("a plan with zero months is rejected", () => { - expect(() => withPlan(draft(), "2026-10", 0)).toThrow(/at least one month/i); + expect(() => withPlan(draft(), "2026-10", 0, 2)).toThrow(/at least one month/i); }); test("split×plan composition: each slice divides across posts in the split ratio", () => { @@ -189,6 +189,7 @@ describe("finance plans", () => { }), "2026-10", 3, + 2, ); expect(composed.splits).toEqual([ { postId: "games", value: 70, absorbsRemainder: true }, diff --git a/src/ui/purchaseForm.ts b/src/ui/purchaseForm.ts index 70dcfb0..b76025e 100644 --- a/src/ui/purchaseForm.ts +++ b/src/ui/purchaseForm.ts @@ -45,10 +45,10 @@ export function emptyDraft(monthId: MonthId, postId: string): PurchaseDraft { } /** What the split editor must show: how far the parts are from the whole. */ -export function splitBalance(draft: PurchaseDraft): number { +export function splitBalance(draft: PurchaseDraft, digits: number): number { const target = draft.splitMode === "percent" ? 100 : draft.amount; const sum = draft.splits.reduce((total, split) => total + split.value, 0); - return roundMoney(target - sum, draft.currency); + return roundMoney(target - sum, digits); } export function validatePurchase(draft: PurchaseDraft): string[] { @@ -104,12 +104,18 @@ export function withPlan( draft: PurchaseDraft, startMonth: MonthId, months: number, + digits: number, ): PurchaseDraft { return { ...draft, plan: { startMonth, - slices: equalSlices({ amount: draft.amount, currency: draft.currency }, startMonth, months), + slices: equalSlices( + { amount: draft.amount, currency: draft.currency }, + startMonth, + months, + digits, + ), }, }; } @@ -140,9 +146,13 @@ export function setSliceAmount( * slices and compare to the total" arithmetic has exactly one implementation. * Positive means the slices fall short of the purchase total. */ -export function planBalance(draft: PurchaseDraft): number { +export function planBalance(draft: PurchaseDraft, digits: number): number { if (!draft.plan) return 0; - return slicesBalance({ amount: draft.amount, currency: draft.currency }, draft.plan.slices); + return slicesBalance( + { amount: draft.amount, currency: draft.currency }, + draft.plan.slices, + digits, + ); } export function fromPurchase(purchase: Purchase): PurchaseDraft { diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx index d021408..c3bdf34 100644 --- a/src/ui/routes/PostMonthRoute.tsx +++ b/src/ui/routes/PostMonthRoute.tsx @@ -15,7 +15,13 @@ export function PostMonthRoute() { const row = monthView(dataset, monthId).rows.find((r) => r.post.id === postId); const thisMonth = dataset.purchases.flatMap((purchase) => { - const charges = chargesForPurchaseInMonth(purchase, monthId, base, dataset.fxRates); + const charges = chargesForPurchaseInMonth( + purchase, + monthId, + base, + dataset.fxRates, + dataset.currencies, + ); const mine = charges.find((c) => c.postId === postId); return mine ? [{ purchase, amount: mine.amount }] : []; }); diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx index d19aa8f..9d23cae 100644 --- a/src/ui/routes/settings/CurrencySection.tsx +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -5,9 +5,17 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; -import { setBaseCurrency, setFxRate, removeFxRate } from "../../../store/actions.ts"; +import { + addCurrency, + removeCurrency, + setBaseCurrency, + setFxRate, + removeFxRate, + updateCurrency, +} from "../../../store/actions.ts"; import { DEFAULT_FX_API_URL, fetchFxRates } from "../../../store/fxApi.ts"; -import { CURRENCIES, type Currency } from "../../../domain/types.ts"; +import { currencyUsage } from "../../../domain/currencies.ts"; +import type { Currency } from "../../../domain/types.ts"; export function CurrencySection() { const dataset = useDataset(); @@ -15,7 +23,8 @@ export function CurrencySection() { const base = dataset.settings.baseCurrency; const apiUrl = dataset.settings.fxApiUrl ?? DEFAULT_FX_API_URL; const [status, setStatus] = useState(null); - const targets = CURRENCIES.filter((c) => c !== base); + const [error, setError] = useState(null); + const targets = dataset.currencies.map((c) => c.code).filter((c) => c !== base); async function refresh() { setStatus("Fetching…"); @@ -24,94 +33,198 @@ export function CurrencySection() { mutate((draft) => { for (const rate of rates) setFxRate(draft, rate); }); - setStatus(`Updated ${rates.length} rate(s).`); - } catch (error) { + const missing = targets.filter((t) => !rates.some((r) => r.currency === t)); + setStatus( + `Updated ${rates.length} rate(s).` + + (missing.length > 0 + ? ` The service returned nothing for ${missing.join(", ")} — type those in by hand.` + : ""), + ); + } catch (cause) { // Fails soft: the cached rates are still in place, never cleared. setStatus( `Could not fetch rates (${ - error instanceof Error ? error.message : String(error) + cause instanceof Error ? cause.message : String(cause) }). Your saved rates are unchanged.`, ); } } - return ( -
+ /** Currency edits throw on bad input, and a throw from a handler is invisible. */ + function tryEdit(change: () => void) { + try { + setError(null); + change(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } -
- + return ( +
+ {error &&

{error}

} + +
+
- - - - - - - - - - {targets.map((currency) => { - const rate = dataset.fxRates.find((r) => r.currency === currency); - return ( - - - + + + + ); + })} + +
Currency1 unit = ? {base}Source
{currency} - { - // A cleared or non-positive field MUST remove the rate - // row rather than store 0: toBase() converts a stored 0 - // silently to a 0-value conversion, while an absent row - // correctly throws MissingRateError. Storing 0 here - // would turn a loud, correct error into wrong numbers - // presented as fact. - const parsed = Number(event.target.value); - mutate((draft) => { - if (Number.isFinite(parsed) && parsed > 0) { - setFxRate(draft, { - currency, - baseUnitsPerOne: parsed, - updatedAt: new Date().toISOString(), - source: "manual", +
+ + + + + + + + + + + + + {dataset.currencies.map((currency) => { + const isBase = currency.code === base; + const rate = dataset.fxRates.find((r) => r.currency === currency.code); + const used = currencyUsage(dataset, currency.code); + return ( + + + + + + - - - ); - })} - -
CodeNameSymbolDecimals1 unit = ? {base}Source +
{currency.code} + { + const name = event.target.value; + mutate((draft) => updateCurrency(draft, currency.code, { name })); + }} + /> + + { + const symbol = event.target.value; + mutate((draft) => updateCurrency(draft, currency.code, { symbol })); + }} + /> + + { + const digits = Number(event.target.value); + if (!Number.isInteger(digits) || digits < 0 || digits > 4) return; + mutate((draft) => updateCurrency(draft, currency.code, { digits })); + }} + /> + + {isBase ? ( + 1 + ) : ( + { + // A cleared or non-positive field MUST remove the rate + // row rather than store 0: toBase() converts a stored 0 + // silently to a 0-value conversion, while an absent row + // correctly throws MissingRateError. Storing 0 here + // would turn a loud, correct error into wrong numbers + // presented as fact. + const parsed = Number(event.target.value); + mutate((draft) => { + if (Number.isFinite(parsed) && parsed > 0) { + setFxRate(draft, { + currency: currency.code, + baseUnitsPerOne: parsed, + updatedAt: new Date().toISOString(), + source: "manual", + }); + } else { + removeFxRate(draft, currency.code); + } }); - } else { - removeFxRate(draft, currency); - } - }); - }} - /> - - {rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"} -
+ }} + /> + )} +
+ {isBase ? "—" : rate ? `${rate.source} · ${rate.updatedAt.slice(0, 10)}` : "not set"} + + +
+
-
-
- + tryEdit(() => mutate((draft) => addCurrency(draft, def)))} /> + +
+
+ -

+

{"{base}"} and {"{targets}"} are substituted. Fetching is optional — the app - works offline with the rates you type in above. + works offline with the rates you type in above, and a currency the service + does not know can always be maintained by hand.

- {status &&

{status}

} + {status &&

{status}

}
); } + +/** + * Decimals default to 2 because almost every currency uses hundredths; the + * field is there for the ones that do not (yen at 0, dinars at 3). + */ +function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) { + const [code, setCode] = useState(""); + const [name, setName] = useState(""); + const [symbol, setSymbol] = useState(""); + const [digits, setDigits] = useState("2"); + + const parsedDigits = Number(digits); + const canAdd = + /^[A-Za-z]{2,8}$/.test(code.trim()) && + Number.isInteger(parsedDigits) && + parsedDigits >= 0 && + parsedDigits <= 4; + + function add() { + onAdd({ code: code.trim(), digits: parsedDigits, symbol, name }); + setCode(""); + setName(""); + setSymbol(""); + setDigits("2"); + } + + return ( +
+ { + const next = event.target.value; + setCode(next); + }} + /> + { + const next = event.target.value; + setName(next); + }} + /> + { + const next = event.target.value; + setSymbol(next); + }} + /> +
+ + { + const next = event.target.value; + setDigits(next); + }} + /> +
+ +
+ ); +} diff --git a/src/ui/routes/settings/PostsSection.tsx b/src/ui/routes/settings/PostsSection.tsx index 7e979ef..f74dbeb 100644 --- a/src/ui/routes/settings/PostsSection.tsx +++ b/src/ui/routes/settings/PostsSection.tsx @@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input"; import { useDataset } from "../../hooks/useDataset.ts"; import { useMutate } from "../../hooks/useMutate.ts"; import { addPost, movePost, setPostArchived, updatePost } from "../../../store/actions.ts"; -import { CURRENCIES, type Currency, type Post } from "../../../domain/types.ts"; +import type { Currency, Post } from "../../../domain/types.ts"; import { ruleAt } from "../../../domain/allocation.ts"; import { currentMonth } from "../../../store/index.ts"; import { RuleHistory } from "../../components/RuleHistory.tsx"; @@ -72,7 +72,7 @@ export function PostsSection() { mutate((draft) => updatePost(draft, post.id, { currency })); }} > - {CURRENCIES.map((currency) => ( + {dataset.currencies.map(({ code: currency }) => (