diff --git a/AGENTS.md b/AGENTS.md index 93b5672..800a96d 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,16 +15,26 @@ and `docs/specs/`, and is not repeated here. (`src/domain/smoke.test.ts` imports it without one). - **Money is a float, rounded at every boundary.** Use `roundMoney` from `src/domain/money.ts` after any division, after any FX conversion, and before - persisting. + persisting. DISPLAY is a boundary too: `src/ui/format.ts` takes digits like + everything else, and `useMoneyFormat()` is how a component gets them bound to + the dataset. A pinned 2 lived in that file once and made the decimals setting + invisible at 0 and lossy at 3. - **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.** - Decimal places live in `Dataset.currencies`, because the owner defines - currencies; resolve them with `digitsFor(dataset.currencies, code)`. The - argument is required on purpose — a default of 2 is right for every currency - the app shipped with, so a missed call site would pass every test and be - silently wrong for the first zero-decimal currency added. + Decimal places are ONE setting for the whole dataset — `settings.digits` — + and every currency rounds to it. Read that field; there is no `digitsFor`, + and `CurrencyDef` has no `digits`. The argument is required on purpose: a + default of 2 is right while the setting is 2, so a missed call site would + pass every test and go silently wrong the moment the owner moves it. + A dataset mixing minor units (yen at 0 beside kroner at 2) CANNOT be + described, and the app does not warn — the owner chose that, and + `docs/specs/2026-09-02-global-decimals-design.md` records the argument and + what re-adding a per-currency override would cost. Do not "fix" it by + reintroducing per-currency digits without that decision being revisited. - **When splitting an amount, never round the last part independently.** Compute it as `total - sum(others)` so the parts sum exactly to the whole. -- **`Currency` is an open string code; `Dataset.currencies` is the authority.** +- **`Currency` is an open string code; `Dataset.currencies` is the authority** + on which codes exist and on their names and symbols — NOT on their decimals, + which are dataset-wide per the rule above. There is no global list of valid currencies. A code is IDENTITY — it keys the FX table and every stored `Money` — so codes are normalised uppercase, are never editable, and a currency still referenced cannot be removed @@ -82,11 +92,14 @@ ship twice — re-introducing one is a regression, not a style choice. rejection: the button appears to do nothing and the user is told nothing. `useMutate` is the guard for most call sites. → `src/ui/storeWriteErrors.test.ts` -- **`roundMoney` call sites must resolve real digits**, per §1. +- **`roundMoney` call sites must read `settings.digits`**, per §1 — never a + literal, and never a per-currency lookup. → `src/domain/currencyDigits.test.ts` is the only test that can tell a real - implementation from one that assumes 2: it drives a JPY-style zero-decimal - currency through rounding, both FX directions, both split modes, plan - division, the fold and the month view. + implementation from one that assumes 2: it drives a dataset at ZERO decimals + through rounding, both FX directions, both split modes, plan division, the + fold and the month view, and asserts that raising the setting to 2 changes + what the fold produces. It cannot prove digits vary per currency, because + they no longer do. - **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see `readBasePathEnv`. Bun inlines the literal only when the variable is set; unset, the bare reference reaches the browser and throws before the app boots. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eace194..70da643 100755 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,14 +95,21 @@ A float, deliberately, with two rules that make it safe: **`FxRate.baseUnitsPerOne` is never rounded.** Rates are stored at six decimals and are not money; quantizing one to cents is wrong in kind. -**Decimal places are data, not a constant.** `Dataset.currencies` records each -currency's code, name, symbol and digits, because the owner can define their -own. `roundMoney` therefore takes a digit count and every call site resolves it -with `digitsFor` — required rather than defaulted, so a missed site is a -compile error instead of something that works for every 2-decimal currency and -breaks on the first zero-decimal one. `src/domain/currencyDigits.test.ts` drives -a zero-decimal currency through the whole stack, which is the only test that can -tell a real implementation from one that assumes 2. +**Decimal places are data, not a constant — one value for the whole dataset.** +`Settings.digits` is what every amount rounds to, whatever its currency; +`Dataset.currencies` records only a code, name and symbol. `roundMoney` takes a +digit count and every call site reads that setting, required rather than +defaulted, so a missed site is a compile error instead of something that works +while the setting is 2 and breaks the moment it is not. +`src/domain/currencyDigits.test.ts` drives a dataset at zero decimals through +the whole stack, which is the only test that can tell a real implementation +from one that assumes 2. + +The cost is explicit: a dataset mixing minor units — yen at 0 beside kroner at +2 — cannot be described, and nothing in the app will warn about it. That was +the owner's call, argued in full in +[`specs/2026-09-02-global-decimals-design.md`](specs/2026-09-02-global-decimals-design.md), +which also says what re-adding a per-currency override would take. ## Splits and finance plans compose diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index bcb33a6..f173a2e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -18,7 +18,7 @@ record. no household mode. → [`PRODUCT.md`](PRODUCT.md) - **Schema migrations are mandatory, not optional.** `schemaVersion` plus ordered migration functions applied on load, because IndexedDB holds the - user's only copy of their financial data. Now at version 5. + user's only copy of their financial data. Now at version 6. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **A destructive write downloads a backup first.** Import and reset both export before replacing, and abort if the export fails. → commit `ac3d18a` @@ -33,13 +33,21 @@ record. last part as `total - sum(others)`. → [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md) - **`roundMoney` takes a digit count, and the argument is required.** Rejected a - default of 2: it is correct for every currency the app shipped with, so a - missed call site would pass every test and fail on the first zero-decimal - currency. → [`AGENTS.md`](../AGENTS.md) §1, commit `cdece63` -- **`Currency` is an open string code; `Dataset.currencies` is the authority.** - Rejected a closed union, which would have made "add a currency" a code change. - The compile-time safety is replaced by validation at the boundaries — - `parseDatasetJson` rejects a code no currency defines. + default of 2: it is correct while the decimals setting is 2, so a missed call + site would pass every test and fail the moment it moves. + → [`AGENTS.md`](../AGENTS.md) §1, commit `cdece63` +- **Decimal places are ONE dataset-wide setting, not per currency.** + `Settings.digits`; `CurrencyDef.digits` is gone. Chosen over a per-currency + default and over display-only rounding, and knowingly over correctness for + mixed minor units: a dataset holding both yen and kroner can no longer be + described, and the app does not warn. The owner asked for the simpler model + with that trade-off in front of them. + → [`specs/2026-09-02-global-decimals-design.md`](specs/2026-09-02-global-decimals-design.md) +- **`Currency` is an open string code; `Dataset.currencies` is the authority** + on which codes exist, their names and their symbols — but no longer on their + decimals. Rejected a closed union, which would have made "add a currency" a + code change. The compile-time safety is replaced by validation at the + boundaries — `parseDatasetJson` rejects a code no currency defines. → `src/domain/types.ts`, commit `cdece63` - **Every derived number comes from one left fold.** `src/domain/fold.ts` is the only implementation; year and summary views aggregate over it rather than diff --git a/docs/specs/2026-09-02-global-decimals-design.md b/docs/specs/2026-09-02-global-decimals-design.md new file mode 100644 index 0000000..a647d0d --- /dev/null +++ b/docs/specs/2026-09-02-global-decimals-design.md @@ -0,0 +1,260 @@ +# One decimal setting for the whole dataset + +Replaces `CurrencyDef.digits` — decimal places defined per currency — with a +single `Settings.digits` that every rounding site uses, whatever the currency. + +Requested directly by the owner, with the trade-off below stated and accepted +before the work started. This document is the record of what was given up, so +that a later reader does not mistake it for an oversight. + +## What this costs + +A dataset whose currencies have different minor units can no longer be +described. That is not a corner case; it is the case the current design exists +for: + +| currencies held | `settings.digits` | result | +| --- | --- | --- | +| DKK (2), JPY (0) | `2` | ¥1234.56 — yen have no minor unit, so this is not an amount that can exist | +| DKK (2), JPY (0) | `0` | 49.95 DKK rounds to 50; the fold's carry drifts from the bank's | +| DKK (2), USD (2), EUR (2), GBP (2) | `2` | correct — every currency agrees | + +The third row is the shipped dataset, which is why this is safe *today*. It +stops being safe the first time a zero- or three-decimal currency is added, and +nothing in the app will say so. Adding a per-currency override later is the +escape hatch, and §"If this needs undoing" says what that would take. + +This directly supersedes the AGENTS.md §1 invariant "`roundMoney(amount, +digits)` takes a DIGIT COUNT... Decimal places live in `Dataset.currencies`". +That bullet gets rewritten, not left standing — three files disagreeing about +where digits live is worse than the change itself. + +## The shape + +### 1. Types + +```ts +export interface Settings { + baseCurrency: Currency; + foldStartMonth: MonthId; + schemaVersion: number; + /** Decimal places for EVERY amount, whatever its currency. Integer 0-4. */ + digits: number; + fxApiUrl?: string; +} + +export interface CurrencyDef { + code: Currency; + symbol?: string; + name?: string; + // `digits` removed. +} +``` + +`digits` is required on `Settings`, not optional. An optional field would mean +every read needs a `?? 2` fallback, and a fallback of 2 is exactly the silent +wrongness the old required-argument rule was built to prevent. + +`DEFAULT_CURRENCY_DIGITS` is renamed `DEFAULT_DIGITS` and keeps its one job: +the value a brand-new dataset seeds with. + +### 2. `digitsFor` is deleted, not re-pointed + +`digitsFor(currencies, code)` resolves a code against a table. With one global +value the code argument is meaningless, and a function that accepts an argument +it ignores invites call sites to believe it still matters. + +So the sixteen call sites, spread over ten files, resolve +`dataset.settings.digits` directly, and the domain functions that took +`currencies: readonly CurrencyDef[]` *only* to resolve digits take +`digits: number` instead: + +| file | today | after | +| --- | --- | --- | +| `domain/allocation.ts` | `resolveRule(..., currencies)` | `resolveRule(..., digits)` | +| `domain/fx.ts` | `toBase/fromBase(..., currencies)` | `..., digits` (rates still passed) | +| `domain/charges.ts` | three `digitsFor` calls: purchase currency, base, base again | one `digits` | +| `domain/fold.ts`, `domain/views.ts` | `digitsFor(dataset.currencies, base)` | `dataset.settings.digits` | +| `store/actions.ts` | `digitsFor(draft.currencies, money.currency)` | `draft.settings.digits` | +| `export/ods.ts` | `digitsFor(dataset.currencies, base)` | `dataset.settings.digits` | +| `ui/components/{PlanEditor,SplitEditor,BulkLines}.tsx` | `digitsFor(useDataset().currencies, draft.currency)` | `useDataset().settings.digits` | + +If the recurring-costs change (`2026-09-03-recurring-costs-design.md`, schema +6) has landed first, `domain/occurrences.ts` and the expected-charge half of +`domain/charges.ts` are two more call sites resolving `settings.digits` the same +way. Neither changes the shape of this work. + +This is a genuine simplification: those domain functions never wanted a +currency table, only a number, and the table was threaded through purely to +resolve one. `currencies` stays a parameter only where it is used for something +else — symbols, names, validation. + +`currencyCodes`, `findCurrency`, `normalizeCurrencyCode` and `currencyUsage` +are untouched. Codes remain identity; nothing here changes that. + +### 3. Migration to the next free schema version + +This step takes **whichever version is next when it is built**: 6 if it lands +before the recurring-costs step, 7 if after. The two are independent and +compose in either order, so neither spec can name its number in advance without +presuming which is implemented first. Code is still at `SCHEMA_VERSION = 5`, so +nothing is committed to yet. + +Call it N below. `SCHEMA_VERSION` becomes N, and one step is appended. + +**Implemented as 6**: this landed before the recurring-costs step, which is +therefore reassigned to 7. The constant is `V6_FALLBACK_DIGITS`. + +- sets `settings.digits` to the **maximum** of the per-currency `digits` it + finds in `data.currencies`; +- strips `digits` from every currency entry; +- falls back to a frozen local `const VN_FALLBACK_DIGITS = 2` — named for the + version it lands as — when the table is missing, empty, or carries no usable + number. + +Maximum rather than the base currency's value, because the migration must not +make an amount already stored less precise than it is. A dataset holding KWD at +3 has amounts with three decimals in it; migrating to the base's 2 would mean +the next edit of one of those amounts silently truncates it. Taking the maximum +can only ever keep more precision than before, never less. + +Per AGENTS.md §2 the step reads no live default — that fallback is a +frozen local literal, and `data.currencies` is the user's own data, not a +default. Existing steps are not edited. + +Behaviour on the shipped dataset is preserving by construction: every seeded +currency records 2, so the maximum is 2 and not one stored amount rounds +differently than it did before. + +### 4. Import validation + +In `export/json.ts`: + +- `requireCurrencyTable` stops validating a per-entry `digits`, and stops + copying it into the parsed entry. Duplicate-code and code-shape checks stay. +- a new `settings.digits` check: present, a number, an integer, `0 <= n <= 4`. + Same bounds the per-currency check used, same error style. +- `parseDatasetJson` already calls `migrate` before validating, so a file + exported at the previous version imports correctly through this step. One that still carries + per-currency `digits` after migration has them ignored rather than rejected — + an unknown field is not a reason to refuse the owner's only backup. + +### 5. UI + +`ui/routes/settings/CurrencySection.tsx`: + +- the `Decimals` column leaves the table — header, cell, and the `w-16` input. + The table loses ~80px, which is what prompted this; +- `AddCurrency` loses its decimals field and its `digits` state; +- one new control beside "Base currency", labelled `Decimals (all amounts)`: + a `type="number"` `min=0 max=4 step=1` input writing through a new + `setDigits(draft, n)` action, rejecting out-of-range input the way the old + per-row input did; +- `setDigits` goes in `store/actions.ts` beside `setBaseCurrency`, and the call + site captures `event.target.value` into a `const` before `mutate` — AGENTS.md + §2, the deferred-write rule. + +**Changing the setting does not rewrite stored amounts.** An amount stored as +49.95 stays 49.95 when the setting moves to 0; it displays as 50 and every +total recomputes at the new precision, but storage is left alone. Rewriting +every `Money` in the dataset is a destructive whole-dataset write, which +AGENTS.md §1 requires a backup export for, and it is lossy in one direction — +going 0 → 2 cannot bring back what 2 → 0 discarded. The setting is a rounding +rule going forward, not a data rewrite. The control gets a hint saying so. + +### 6. Tests + +- **`domain/currencyDigits.test.ts` is rewritten, not deleted.** Its teeth come + from driving a non-2 digit count end-to-end, and that still works: the + dataset gets `settings.digits: 0` globally and the same paths are exercised — + `roundMoney`, both FX directions, both split modes, plan division, the fold, + the month view. It can no longer prove digits flow *per currency*, because + they no longer do; it still proves they flow *from the dataset* rather than + being assumed to be 2, which is what the guard is for. +- **`domain/currencies.test.ts`** loses its `digitsFor` block. +- **`store/migrations.test.ts`** gains a case for this step: mixed per-currency digits + collapse to the maximum, entries come out without `digits`, an absent table + yields the frozen fallback. The existing no-live-default assertion covers the + new step automatically. +- **`export/json.test.ts`** gains `settings.digits` acceptance and rejection + cases (missing, `2.5`, `5`, `-1`), and loses per-currency `digits` rejection. +- 41 `digits:` literals across 15 test files construct `CurrencyDef`s or + datasets and need mechanical updating. This is the bulk of the diff and the + main risk of the change: a test left constructing a currency with `digits` + will still typecheck as an excess property in some positions. + +### 7. Documents + +- **AGENTS.md §1** — the `roundMoney` bullet is rewritten around + `settings.digits`; the `Dataset.currencies` bullet drops "how many decimals + they round to" from what the table is authority for. +- **AGENTS.md §2** — the `currencyDigits.test.ts` guard bullet is rewritten to + describe the global-zero-decimals drive, and to say plainly that a + mixed-decimal dataset is no longer representable, so nobody re-adds the old + guard as a "missing" check. +- **docs/DECISIONS.md** — a new entry under "Money and the data model" pointing + here, and the schema line updated to N. That line reads "Now at version 5" + today; if the recurring-costs step lands first it will already say 6, so + match what is actually there rather than searching for the 5. +- **docs/ARCHITECTURE.md** — updated wherever it describes digits as living per + currency. + +## Alternatives considered + +- **A default for new currencies, per-currency kept in the data.** One visible + setting feeding `AddCurrency`, the column gone from the table, `digitsFor` + untouched. Gets the table narrower with no migration and no invariant + rewritten, and keeps a JPY expressible. Rejected by the owner in favour of + the genuinely global setting; it remains the cheapest path back. +- **Display-only rounding.** A preference that rounds what the UI shows while + storage keeps full precision. Rejected: displayed parts stop summing to + displayed totals, which reads as a bug in a budgeting app. +- **Keeping the column.** The horizontal scrollbar that prompted this turned + out to be an 8px negative margin on the row's remove button, fixed separately + and already resolved, so nothing about the table's width requires this change. + It proceeds on the owner's preference for a simpler model, not on necessity. + +## If this needs undoing + +Re-adding an optional `CurrencyDef.digits` that overrides `settings.digits` +when present is a later migration that needs no data change — absent means +"use the global". The domain functions would take `digits: number` still, with +resolution moving up to the call site. The expensive half of this change, the +41 test constructions, does not have to be undone to get there. + +## Display, which this spec got wrong + +An earlier draft put formatting out of scope, on the assumption that +`src/ui/format.ts` already read digits and would simply follow the new source. +It did not: it pinned `minimumFractionDigits: 2, maximumFractionDigits: 2` in a +module-level `Intl.NumberFormat`, so display never consulted the dataset at +all. That was equally broken before this change — a zero-decimal currency +rendered "3,333.00" under the per-currency table too — so it is a pre-existing +gap rather than a regression, but shipping a prominent Settings control that +display ignores would have made the setting look broken at 0 and been LOSSY at +3 and 4, hiding precision the dataset really holds. + +So display is treated as the rounding boundary it is: + +- the four functions in `format.ts` take `digits` explicitly, with no default, + exactly as `roundMoney` does. `Intl.NumberFormat` instances are cached per + digit count, since constructing one is the expensive part and a table of + thirty money cells would otherwise build thirty per render; +- `src/ui/hooks/useMoneyFormat.ts` binds them to `settings.digits` and is what + components call — 64 call sites across 10 files. A hook rather than threaded + props because display is needed at every depth and the setting is one value + for the whole dataset; the underlying functions stay pure and testable + without a React tree; +- `postRowCells` in `PostTable.tsx` is a plain helper, not a component, so it + takes a `MoneyFormat` argument instead of calling the hook. + +Verified in Chrome: the same month reads `600` / `206` at zero decimals and +`600.000` / `205.561` at three — so the figures are recomputed, not merely +reformatted. + +## Out of scope + +Parsing (`ui/moneyInput.ts`, `ui/purchaseForm.ts`, `ui/bulkEntry.ts`) already +takes digits as an argument and needed no change beyond its new source. +Currency symbols, names, the FX table, the rate service and the fold's math are +all untouched. diff --git a/src/domain/allocation.test.ts b/src/domain/allocation.test.ts index 19f5d08..c2c16a2 100644 --- a/src/domain/allocation.test.ts +++ b/src/domain/allocation.test.ts @@ -12,11 +12,14 @@ 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" }, + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, + { code: "EUR", symbol: "\u20ac" }, ]; +/** The dataset\'s decimal places; every currency rounds to this. */ +const DIGITS = 2; + 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" }, @@ -42,7 +45,7 @@ function post(overrides: Partial = {}): Post { function dataset(overrides: Partial = {}): Dataset { return { - settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 2, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: rates, posts: [post()], @@ -56,21 +59,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, TEST_CURRENCIES)).toBe(400); + expect(resolveRule(fixed(400), 20000, "DKK", rates, DIGITS)).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, TEST_CURRENCIES), + resolveRule({ kind: "fixed", amount: { amount: 50, currency: "EUR" } }, 20000, "DKK", rates, DIGITS), ).toBe(373); }); test("a percentage rule resolves against income", () => { - expect(resolveRule(pct(25), 20000, "DKK", rates, TEST_CURRENCIES)).toBe(5000); + expect(resolveRule(pct(25), 20000, "DKK", rates, DIGITS)).toBe(5000); }); test("a percentage above 100 is permitted", () => { - expect(resolveRule(pct(150), 1000, "DKK", rates, TEST_CURRENCIES)).toBe(1500); + expect(resolveRule(pct(150), 1000, "DKK", rates, DIGITS)).toBe(1500); }); }); @@ -184,7 +187,7 @@ describe("the fold across a rule change", () => { // The feature's headline behaviour: 10% through June, 15% from July, with the // rollover carrying correctly across the boundary. const data: Dataset = { - settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-05", schemaVersion: 2, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: [], posts: [ diff --git a/src/domain/allocation.ts b/src/domain/allocation.ts index f459999..18308c6 100644 --- a/src/domain/allocation.ts +++ b/src/domain/allocation.ts @@ -1,10 +1,8 @@ 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, @@ -21,12 +19,12 @@ export function resolveRule( income: number, baseCurrency: Currency, rates: FxRate[], - currencies: readonly CurrencyDef[], + digits: number, ): number { if (rule.kind === "fixed") { - return toBase(rule.amount, baseCurrency, rates, currencies); + return toBase(rule.amount, baseCurrency, rates, digits); } - return roundMoney((income * rule.percent) / 100, digitsFor(currencies, baseCurrency)); + return roundMoney((income * rule.percent) / 100, digits); } /** @@ -72,7 +70,7 @@ export function incomeFor(dataset: Dataset, monthId: MonthId): number { month.income, dataset.settings.baseCurrency, dataset.fxRates, - dataset.currencies, + dataset.settings.digits, ); } @@ -91,6 +89,6 @@ export function allocationFor( incomeFor(dataset, monthId), dataset.settings.baseCurrency, dataset.fxRates, - dataset.currencies, + dataset.settings.digits, ); } diff --git a/src/domain/charges.test.ts b/src/domain/charges.test.ts index 83b590e..c5a0512 100644 --- a/src/domain/charges.test.ts +++ b/src/domain/charges.test.ts @@ -10,11 +10,14 @@ 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" }, + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, + { code: "EUR", symbol: "\u20ac" }, ]; +/** The dataset\'s decimal places; every currency rounds to this. */ +const DIGITS = 2; + 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" }, @@ -131,14 +134,14 @@ describe("sliceAmountForMonth", () => { describe("chargesForPurchaseInMonth", () => { test("a simple purchase charges one post", () => { - expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates, TEST_CURRENCIES)).toEqual([ + expect(chargesForPurchaseInMonth(purchase(), "2026-09", "DKK", rates, DIGITS)).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, TEST_CURRENCIES)).toEqual([ + expect(chargesForPurchaseInMonth(eur, "2026-09", "DKK", rates, DIGITS)).toEqual([ { postId: "food", amount: 74.6 }, ]); }); @@ -157,11 +160,11 @@ describe("chargesForPurchaseInMonth", () => { ], }, }); - expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([ + expect(chargesForPurchaseInMonth(financedSplit, "2026-10", "DKK", rates, DIGITS)).toEqual([ { postId: "games", amount: 700 }, { postId: "events", amount: 300 }, ]); - expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates, TEST_CURRENCIES)).toEqual([ + expect(chargesForPurchaseInMonth(financedSplit, "2026-11", "DKK", rates, DIGITS)).toEqual([ { postId: "games", amount: 1400 }, { postId: "events", amount: 600 }, ]); @@ -179,14 +182,14 @@ describe("chargesForPurchaseInMonth", () => { slices: [{ month: "2026-10", amount: { amount: 600, currency: "DKK" } }], }, }); - expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates, TEST_CURRENCIES)).toEqual([ + expect(chargesForPurchaseInMonth(financed, "2026-10", "DKK", rates, DIGITS)).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, TEST_CURRENCIES)).toEqual([]); + expect(chargesForPurchaseInMonth(purchase(), "2026-01", "DKK", rates, DIGITS)).toEqual([]); }); test("slice charges always sum to the slice total", () => { @@ -198,7 +201,7 @@ describe("chargesForPurchaseInMonth", () => { { postId: "c", value: 1, absorbsRemainder: true }, ], }); - const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates, TEST_CURRENCIES); + const charges = chargesForPurchaseInMonth(odd, "2026-09", "DKK", rates, DIGITS); expect(roundMoney(charges.reduce((sum, c) => sum + c.amount, 0), 2)).toBe(100); }); }); @@ -206,7 +209,7 @@ describe("chargesForPurchaseInMonth", () => { describe("chargesForMonth", () => { test("sums charges per post across all purchases", () => { const data: Dataset = { - settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: rates, posts: [], @@ -224,7 +227,7 @@ describe("chargesForMonth", () => { test("a month with no purchases yields an empty map", () => { const data: Dataset = { - settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: rates, posts: [], diff --git a/src/domain/charges.ts b/src/domain/charges.ts index d6a8252..de00426 100644 --- a/src/domain/charges.ts +++ b/src/domain/charges.ts @@ -1,10 +1,8 @@ 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, @@ -73,18 +71,19 @@ export function chargesForPurchaseInMonth( monthId: MonthId, baseCurrency: Currency, rates: FxRate[], - currencies: readonly CurrencyDef[], + digits: number, ): Charge[] { const slice = sliceAmountForMonth(purchase, monthId); if (!slice) return []; - const sliceBase = toBase(slice, baseCurrency, rates, currencies); + const sliceBase = toBase(slice, baseCurrency, rates, digits); const index = remainderIndexOf(purchase); // 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)); + // month's slice is distributed to those proportions in the BASE currency. + // One setting covers both roundings now; it used to be two lookups, one per + // currency, which is the only thing that changed here. + const weights = splitPartsOfTotal(purchase, digits); + const parts = distributeByWeight(sliceBase, weights, index, digits); return purchase.splits.map((split, i) => ({ postId: split.postId, @@ -98,14 +97,14 @@ export function chargesForMonth( monthId: MonthId, ): Map { const totals = new Map(); - const baseDigits = digitsFor(dataset.currencies, dataset.settings.baseCurrency); + const baseDigits = dataset.settings.digits; for (const purchase of dataset.purchases) { const charges = chargesForPurchaseInMonth( purchase, monthId, dataset.settings.baseCurrency, dataset.fxRates, - dataset.currencies, + dataset.settings.digits, ); for (const charge of charges) { const previous = totals.get(charge.postId) ?? 0; diff --git a/src/domain/currencies.test.ts b/src/domain/currencies.test.ts index bb95eaf..97dca28 100644 --- a/src/domain/currencies.test.ts +++ b/src/domain/currencies.test.ts @@ -1,30 +1,18 @@ import { test, expect, describe } from "bun:test"; -import { digitsFor, currencyCodes, findCurrency, normalizeCurrencyCode } from "./currencies.ts"; +import { 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 }, + { code: "DKK", symbol: "kr" }, + { code: "JPY", symbol: "¥" }, + { code: "KWD" }, ]; -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); - }); -}); +/* + * `digitsFor` was tested here. Decimal places are one dataset-wide setting + * now, so there is no per-currency resolution left to assert; that the setting + * reaches every rounding site is what src/domain/currencyDigits.test.ts drives. + */ describe("normalizeCurrencyCode", () => { test("codes are identity, so case and padding cannot fork one currency in two", () => { diff --git a/src/domain/currencies.ts b/src/domain/currencies.ts index 7bf2ddc..46acb96 100644 --- a/src/domain/currencies.ts +++ b/src/domain/currencies.ts @@ -1,30 +1,13 @@ -import { - DEFAULT_CURRENCY_DIGITS, - type Currency, - type CurrencyDef, - type Dataset, -} from "./types.ts"; +import { 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. +/* + * `digitsFor(currencies, code)` used to live here. Decimal places are now one + * setting for the whole dataset, `settings.digits`, so there is nothing to + * resolve a code against: rounding sites read that field directly, and domain + * functions that once took the currency table purely to look digits up take a + * `digits: number` instead. A function still accepting a code it ignored would + * only invite call sites to believe the code still mattered. */ -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[] { diff --git a/src/domain/currencyDigits.test.ts b/src/domain/currencyDigits.test.ts index b5cb1b6..dc15ed6 100644 --- a/src/domain/currencyDigits.test.ts +++ b/src/domain/currencyDigits.test.ts @@ -5,20 +5,28 @@ import { equalSlices, sliceTotal } from "./plans.ts"; import { resolveRule } from "./allocation.ts"; import { foldBalances, figuresFor } from "./fold.ts"; import { monthView } from "./views.ts"; +import { SCHEMA_VERSION } from "./seed.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. + * Decimal places are ONE setting for the whole dataset — `settings.digits` — + * and every currency rounds to it. See + * docs/specs/2026-09-02-global-decimals-design.md for what that gave up. * - * JPY is the real-world case: yen have no minor unit, so ¥3333.3 is not a - * number that can exist. + * Two decimals is right for every currency the app ships with, so code that + * hardcodes 2 is indistinguishable from code that reads the setting. Driving a + * dataset at zero decimals is the only thing that tells them apart, and that + * is what this file exists for: it proves digits reach each rounding site FROM + * THE DATASET. It can no longer prove they vary per currency, because they no + * longer do. + * + * Yen are the reason zero is a real setting and not a contrivance: ¥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 DIGITS = 0; + +const JPY: CurrencyDef = { code: "JPY", symbol: "¥" }; +const USD: CurrencyDef = { code: "USD", symbol: "$" }; const CURRENCIES = [JPY, USD]; describe("rounding takes its digits from the dataset", () => { @@ -31,52 +39,59 @@ describe("rounding takes its digits from the dataset", () => { expect(roundMoney(1.2345, 3)).toBe(1.234); }); - test("a percentage allocation in a zero-decimal currency yields whole units", () => { + test("a percentage allocation at zero decimals yields whole units", () => { const rate = resolveRule( { kind: "percentOfIncome", percent: 33.333 }, 10_000, "JPY", [], - CURRENCIES, + DIGITS, ); expect(rate).toBe(3333); expect(Number.isInteger(rate)).toBe(true); }); - test("an FX conversion into a zero-decimal base yields whole units", () => { + test("an FX conversion at zero decimals yields whole units both ways", () => { 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); + expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, DIGITS)).toBe(1512); + // Back out: 1512 / 151.234567 = 9.9977..., and the SAME setting rounds it, + // so 10. Under per-currency digits this rounded to USD's two places; the + // arithmetic differs, the "no fractional unit escapes" property does not. + expect(fromBase(1512, "USD", "JPY", rates, DIGITS)).toBe(10); + expect(Number.isInteger(fromBase(1512, "USD", "JPY", rates, DIGITS))).toBe(true); }); - test("a split distributed in a zero-decimal currency still sums exactly", () => { - const parts = distributeByWeight(1000, [1, 1, 1], 2, 0); + test("a split distributed at zero decimals still sums exactly", () => { + const parts = distributeByWeight(1000, [1, 1, 1], 2, DIGITS); 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); + test("fixed amounts distributed at zero decimals still sum exactly", () => { + const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, DIGITS); 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); + const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, DIGITS); expect(slices.map((s) => s.amount.amount)).toEqual([333, 333, 334]); - expect(sliceTotal(slices, 0)).toBe(1000); + expect(sliceTotal(slices, DIGITS)).toBe(1000); }); }); -describe("the fold in a zero-decimal currency", () => { +describe("the fold at zero decimals", () => { function dataset(): Dataset { return { - settings: { baseCurrency: "JPY", foldStartMonth: "2026-01", schemaVersion: 3 }, + settings: { + baseCurrency: "JPY", + foldStartMonth: "2026-01", + schemaVersion: SCHEMA_VERSION, + digits: DIGITS, + }, currencies: CURRENCIES, fxRates: [], posts: [ @@ -116,4 +131,14 @@ describe("the fold in a zero-decimal currency", () => { expect(Number.isInteger(view.unallocated)).toBe(true); expect(view.unallocated).toBe(10_000 - 3333); }); + + /** + * The setting is what is read, not the base currency's old per-currency + * entry and not a constant: moving it to 2 has to change the numbers. + */ + test("raising the setting to two decimals changes what the fold produces", () => { + const twoPlaces = { ...dataset(), settings: { ...dataset().settings, digits: 2 } }; + const fold = foldBalances(twoPlaces, "2026-01"); + expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333.3); + }); }); diff --git a/src/domain/fold.test.ts b/src/domain/fold.test.ts index dc2933f..4028d48 100644 --- a/src/domain/fold.test.ts +++ b/src/domain/fold.test.ts @@ -4,11 +4,14 @@ 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" }, + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, + { code: "EUR", symbol: "\u20ac" }, ]; +/** The dataset\'s decimal places; every currency rounds to this. */ +const DIGITS = 2; + function post(id: string, fixed: number, archived = false): Post { return { id, @@ -34,7 +37,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 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: [], posts, @@ -139,7 +142,7 @@ test("percentage allocations follow the month's income", () => { test("months before foldStartMonth contribute nothing", () => { const data: Dataset = { ...dataset([post("food", 500)], [spend("a", "food", 5000, "2025-06-01")]), - settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, }; const fold = foldBalances(data, "2026-01"); expect(fold.has("2025-06")).toBe(false); diff --git a/src/domain/fold.ts b/src/domain/fold.ts index 042b913..cfe6291 100644 --- a/src/domain/fold.ts +++ b/src/domain/fold.ts @@ -1,5 +1,4 @@ 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"; @@ -37,7 +36,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 baseDigits = dataset.settings.digits; const fold: Fold = new Map(); if (compareMonths(upToMonth, foldStartMonth) < 0) return fold; diff --git a/src/domain/fx.test.ts b/src/domain/fx.test.ts index 9820dc7..ad19292 100644 --- a/src/domain/fx.test.ts +++ b/src/domain/fx.test.ts @@ -2,12 +2,8 @@ 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" }, -]; +/** The dataset's decimal places, which every currency now rounds to. */ +const DIGITS = 2; const rates: FxRate[] = [ { currency: "EUR", baseUnitsPerOne: 7.46, updatedAt: "2026-09-01", source: "manual" }, @@ -16,23 +12,23 @@ const rates: FxRate[] = [ describe("toBase", () => { test("passes base-currency values through untouched", () => { - expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates, TEST_CURRENCIES)).toBe(100); + expect(toBase({ amount: 100, currency: "DKK" }, "DKK", rates, DIGITS)).toBe(100); }); test("converts using baseUnitsPerOne", () => { - expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates, TEST_CURRENCIES)).toBe(74.6); + expect(toBase({ amount: 10, currency: "EUR" }, "DKK", rates, DIGITS)).toBe(74.6); }); test("rounds the conversion result", () => { - expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates, TEST_CURRENCIES)).toBe(22.98); + expect(toBase({ amount: 3.33, currency: "USD" }, "DKK", rates, DIGITS)).toBe(22.98); }); test("throws MissingRateError naming the currency", () => { - expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES)).toThrow( + expect(() => toBase({ amount: 1, currency: "USD" }, "DKK", [], DIGITS)).toThrow( MissingRateError, ); try { - toBase({ amount: 1, currency: "USD" }, "DKK", [], TEST_CURRENCIES); + toBase({ amount: 1, currency: "USD" }, "DKK", [], DIGITS); } catch (error) { expect((error as MissingRateError).currency).toBe("USD"); } @@ -41,10 +37,10 @@ describe("toBase", () => { describe("fromBase", () => { test("passes base through untouched", () => { - expect(fromBase(100, "DKK", "DKK", rates, TEST_CURRENCIES)).toBe(100); + expect(fromBase(100, "DKK", "DKK", rates, DIGITS)).toBe(100); }); test("inverts the conversion", () => { - expect(fromBase(74.6, "EUR", "DKK", rates, TEST_CURRENCIES)).toBe(10); + expect(fromBase(74.6, "EUR", "DKK", rates, DIGITS)).toBe(10); }); }); diff --git a/src/domain/fx.ts b/src/domain/fx.ts index edc1906..3c019c4 100644 --- a/src/domain/fx.ts +++ b/src/domain/fx.ts @@ -1,6 +1,5 @@ import { roundMoney } from "./money.ts"; -import { digitsFor } from "./currencies.ts"; -import type { Currency, CurrencyDef, FxRate, Money } from "./types.ts"; +import type { Currency, FxRate, Money } from "./types.ts"; export class MissingRateError extends Error { constructor(public readonly currency: Currency) { @@ -20,9 +19,8 @@ export function toBase( value: Money, baseCurrency: Currency, rates: FxRate[], - currencies: readonly CurrencyDef[], + digits: number, ): number { - const digits = digitsFor(currencies, baseCurrency); if (value.currency === baseCurrency) { return roundMoney(value.amount, digits); } @@ -35,9 +33,8 @@ export function fromBase( target: Currency, baseCurrency: Currency, rates: FxRate[], - currencies: readonly CurrencyDef[], + digits: number, ): number { - 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.ts b/src/domain/money.ts index 8b36361..b106bf9 100644 --- a/src/domain/money.ts +++ b/src/domain/money.ts @@ -2,15 +2,15 @@ * 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. + * Digits are passed in, never defaulted here. They come from the dataset — + * `settings.digits`, one value for every currency alike — so every call site + * has to be told. That is deliberate friction: a default of 2 would look right + * for every currency the app ships with, so a missed call site would pass the + * whole suite and be wrong only once the setting moves off 2. */ /** - * Rounds to a currency's minor unit. `toFixed` rounds the double's actual + * Rounds to the dataset's decimal places. `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. diff --git a/src/domain/plans.test.ts b/src/domain/plans.test.ts index 4931062..f094fbb 100644 --- a/src/domain/plans.test.ts +++ b/src/domain/plans.test.ts @@ -2,8 +2,8 @@ import { test, expect } from "bun:test"; import { equalSlices, sliceTotal, slicesBalance } from "./plans.ts"; /** - * 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 + * Digits are passed in rather than looked up, so a dataset rounding to a + * different number of places is expressed directly instead of by * monkey-patching a constant, which is what this file used to have to do. */ const DKK = 2; diff --git a/src/domain/plans.ts b/src/domain/plans.ts index ff3d5bb..cde555e 100644 --- a/src/domain/plans.ts +++ b/src/domain/plans.ts @@ -21,9 +21,8 @@ export function equalSlices( } /** - * Every slice of a schedule carries the purchase's currency, so the first one - * names the currency to round in. Rounding to a hardcoded 2 places would be - * wrong for any currency whose minor unit is not hundredths. + * Digits are passed in rather than assumed: rounding to a hardcoded 2 places + * is wrong the moment the dataset's decimals setting is not hundredths. */ export function sliceTotal(slices: ScheduleSlice[], digits: number): number { const sum = slices.reduce((total, s) => total + s.amount.amount, 0); diff --git a/src/domain/seed.ts b/src/domain/seed.ts index 6eaed63..d345d12 100644 --- a/src/domain/seed.ts +++ b/src/domain/seed.ts @@ -1,7 +1,7 @@ -import { SEED_CURRENCIES } from "./types.ts"; +import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts"; import type { CurrencyDef, Dataset, FxRate, MonthId, Post } from "./types.ts"; -export const SCHEMA_VERSION = 5; +export const SCHEMA_VERSION = 6; const SEED_POST_NAMES = ["Video Games", "Food", "Events and Social"] as const; @@ -36,6 +36,7 @@ export function createSeedDataset( baseCurrency: "DKK", foldStartMonth: startMonth, schemaVersion: SCHEMA_VERSION, + digits: DEFAULT_DIGITS, }, // Copied, so a caller passing shared constants cannot have them edited // out from under it the first time the user changes a rate or a currency. diff --git a/src/domain/types.test.ts b/src/domain/types.test.ts index 8adc3cb..4587029 100644 --- a/src/domain/types.test.ts +++ b/src/domain/types.test.ts @@ -1,5 +1,5 @@ import { test, expect } from "bun:test"; -import { DEFAULT_CURRENCY_DIGITS, SEED_CURRENCIES } from "./types.ts"; +import { DEFAULT_DIGITS, SEED_CURRENCIES } from "./types.ts"; /** * This file used to assert "the supported currencies are exactly DKK, USD, @@ -14,17 +14,26 @@ test("a new dataset starts with the currencies the app shipped with", () => { test("sterling carries the pound sign, so \"10£\" parses", () => { const gbp = SEED_CURRENCIES.find((c) => c.code === "GBP")!; expect(gbp.symbol).toBe("£"); - expect(gbp.digits).toBe(2); }); -test("each seeded currency carries its decimals and a symbol to type", () => { +test("each seeded currency carries a symbol to type and a name to read", () => { 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); +/** + * Decimals are not a property of a currency here — they are one setting for + * the whole dataset. A definition still carrying `digits` would be a leftover + * that the import validator drops and nothing reads. + */ +test("no seeded currency declares decimals of its own", () => { + for (const currency of SEED_CURRENCIES) { + expect("digits" in currency).toBe(false); + } +}); + +test("a new dataset rounds to two places", () => { + expect(DEFAULT_DIGITS).toBe(2); }); diff --git a/src/domain/types.ts b/src/domain/types.ts index f3a4ff7..ae735c9 100755 --- a/src/domain/types.ts +++ b/src/domain/types.ts @@ -9,11 +9,15 @@ */ export type Currency = string; -/** What the owner has told the app about one currency. */ +/** + * What the owner has told the app about one currency. + * + * Decimal places are NOT here. They are one setting for the whole dataset, + * `Settings.digits`, applied to every currency alike — see + * docs/specs/2026-09-02-global-decimals-design.md, including what that costs. + */ 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. */ @@ -25,14 +29,14 @@ export interface CurrencyDef { * 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" }, - { code: "GBP", digits: 2, symbol: "\u00a3", name: "British pound" }, + { code: "DKK", symbol: "kr", name: "Danish krone" }, + { code: "USD", symbol: "$", name: "US dollar" }, + { code: "EUR", symbol: "\u20ac", name: "Euro" }, + { code: "GBP", symbol: "\u00a3", name: "British pound" }, ]; -/** Used when a currency has no definition. Two places is right almost always. */ -export const DEFAULT_CURRENCY_DIGITS = 2; +/** What a brand-new dataset rounds to. Right for every currency it ships with. */ +export const DEFAULT_DIGITS = 2; /** A float amount paired with its currency. See AGENTS.md for rounding rules. */ export interface Money { @@ -127,6 +131,14 @@ export interface Settings { baseCurrency: Currency; foldStartMonth: MonthId; schemaVersion: number; + /** + * Decimal places for EVERY amount, whatever its currency. Integer 0-4. + * + * Required, not optional: an optional field means a `?? 2` at every read, + * and a silent default of 2 is exactly the wrongness that the old + * per-currency table's required argument existed to prevent. + */ + digits: number; /** Optional: no migration needed, existing datasets leave it undefined. */ fxApiUrl?: string; } diff --git a/src/domain/views.test.ts b/src/domain/views.test.ts index b6b7691..e86c04e 100644 --- a/src/domain/views.test.ts +++ b/src/domain/views.test.ts @@ -4,11 +4,14 @@ 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" }, + { code: "DKK", symbol: "kr" }, + { code: "USD", symbol: "$" }, + { code: "EUR", symbol: "\u20ac" }, ]; +/** The dataset\'s decimal places; every currency rounds to this. */ +const DIGITS = 2; + function post(id: string, order: number, percent: number, archived = false): Post { return { id, @@ -33,7 +36,7 @@ function spend(id: string, postId: string, amount: number, date: string): Purcha } const data: Dataset = { - settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1 }, + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 1, digits: DIGITS }, currencies: TEST_CURRENCIES, fxRates: [], posts: [post("food", 0, 20), post("games", 1, 10)], diff --git a/src/domain/views.ts b/src/domain/views.ts index 6ebc7f4..13d239a 100644 --- a/src/domain/views.ts +++ b/src/domain/views.ts @@ -1,5 +1,4 @@ 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"; @@ -52,7 +51,7 @@ export function monthView(dataset: Dataset, monthId: MonthId): MonthViewModel { } const base = dataset.settings.baseCurrency; - const baseDigits = digitsFor(dataset.currencies, base); + const baseDigits = dataset.settings.digits; const totalAllocation = roundMoney( rows.reduce((sum, r) => sum + r.figures.allocation, 0), baseDigits, @@ -96,7 +95,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 baseDigits = dataset.settings.digits; const incomeByMonth = months.map((m) => incomeFor(dataset, m)); @@ -149,7 +148,7 @@ export function summaryView( ): SummaryViewModel { const months = monthRange(from, to); const base = dataset.settings.baseCurrency; - const baseDigits = digitsFor(dataset.currencies, base); + const baseDigits = dataset.settings.digits; const fold: Fold = months.length > 0 ? foldBalances(dataset, months[months.length - 1]!) : new Map(); diff --git a/src/export/json.test.ts b/src/export/json.test.ts index e975d71..7e25dee 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -223,3 +223,33 @@ describe("rule versions on import", () => { expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/); }); }); + +describe("the decimal-places setting on import", () => { + test("a valid setting round-trips", () => { + const data = populated(); + data.settings.digits = 0; + expect(parseDatasetJson(exportDatasetJson(data)).settings.digits).toBe(0); + }); + + test.each([ + ["missing", undefined], + ["fractional", 1.5], + ["negative", -1], + ["beyond four places", 5], + ["a string", "2"], + ])("a %s setting is refused", (_label, value) => { + const data = populated() as any; + if (value === undefined) delete data.settings.digits; + else data.settings.digits = value; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError); + }); + + test("a per-currency digits left over in a hand-edited file is ignored, not refused", () => { + // An unknown field is not a reason to refuse the owner's only backup. + const data = populated() as any; + data.currencies[0].digits = 3; + const out = parseDatasetJson(JSON.stringify(data)); + expect("digits" in out.currencies[0]!).toBe(false); + expect(out.settings.digits).toBe(2); + }); +}); diff --git a/src/export/json.ts b/src/export/json.ts index 7d5519c..6158d19 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -59,8 +59,13 @@ 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. + * A duplicate code would make two definitions compete for the same identity — + * the code keys the FX table and every stored Money. + * + * Decimal places are NOT checked here any more: they are one dataset-wide + * setting, validated below. A per-currency `digits` surviving in a hand-edited + * file is dropped rather than refused — an unknown field is no reason to turn + * away the owner's only backup. */ function requireCurrencyTable(data: Record): CurrencyDef[] { const raw = data.currencies; @@ -88,12 +93,6 @@ function requireCurrencyTable(data: Record): CurrencyDef[] { } 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`); } @@ -103,7 +102,6 @@ function requireCurrencyTable(data: Record): CurrencyDef[] { table.push({ code, - digits, ...(typeof entry.symbol === "string" ? { symbol: entry.symbol } : {}), ...(typeof entry.name === "string" ? { name: entry.name } : {}), }); @@ -145,6 +143,10 @@ export function parseDatasetJson(text: string): Dataset { const data = dataset as unknown as Record; const currencies = requireCurrencyTable(data); + // The validated table replaces the raw one, so what comes back is normalised: + // trimmed symbols and names, and no stray per-currency `digits` left over + // from a hand-edited or pre-v6 file. + dataset.currencies = currencies; const defined = new Set(currencies.map((currency) => currency.code)); requireArray(data, "posts"); requireArray(data, "months"); @@ -152,6 +154,14 @@ export function parseDatasetJson(text: string): Dataset { requireArray(data, "fxRates"); requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency", defined); + // One setting, every currency. Bad digits here would round every amount in + // the dataset to nonsense, so this is checked before anything is folded. + const digits = dataset.settings.digits; + if (typeof digits !== "number" || !Number.isInteger(digits) || digits < 0 || digits > 4) { + throw new ImportValidationError( + `settings.digits is ${String(digits)}; expected a whole number of decimal places between 0 and 4`, + ); + } if (!MONTH_ID.test(dataset.settings.foldStartMonth)) { throw new ImportValidationError( `Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`, diff --git a/src/export/ods.ts b/src/export/ods.ts index 66164c3..da67ff7 100644 --- a/src/export/ods.ts +++ b/src/export/ods.ts @@ -2,7 +2,6 @@ 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"; @@ -32,7 +31,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), digitsFor(dataset.currencies, base))), + num(roundMoney(perMonth.reduce((a, b) => a + b, 0), dataset.settings.digits)), ]); } @@ -88,7 +87,7 @@ function purchasesSheet(dataset: Dataset, months: MonthId[]): Sheet { monthId, base, dataset.fxRates, - dataset.currencies, + dataset.settings.digits, ); for (const charge of charges) { rows.push([ diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 1b5418d..3bc7c1b 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -4,27 +4,25 @@ import { createSeedDataset } from "../domain/seed.ts"; import type { Dataset } from "../domain/types.ts"; /** - * 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. + * Decimal places are one dataset-wide setting, so a different minor unit is + * expressed by moving `settings.digits` — no monkey-patching a module + * constant, which is what this file used to need, and no per-currency entry + * either, which is what it needed before that. */ -function withDigits(currency: string, digits: number, body: () => void) { - const original = zeroDigitDataset; - zeroDigitDataset = { currency, digits }; +function withDigits(digits: number, body: () => void) { + const original = overriddenDigits; + overriddenDigits = digits; try { body(); } finally { - zeroDigitDataset = original; + overriddenDigits = original; } } -let zeroDigitDataset: { currency: string; digits: number } | null = null; +let overriddenDigits: number | null = null; function draft(): Dataset { const data = createSeedDataset("2026-09"); - if (zeroDigitDataset) { - const target = data.currencies.find((c) => c.code === zeroDigitDataset!.currency); - if (target) target.digits = zeroDigitDataset.digits; - } + if (overriddenDigits !== null) data.settings.digits = overriddenDigits; return data; } @@ -48,7 +46,7 @@ describe("months", () => { expect(data.months.find((m) => m.id === "2026-11")!.income.amount).toBe(25000); }); - test("setIncome rounds the amount to the currency's minor unit", () => { + test("setIncome rounds the amount to the dataset's decimal places", () => { const data = draft(); actions.setIncome(data, "2026-11", { amount: 25000.005, currency: "DKK" }); expect(data.months.find((m) => m.id === "2026-11")!.income.amount).toBe(25000.01); @@ -401,11 +399,11 @@ describe("rule versions", () => { }); describe("split values are rounded by what they MEAN", () => { - // In "fixed" mode a split value is money in the purchase's currency, so it - // must round to that currency's minor unit. In "percent" mode it is a - // percentage and has nothing to do with any currency. - test("a fixed-mode split rounds to the purchase currency's minor unit", () => { - withDigits("EUR", 0, () => { + // In "fixed" mode a split value is money, so it must round to the dataset's + // decimal places. In "percent" mode it is a percentage and has nothing to do + // with money at all, so it stays at two places whatever the setting says. + test("a fixed-mode split rounds to the dataset's decimal places", () => { + withDigits(0, () => { const data = draft(); const purchase = actions.addPurchase(data, { date: "2026-09-14", @@ -422,8 +420,8 @@ describe("split values are rounded by what they MEAN", () => { }); }); - test("a fixed-mode split still rounds by currency when only splits change", () => { - withDigits("EUR", 0, () => { + test("a fixed-mode split still rounds to the setting when only splits change", () => { + withDigits(0, () => { const data = draft(); const purchase = actions.addPurchase(data, { date: "2026-09-14", @@ -441,7 +439,7 @@ describe("split values are rounded by what they MEAN", () => { }); test("a percent-mode split keeps 2 decimals whatever the currency does", () => { - withDigits("EUR", 0, () => { + withDigits(0, () => { const data = draft(); const purchase = actions.addPurchase(data, { date: "2026-09-14", diff --git a/src/store/actions.ts b/src/store/actions.ts index fff5338..8f834e4 100755 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,7 +1,7 @@ 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 { currencyUsage, normalizeCurrencyCode } from "../domain/currencies.ts"; import type { Currency, CurrencyDef, @@ -24,8 +24,8 @@ import type { * Every function here mutates a draft Dataset in place. They are called from * store.mutate(), which clones before applying and writes through afterwards. * - * Every MONEY amount that flows through here is rounded to its currency's - * minor unit (via roundMoney) before it lands in the draft, per AGENTS.md's + * Every MONEY amount that flows through here is rounded to the dataset's + * decimal places (via roundMoney) before it lands in the draft, per AGENTS.md's * "round before persisting" rule. FxRate.baseUnitsPerOne is deliberately * exempted in setFxRate below — an exchange rate is not money, and rounding * it to cents would be wrong in kind (see the comment there). @@ -50,7 +50,7 @@ function round2(value: number): number { function roundMoneyValue(draft: Dataset, money: Money): Money { return { - amount: roundMoney(money.amount, digitsFor(draft.currencies, money.currency)), + amount: roundMoney(money.amount, draft.settings.digits), currency: money.currency, }; } @@ -65,17 +65,16 @@ function roundRule(draft: Dataset, rule: Rule): Rule { /** * A split's `value` is a percentage in "percent" mode and MONEY in the * purchase's currency in "fixed" mode, so the two round differently: a - * percentage to 2 decimals, money to its currency's minor unit. Rounding - * both to a hardcoded 2 places is correct only while every supported - * currency happens to use hundredths. + * percentage always to 2 decimals, money to the dataset's decimal places. + * Rounding both to a hardcoded 2 is correct only while that setting is 2. */ function roundSplits( draft: Dataset, splits: Split[], splitMode: Purchase["splitMode"], - currency: Currency, + _currency: Currency, ): Split[] { - const digits = digitsFor(draft.currencies, currency); + const digits = draft.settings.digits; return splits.map((split) => ({ ...split, value: splitMode === "fixed" ? roundMoney(split.value, digits) : round2(split.value), @@ -261,8 +260,8 @@ export function cancelScheduleFrom( /** * Stores the rate exactly as passed. Exchange rates are not money: they are - * 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. + * kept at six decimal places (see fxApi.ts's toFixed(6)), and rounding one to + * the dataset's decimals 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}$/; @@ -272,14 +271,8 @@ function requireCurrencyDef(def: CurrencyDef): CurrencyDef { 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() } : {}), }; @@ -344,6 +337,27 @@ export function removeFxRate(draft: Dataset, currency: Currency): void { } /** The base currency never has a rate row of its own. */ +/** + * The decimal places every amount rounds to, dataset-wide. + * + * Amounts already stored are deliberately NOT rewritten. Moving the setting is + * a rounding rule going forward: 49.95 stays 49.95 and displays as 50, while + * every total recomputes at the new precision. Rewriting each `Money` would be + * a destructive whole-dataset write — the kind that owes the owner a backup + * export first — and it is lossy one way, since 2 -> 0 discards what 0 -> 2 + * cannot bring back. + * + * Throws rather than clamping: a digit count outside 0-4 is a caller bug, and + * silently storing 2 instead is how a wrong number becomes invisible. The + * Settings input guards before calling, so no event handler sees this throw. + */ +export function setDigits(draft: Dataset, digits: number): void { + if (!Number.isInteger(digits) || digits < 0 || digits > 4) { + throw new Error("Decimal places must be a whole number between 0 and 4"); + } + draft.settings.digits = digits; +} + export function setBaseCurrency(draft: Dataset, currency: Currency): void { draft.settings.baseCurrency = currency; draft.fxRates = draft.fxRates.filter((r) => r.currency !== currency); diff --git a/src/store/bakedRates.test.ts b/src/store/bakedRates.test.ts index 6a973c4..f2b0b26 100644 --- a/src/store/bakedRates.test.ts +++ b/src/store/bakedRates.test.ts @@ -75,7 +75,7 @@ describe("what baked rates are allowed to do", () => { { amount: 100, currency: "EUR" }, data.settings.baseCurrency, data.fxRates, - data.currencies, + data.settings.digits, ), ).toThrow(MissingRateError); }); diff --git a/src/store/currencyActions.test.ts b/src/store/currencyActions.test.ts index 82ae150..b43d2c6 100644 --- a/src/store/currencyActions.test.ts +++ b/src/store/currencyActions.test.ts @@ -7,28 +7,35 @@ 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", () => { + test("appends it with the symbol and name given", () => { const data = draft(); - actions.addCurrency(data, { code: "JPY", digits: 0, symbol: "¥", name: "Japanese yen" }); + actions.addCurrency(data, { code: "JPY", symbol: "¥", name: "Japanese yen" }); expect(data.currencies.at(-1)).toEqual({ code: "JPY", - digits: 0, symbol: "¥", name: "Japanese yen", }); }); + // Decimals are a dataset-wide setting, so there is nothing per-currency to + // carry. A definition arriving with one keeps nothing of it. + test("a stray digits field is not stored on the definition", () => { + const data = draft(); + actions.addCurrency(data, { code: "JPY", digits: 0 } as any); + expect("digits" in data.currencies.at(-1)!).toBe(false); + }); + 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 }); + actions.addCurrency(data, { code: " jpy " }); 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(() => actions.addCurrency(data, { code: "usd" })).toThrow(/USD/); expect(data.currencies.filter((c) => c.code === "USD")).toHaveLength(1); }); @@ -37,38 +44,35 @@ describe("adding a currency", () => { ["too short", "U"], ["empty", " "], ])("a code that is %s is refused", (_label, code) => { - expect(() => actions.addCurrency(draft(), { code, digits: 2 })).toThrow(); + expect(() => actions.addCurrency(draft(), { code })).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", () => { + test("changes the symbol and name but never the code", () => { const data = draft(); - actions.updateCurrency(data, "USD", { digits: 3, symbol: "US$" }); + actions.updateCurrency(data, "USD", { symbol: "US$" }); const usd = data.currencies.find((c) => c.code === "USD")!; - expect(usd).toEqual({ code: "USD", digits: 3, symbol: "US$", name: "US dollar" }); + expect(usd).toEqual({ code: "USD", symbol: "US$", name: "US dollar" }); }); test("an unknown code throws", () => { - expect(() => actions.updateCurrency(draft(), "JPY", { digits: 0 })).toThrow(/JPY/); + expect(() => actions.updateCurrency(draft(), "JPY", { symbol: "¥" })).toThrow(/JPY/); }); }); describe("removing a currency", () => { test("removes one nothing references", () => { const data = draft(); - actions.addCurrency(data, { code: "JPY", digits: 0 }); + actions.addCurrency(data, { code: "JPY" }); actions.removeCurrency(data, "JPY"); expect(data.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP"]); }); 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.addCurrency(data, { code: "JPY" }); actions.setFxRate(data, { currency: "JPY", baseUnitsPerOne: 0.045, @@ -126,3 +130,40 @@ describe("currencyUsage", () => { expect(currencyUsage(data, "EUR").join(" ")).toMatch(/rule/i); }); }); + +describe("the decimal-places setting", () => { + test("setDigits writes the whole dataset's decimal places", () => { + const data = draft(); + actions.setDigits(data, 0); + expect(data.settings.digits).toBe(0); + }); + + test("it does not rewrite amounts already stored", () => { + // Moving the setting is a rounding rule going forward, not a data rewrite: + // 2 -> 0 cannot be undone, and rewriting every Money is a destructive + // whole-dataset write that would owe the owner a backup first. + const data = draft(); + data.months[0]!.income = { amount: 20_000.55, currency: "DKK" }; + actions.setDigits(data, 0); + expect(data.months[0]!.income.amount).toBe(20_000.55); + }); + + test.each([ + ["fractional", 1.5], + ["negative", -1], + ["beyond four places", 5], + ["not a number", Number.NaN], + ])("a %s value is refused rather than stored", (_label, value) => { + const data = draft(); + expect(() => actions.setDigits(data, value)).toThrow(); + expect(data.settings.digits).toBe(2); + }); + + test("the bounds themselves are accepted", () => { + for (const digits of [0, 1, 2, 3, 4]) { + const data = draft(); + actions.setDigits(data, digits); + expect(data.settings.digits).toBe(digits); + } + }); +}); diff --git a/src/store/migrations.test.ts b/src/store/migrations.test.ts index 2954b8e..9f2b948 100644 --- a/src/store/migrations.test.ts +++ b/src/store/migrations.test.ts @@ -164,29 +164,29 @@ describe("v2 -> v3: currencies become data", () => { }; } - test("the three shipped currencies are added, with their decimals", () => { + test("the three shipped currencies are added", () => { const migrated = migrate(v2()); expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION); // The first three come from this step, frozen at what it always produced. - // GBP is appended later, by 4 -> 5; see that block. + // GBP is appended later, by 4 -> 5; see that block. The decimals this step + // wrote per currency are collapsed into one setting by 5 -> 6, so they no + // longer appear on the definitions here. 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" }, - { code: "GBP", digits: 2, symbol: "£", name: "British pound" }, + { code: "DKK", symbol: "kr", name: "Danish krone" }, + { code: "USD", symbol: "$", name: "US dollar" }, + { code: "EUR", symbol: "€", name: "Euro" }, + { code: "GBP", symbol: "£", name: "British pound" }, ]); }); /** * 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. + * had two decimal places, this step wrote two, and 5 -> 6 takes the maximum + * of those — two. So not one stored amount can round differently than it did + * before, across the whole chain. */ 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); - } + expect(migrate(v2()).settings.digits).toBe(2); }); test("nothing else about the dataset is touched", () => { @@ -312,7 +312,15 @@ describe("v4 -> v5: GBP joins the prebaked currencies", () => { // definition, and a migration that corrected it would be editing data. const mine = { code: "GBP", digits: 0, symbol: "quid" }; const out = migrate(v4([DKK, mine])); - expect(out.currencies.find((c) => c.code === "GBP")).toEqual(mine); + // Their symbol survives; their 0 does not, because 5 -> 6 collapses every + // per-currency figure into one setting and takes the maximum — DKK's 2. + // This is the cost that spec records: sterling and kroner can disagree + // about decimals in a v5 dataset and cannot in a v6 one. + expect(out.currencies.find((c) => c.code === "GBP")).toEqual({ + code: "GBP", + symbol: "quid", + }); + expect(out.settings.digits).toBe(2); }); test("a GBP rate the owner already has keeps their number", () => { @@ -329,7 +337,8 @@ describe("v4 -> v5: GBP joins the prebaked currencies", () => { test("everything else is left untouched", () => { const out = migrate(v4([DKK], [USD_RATE])); expect(out.fxRates.find((r) => r.currency === "USD")).toEqual(USD_RATE); - expect(out.currencies[0]).toEqual(DKK); + const { digits: _dropped, ...dkkWithoutDigits } = DKK; + expect(out.currencies[0]).toEqual(dkkWithoutDigits); }); test("GBP is not added twice when the migration is somehow reapplied", () => { @@ -339,6 +348,75 @@ describe("v4 -> v5: GBP joins the prebaked currencies", () => { }); }); +describe("v5 -> v6: decimals become one setting for the whole dataset", () => { + const v5 = (currencies: any[]) => ({ + settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 5 }, + currencies, + fxRates: [], + posts: [], + months: [], + purchases: [], + }); + + test("the setting takes the MAXIMUM of the per-currency digits", () => { + // Not the base currency's 2: a dataset holding KWD has three-decimal + // amounts already stored, and migrating to 2 would truncate the next edit + // of one. The maximum can only ever keep more precision, never less. + const out = migrate(v5([ + { code: "DKK", digits: 2 }, + { code: "JPY", digits: 0 }, + { code: "KWD", digits: 3 }, + ])); + expect(out.settings.digits).toBe(3); + }); + + test("digits are stripped from every currency entry", () => { + const out = migrate(v5([ + { code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" }, + { code: "JPY", digits: 0 }, + ])); + for (const currency of out.currencies) { + expect("digits" in currency).toBe(false); + } + // Everything else about the definition survives. + expect(out.currencies[0]).toEqual({ code: "DKK", symbol: "kr", name: "Danish krone" }); + }); + + test("a table where every currency agrees is behaviour-preserving", () => { + // The shipped dataset: four currencies, all at 2, so nothing rounds + // differently after the step than before it. + const out = migrate(v5([ + { code: "DKK", digits: 2 }, + { code: "USD", digits: 2 }, + { code: "EUR", digits: 2 }, + { code: "GBP", digits: 2 }, + ])); + expect(out.settings.digits).toBe(2); + }); + + test.each([ + ["an empty table", []], + ["digits that are not numbers", [{ code: "DKK", digits: "two" }]], + ["entries with no digits at all", [{ code: "DKK" }]], + ])("%s falls back to two places rather than NaN", (_label, currencies) => { + const out = migrate(v5(currencies as any[])); + expect(out.settings.digits).toBe(2); + }); + + test("a missing currencies table does not throw", () => { + const data: any = v5([]); + delete data.currencies; + expect(migrate(data).settings.digits).toBe(2); + }); + + test("everything else in settings is left untouched", () => { + const out = migrate(v5([{ code: "DKK", digits: 2 }])); + expect(out.settings.baseCurrency).toBe("DKK"); + expect(out.settings.foldStartMonth).toBe("2026-01"); + expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION); + }); +}); + /** * A migration's output must not change when today's defaults change. The 2 -> 3 * step originally seeded `SEED_CURRENCIES` by reference, so adding GBP to that diff --git a/src/store/migrations.ts b/src/store/migrations.ts index 24c46d5..ae82068 100644 --- a/src/store/migrations.ts +++ b/src/store/migrations.ts @@ -18,6 +18,13 @@ const V3_SEED_CURRENCIES = [ { code: "EUR", digits: 2, symbol: "\u20ac", name: "Euro" }, ]; +/** + * What 5 -> 6 falls back to when a dataset carries no usable per-currency + * digits. Frozen for the same reason as the tables above: a step's output must + * not move when today's defaults do. + */ +const V6_FALLBACK_DIGITS = 2; + /** Sterling as 4 -> 5 adds it. Frozen for the same reason as the list above. */ const V5_GBP = { code: "GBP", digits: 2, symbol: "\u00a3", name: "British pound" }; const V5_GBP_RATE = { @@ -135,6 +142,38 @@ const MIGRATIONS: Array<(data: any) => any> = [ fxRates: hasGbpRate || hasGbp ? fxRates : [...fxRates, { ...V5_GBP_RATE }], }; }, + + // 5 -> 6: decimal places stop being per-currency and become one setting for + // the whole dataset. See docs/specs/2026-09-02-global-decimals-design.md, + // which records what that gives up: a dataset mixing minor units — yen at 0 + // beside kroner at 2 — can no longer be described. + // + // The setting takes the MAXIMUM of what the currencies declared, not the base + // currency's value. A dataset holding a three-decimal currency already has + // three-decimal amounts stored in it, and adopting the base's 2 would mean + // the next edit of one silently truncated it. The maximum can only ever keep + // more precision than before, never less. + // + // Behaviour-preserving on every dataset this app has written: all four seeded + // currencies record 2, so the maximum is 2 and not one stored amount rounds + // differently than it did before. + (data: any) => { + const currencies = Array.isArray(data.currencies) ? data.currencies : []; + const declared = currencies + .map((currency: any) => currency?.digits) + .filter((digits: unknown): digits is number => Number.isInteger(digits)); + return { + ...data, + settings: { + ...data.settings, + schemaVersion: 6, + digits: declared.length > 0 ? Math.max(...declared) : V6_FALLBACK_DIGITS, + }, + // `digits` dropped from each entry; everything else about a definition + // the owner wrote is theirs and survives untouched. + currencies: currencies.map(({ digits, ...rest }: any) => rest), + }; + }, ]; export function migrate(raw: unknown): Dataset { diff --git a/src/store/snapshot.test.ts b/src/store/snapshot.test.ts index c977e21..34a1fab 100644 --- a/src/store/snapshot.test.ts +++ b/src/store/snapshot.test.ts @@ -175,7 +175,7 @@ describe("reset", () => { // Diverge from the seed in every direction a reset has to undo: an added // currency, a renamed post, recorded income and a purchase. await store.mutate((draft) => { - draft.currencies.push({ code: "JPY", digits: 0, symbol: "\u00a5", name: "Japanese yen" }); + draft.currencies.push({ code: "JPY", symbol: "\u00a5", name: "Japanese yen" }); draft.posts[0]!.name = "Renamed"; draft.posts.push({ id: "extra", diff --git a/src/ui/components/BulkLines.tsx b/src/ui/components/BulkLines.tsx index 8a18b02..1db7cb0 100644 --- a/src/ui/components/BulkLines.tsx +++ b/src/ui/components/BulkLines.tsx @@ -1,11 +1,10 @@ import { useRef } from "react"; import { XIcon } from "lucide-react"; import { useDataset } from "../hooks/useDataset.ts"; -import { digitsFor } from "../../domain/currencies.ts"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { formatMoney } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { bulkTotal, isBlankLine, @@ -28,7 +27,8 @@ 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 digits = useDataset().settings.digits; + const fmt = useMoneyFormat(); const descriptionRefs = useRef>([]); const noteRefs = useRef>([]); @@ -135,7 +135,7 @@ export function BulkLines({ draft, onChange }: Props) {

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

); diff --git a/src/ui/components/PlanEditor.tsx b/src/ui/components/PlanEditor.tsx index 2753c3e..92201c6 100644 --- a/src/ui/components/PlanEditor.tsx +++ b/src/ui/components/PlanEditor.tsx @@ -1,9 +1,8 @@ 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"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { planBalance, setSliceAmount, @@ -32,7 +31,8 @@ function hasValidDate(draft: PurchaseDraft): boolean { export function PlanEditor({ draft, onChange }: Props) { // 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 digits = useDataset().settings.digits; + const fmt = useMoneyFormat(); const balance = planBalance(draft, digits); if (!draft.plan) { @@ -105,8 +105,8 @@ export function PlanEditor({ draft, onChange }: Props) {

{balance === 0 - ? `Slices total ${formatAmount(draft.amount)} ${draft.currency}, matching the purchase.` - : `Slices are ${formatAmount(Math.abs(balance))} ${draft.currency} ${ + ? `Slices total ${fmt.amount(draft.amount)} ${draft.currency}, matching the purchase.` + : `Slices are ${fmt.amount(Math.abs(balance))} ${draft.currency} ${ balance > 0 ? "short of" : "over" } the purchase total.`}

diff --git a/src/ui/components/PostTable.tsx b/src/ui/components/PostTable.tsx index 3701be3..66b90b8 100644 --- a/src/ui/components/PostTable.tsx +++ b/src/ui/components/PostTable.tsx @@ -1,6 +1,6 @@ import { Link } from "react-router"; import { Badge } from "@/components/ui/badge"; -import { formatAmount, formatSignedAmount } from "../format.ts"; +import { useMoneyFormat, type MoneyFormat } from "../hooks/useMoneyFormat.ts"; import { ruleAt } from "../../domain/allocation.ts"; import { carryMeterSegments, type MeterSegment } from "../meterSegments.ts"; import { Meter } from "./Meter.tsx"; @@ -41,18 +41,23 @@ interface RowCells { segments: MeterSegment[]; } -function postRowCells({ figures }: MonthPostRow): RowCells { +/** + * Takes the formatter as an argument rather than calling the hook: this is a + * plain function, not a component, and both table markups call it from inside + * a map. + */ +function postRowCells({ figures }: MonthPostRow, fmt: MoneyFormat): RowCells { return { - carriedIn: formatSignedAmount(figures.carriedIn), + carriedIn: fmt.signedAmount(figures.carriedIn), carriedInTone: figures.carriedIn > 0 ? "text-surplus" : figures.carriedIn < 0 ? "text-overspend" : "", - allocation: formatAmount(figures.allocation), - charges: formatAmount(figures.charges), - remaining: formatSignedAmount(figures.remaining), + allocation: fmt.amount(figures.allocation), + charges: fmt.amount(figures.charges), + remaining: fmt.signedAmount(figures.remaining), remainingTone: figures.remaining < 0 ? "text-overspend" : "", segments: carryMeterSegments(figures), }; @@ -161,6 +166,7 @@ function ChangeRuleButton({ } export function PostTable({ monthId, rows, onChangeRule }: Props) { + const fmt = useMoneyFormat(); return ( <>
@@ -180,7 +186,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) { belong together, which is exactly how the meter came to look like a divider between two rows rather than part of one. */} {rows.map((row) => { - const cells = postRowCells(row); + const cells = postRowCells(row, fmt); return ( @@ -224,7 +230,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
    {rows.map((row) => { - const cells = postRowCells(row); + const cells = postRowCells(row, fmt); return (
  • diff --git a/src/ui/components/SplitEditor.tsx b/src/ui/components/SplitEditor.tsx index b99dbec..924cd28 100644 --- a/src/ui/components/SplitEditor.tsx +++ b/src/ui/components/SplitEditor.tsx @@ -1,11 +1,10 @@ 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 { NativeSelect } from "@/components/ui/native-select"; import { Label } from "@/components/ui/label"; import { Segmented } from "./Segmented.tsx"; -import { formatAmount } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import type { PurchaseDraft, SplitDraft } from "../purchaseForm.ts"; import { splitBalance } from "../purchaseForm.ts"; import type { Post } from "../../domain/types.ts"; @@ -17,7 +16,8 @@ interface Props { } export function SplitEditor({ draft, posts, onChange }: Props) { - const balance = splitBalance(draft, digitsFor(useDataset().currencies, draft.currency)); + const balance = splitBalance(draft, useDataset().settings.digits); + const fmt = useMoneyFormat(); const unit = draft.splitMode === "percent" ? "%" : draft.currency; function updateSplit(index: number, changes: Partial) { @@ -133,7 +133,7 @@ export function SplitEditor({ draft, posts, onChange }: Props) { {balance === 0 ? "Splits balance exactly." - : `${formatAmount(balance)} ${unit} unassigned — the rounding post absorbs it.`} + : `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
    diff --git a/src/ui/components/YearMatrix.tsx b/src/ui/components/YearMatrix.tsx index d1631fa..10ed496 100644 --- a/src/ui/components/YearMatrix.tsx +++ b/src/ui/components/YearMatrix.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router"; -import { formatAmount } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import type { YearViewModel } from "../../domain/views.ts"; interface Props { @@ -14,6 +14,7 @@ interface Props { * recomputes rollover. */ export function YearMatrix({ view, mode }: Props) { + const fmt = useMoneyFormat(); return ( // Thirteen columns of figures cannot fit any realistic card, so the table // scrolls. The fade tells you that: without it the matrix just appears to @@ -37,11 +38,11 @@ export function YearMatrix({ view, mode }: Props) { Income {view.incomeByMonth.map((income, i) => ( - {formatAmount(income)} + {fmt.amount(income)} ))} - {formatAmount(view.totalIncome)} + {fmt.amount(view.totalIncome)} @@ -61,7 +62,7 @@ export function YearMatrix({ view, mode }: Props) { mode === "balance" && value < 0 ? "text-overspend" : "" }`} > - {formatAmount(value)} + {fmt.amount(value)} ); })} @@ -70,7 +71,7 @@ export function YearMatrix({ view, mode }: Props) { mode === "balance" && row.closingBalance < 0 ? "text-overspend" : "" }`} > - {formatAmount(mode === "spend" ? row.totalCharges : row.closingBalance)} + {fmt.amount(mode === "spend" ? row.totalCharges : row.closingBalance)} ))} diff --git a/src/ui/format.test.ts b/src/ui/format.test.ts index 658157b..457ddab 100644 --- a/src/ui/format.test.ts +++ b/src/ui/format.test.ts @@ -6,24 +6,51 @@ import { formatSignedMoney, } from "./format.ts"; -test("formatAmount groups thousands and always shows two decimals", () => { - expect(formatAmount(1234.5)).toBe("1,234.50"); - expect(formatAmount(0)).toBe("0.00"); - expect(formatAmount(-42)).toBe("-42.00"); +test("formatAmount groups thousands and shows the digits it is given", () => { + expect(formatAmount(1234.5, 2)).toBe("1,234.50"); + expect(formatAmount(0, 2)).toBe("0.00"); + expect(formatAmount(-42, 2)).toBe("-42.00"); }); test("formatMoney appends the currency code", () => { - expect(formatMoney(1234.5, "DKK")).toBe("1,234.50 DKK"); + expect(formatMoney(1234.5, "DKK", 2)).toBe("1,234.50 DKK"); }); test("formatSignedMoney marks positive balances with a plus", () => { - expect(formatSignedMoney(200, "DKK")).toBe("+200.00 DKK"); - expect(formatSignedMoney(-200, "DKK")).toBe("-200.00 DKK"); - expect(formatSignedMoney(0, "DKK")).toBe("0.00 DKK"); + expect(formatSignedMoney(200, "DKK", 2)).toBe("+200.00 DKK"); + expect(formatSignedMoney(-200, "DKK", 2)).toBe("-200.00 DKK"); + expect(formatSignedMoney(0, "DKK", 2)).toBe("0.00 DKK"); }); test("formatSignedAmount signs the number without naming a currency", () => { - expect(formatSignedAmount(200)).toBe("+200.00"); - expect(formatSignedAmount(-200)).toBe("-200.00"); - expect(formatSignedAmount(0)).toBe("0.00"); + expect(formatSignedAmount(200, 2)).toBe("+200.00"); + expect(formatSignedAmount(-200, 2)).toBe("-200.00"); + expect(formatSignedAmount(0, 2)).toBe("0.00"); +}); + +/** + * Display is a rounding boundary like any other, and the dataset's decimals + * setting is what it rounds to. A pinned 2 made the setting invisible at zero + * — whole yen still rendered "3,333.00" — and LOSSY at three or four, where it + * hid precision the dataset really holds. + */ +test("the digit count is the dataset's setting, not a fixed two", () => { + expect(formatAmount(3333, 0)).toBe("3,333"); + expect(formatAmount(1.234, 3)).toBe("1.234"); + expect(formatAmount(1.2345, 4)).toBe("1.2345"); + expect(formatMoney(3333, "JPY", 0)).toBe("3,333 JPY"); + expect(formatSignedMoney(3333, "JPY", 0)).toBe("+3,333 JPY"); + expect(formatSignedAmount(-3333, 0)).toBe("-3,333"); +}); + +test("both bounds are pinned, so a whole number still shows its decimals", () => { + // minimumFractionDigits as well as maximum: an even 200 must read "200.00" + // beside "199.95", or a column of money stops lining up. + expect(formatAmount(200, 2)).toBe("200.00"); + expect(formatAmount(200, 3)).toBe("200.000"); +}); + +/** The locale stays pinned, so output cannot differ between a machine and CI. */ +test("grouping and the decimal mark do not follow the host locale", () => { + expect(formatAmount(1234567.89, 2)).toBe("1,234,567.89"); }); diff --git a/src/ui/format.ts b/src/ui/format.ts index dc29e77..b1fb193 100644 --- a/src/ui/format.ts +++ b/src/ui/format.ts @@ -1,17 +1,39 @@ import type { Currency } from "../domain/types.ts"; -// Pinned locale: output must not vary between the user's machine and CI. -const NUMBER = new Intl.NumberFormat("en-GB", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, -}); +/** + * Display is a rounding boundary, and `settings.digits` is what it rounds to — + * so digits are passed in here exactly as they are to `roundMoney`, with no + * default. A pinned 2 used to live in this file, which made the setting + * invisible at zero (whole yen still read "3,333.00") and lossy at three or + * four, where it hid precision the dataset genuinely holds. + * + * The locale stays pinned: output must not vary between the owner's machine + * and CI. Formatters are cached per digit count, because constructing an + * `Intl.NumberFormat` is the expensive part and a table with thirty money + * cells would otherwise build thirty of them per render. + */ +const FORMATTERS = new Map(); -export function formatAmount(amount: number): string { - return NUMBER.format(amount); +function formatter(digits: number): Intl.NumberFormat { + let found = FORMATTERS.get(digits); + if (!found) { + found = new Intl.NumberFormat("en-GB", { + // Both bounds, so an even 200 reads "200.00" beside "199.95" and a + // column of money still lines up. + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + FORMATTERS.set(digits, found); + } + return found; } -export function formatMoney(amount: number, currency: Currency): string { - return `${formatAmount(amount)} ${currency}`; +export function formatAmount(amount: number, digits: number): string { + return formatter(digits).format(amount); +} + +export function formatMoney(amount: number, currency: Currency, digits: number): string { + return `${formatAmount(amount, digits)} ${currency}`; } /** @@ -20,10 +42,14 @@ export function formatMoney(amount: number, currency: Currency): string { * the dataset's base currency, and repeating the code on all thirty cells only * bought a line wrap at phone width. */ -export function formatSignedAmount(amount: number): string { - return `${amount > 0 ? "+" : ""}${formatAmount(amount)}`; +export function formatSignedAmount(amount: number, digits: number): string { + return `${amount > 0 ? "+" : ""}${formatAmount(amount, digits)}`; } -export function formatSignedMoney(amount: number, currency: Currency): string { - return `${formatSignedAmount(amount)} ${currency}`; +export function formatSignedMoney( + amount: number, + currency: Currency, + digits: number, +): string { + return `${formatSignedAmount(amount, digits)} ${currency}`; } diff --git a/src/ui/hooks/useMoneyFormat.ts b/src/ui/hooks/useMoneyFormat.ts new file mode 100644 index 0000000..4f4fa0f --- /dev/null +++ b/src/ui/hooks/useMoneyFormat.ts @@ -0,0 +1,37 @@ +import { useMemo } from "react"; +import { useDataset } from "./useDataset.ts"; +import { + formatAmount, + formatMoney, + formatSignedAmount, + formatSignedMoney, +} from "../format.ts"; +import type { Currency } from "../../domain/types.ts"; + +/** + * The four money formatters, bound to the dataset's decimals setting. + * + * A hook rather than digits threaded through props: display is needed at every + * depth — a table cell, a hint under an input, a stat tile — and `settings.digits` + * is one value for the whole dataset, so a component deep in a tree asking for + * it directly is honest rather than a shortcut. The underlying functions in + * `../format.ts` still take digits explicitly, which is what keeps them + * testable without a React tree. + */ +export function useMoneyFormat() { + const digits = useDataset().settings.digits; + return useMemo( + () => ({ + digits, + amount: (amount: number) => formatAmount(amount, digits), + money: (amount: number, currency: Currency) => formatMoney(amount, currency, digits), + signedAmount: (amount: number) => formatSignedAmount(amount, digits), + signedMoney: (amount: number, currency: Currency) => + formatSignedMoney(amount, currency, digits), + }), + [digits], + ); +} + +/** What `useMoneyFormat` returns, for a component that takes it as a prop. */ +export type MoneyFormat = ReturnType; diff --git a/src/ui/routes/MonthRoute.tsx b/src/ui/routes/MonthRoute.tsx index 9d04644..61a66b9 100644 --- a/src/ui/routes/MonthRoute.tsx +++ b/src/ui/routes/MonthRoute.tsx @@ -12,7 +12,7 @@ import { setIncome, deletePurchase, setRuleFrom } from "../../store/actions.ts"; import { ruleAt } from "../../domain/allocation.ts"; import type { MonthId, Post, Purchase, Rule } from "../../domain/types.ts"; import { sliceAmountForMonth } from "../../domain/charges.ts"; -import { formatMoney, formatSignedMoney } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { PostTable, PostTableLegend } from "../components/PostTable.tsx"; import { PurchaseDialog } from "../components/PurchaseDialog.tsx"; import { Meter } from "../components/Meter.tsx"; @@ -21,6 +21,7 @@ import { allocatedPercentOfIncome, allocationMeterSegments } from "../meterSegme import { groupPurchasesByDate } from "../purchaseGroups.ts"; export function MonthRoute() { + const fmt = useMoneyFormat(); const { monthId = "" } = useParams(); const dataset = useDataset(); const { mutate, error, clearError } = useMutate(); @@ -85,7 +86,7 @@ export function MonthRoute() {
- {formatMoney(view.totalAllocation, base)} + {fmt.money(view.totalAllocation, base)} - {formatSignedMoney(view.unallocated, base)} + {fmt.signedMoney(view.unallocated, base)}
- {formatMoney(view.totalCharges, base)} + {fmt.money(view.totalCharges, base)}
{/* Rendered in both directions. "No posts overspent" is a real answer to the app's second question, and a line that only appears when @@ -189,6 +190,7 @@ function PurchaseRow({ monthId: MonthId; onDelete: () => void; }) { + const fmt = useMoneyFormat(); const slice = sliceAmountForMonth(purchase, monthId)!; return ( // `-mx-2 px-2` rather than `px-1`: the row's text now starts exactly where @@ -221,7 +223,7 @@ function PurchaseRow({ )} - {formatMoney(slice.amount, slice.currency)} + {fmt.money(slice.amount, slice.currency)} {/* From `sm:` up: revealed on hover, and held at a fixed width so diff --git a/src/ui/routes/PostMonthRoute.tsx b/src/ui/routes/PostMonthRoute.tsx index c3bdf34..c8a07f7 100644 --- a/src/ui/routes/PostMonthRoute.tsx +++ b/src/ui/routes/PostMonthRoute.tsx @@ -3,9 +3,10 @@ import { useDataset } from "../hooks/useDataset.ts"; import { monthView } from "../../domain/views.ts"; import { chargesForPurchaseInMonth, sliceAmountForMonth } from "../../domain/charges.ts"; import { addMonths, compareMonths } from "../../domain/months.ts"; -import { formatMoney, formatSignedMoney } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; export function PostMonthRoute() { + const fmt = useMoneyFormat(); const { postId = "", monthId = "" } = useParams(); const dataset = useDataset(); const post = dataset.posts.find((p) => p.id === postId); @@ -20,7 +21,7 @@ export function PostMonthRoute() { monthId, base, dataset.fxRates, - dataset.currencies, + dataset.settings.digits, ); const mine = charges.find((c) => c.postId === postId); return mine ? [{ purchase, amount: mine.amount }] : []; @@ -72,23 +73,23 @@ export function PostMonthRoute() { : "" }`} > - {formatSignedMoney(row?.figures.carriedIn ?? 0, base)} + {fmt.signedMoney(row?.figures.carriedIn ?? 0, base)}
Allocated
-
{formatMoney(row?.figures.allocation ?? 0, base)}
+
{fmt.money(row?.figures.allocation ?? 0, base)}
Spent
-
{formatMoney(row?.figures.charges ?? 0, base)}
+
{fmt.money(row?.figures.charges ?? 0, base)}
Remaining
- {formatSignedMoney(row?.figures.remaining ?? 0, base)} + {fmt.signedMoney(row?.figures.remaining ?? 0, base)}
@@ -107,7 +108,7 @@ export function PostMonthRoute() { {purchase.splits.length > 1 && ( split )} - {formatMoney(amount, base)} + {fmt.money(amount, base)} ))} {thisMonth.length === 0 &&
  • Nothing yet.
  • } @@ -123,7 +124,7 @@ export function PostMonthRoute() { {slice.month} {purchase.description} - {formatMoney(slice.amount.amount, slice.amount.currency)} + {fmt.money(slice.amount.amount, slice.amount.currency)} ))} diff --git a/src/ui/routes/PostYearRoute.tsx b/src/ui/routes/PostYearRoute.tsx index 6d719c6..29853da 100644 --- a/src/ui/routes/PostYearRoute.tsx +++ b/src/ui/routes/PostYearRoute.tsx @@ -1,9 +1,10 @@ import { Link, useParams } from "react-router"; import { useDataset } from "../hooks/useDataset.ts"; import { yearView } from "../../domain/views.ts"; -import { formatAmount, formatMoney, formatSignedMoney } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; export function PostYearRoute() { + const fmt = useMoneyFormat(); const { postId = "", year = "" } = useParams(); const dataset = useDataset(); const post = dataset.posts.find((p) => p.id === postId); @@ -20,9 +21,9 @@ export function PostYearRoute() { {post.name} · {year}

    - Allocated {formatMoney(row.totalAllocation, base)} · spent{" "} - {formatMoney(row.totalCharges, base)} · closing balance{" "} - {formatSignedMoney(row.closingBalance, base)} + Allocated {fmt.money(row.totalAllocation, base)} · spent{" "} + {fmt.money(row.totalCharges, base)} · closing balance{" "} + {fmt.signedMoney(row.closingBalance, base)}

    @@ -55,16 +56,16 @@ export function PostYearRoute() { : "" }`} > - {formatSignedMoney(figures.carriedIn, base)} + {fmt.signedMoney(figures.carriedIn, base)} - {formatAmount(figures.allocation)} - {formatAmount(figures.charges)} + {fmt.amount(figures.allocation)} + {fmt.amount(figures.charges)} - {formatSignedMoney(figures.remaining, base)} + {fmt.signedMoney(figures.remaining, base)} ); diff --git a/src/ui/routes/SummaryRoute.tsx b/src/ui/routes/SummaryRoute.tsx index 8a2be02..bec49e6 100644 --- a/src/ui/routes/SummaryRoute.tsx +++ b/src/ui/routes/SummaryRoute.tsx @@ -5,11 +5,12 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { useDataset } from "../hooks/useDataset.ts"; import { datasetMonthSpan, summaryView } from "../../domain/views.ts"; -import { formatAmount, formatMoney } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { Section, Stat } from "../components/Section.tsx"; import { Segmented } from "../components/Segmented.tsx"; export function SummaryRoute() { + const fmt = useMoneyFormat(); const dataset = useDataset(); const span = datasetMonthSpan(dataset); const [from, setFrom] = useState(span.from); @@ -46,10 +47,10 @@ export function SummaryRoute() {
    - {formatMoney(view.totalIncome, base)} - {formatMoney(view.totalCharges, base)} + {fmt.money(view.totalIncome, base)} + {fmt.money(view.totalCharges, base)} - {formatMoney(difference, base)} + {fmt.money(difference, base)}
    @@ -81,7 +82,7 @@ export function SummaryRoute() { ? view.byPost.map((entry) => ( {entry.post.name} - {formatAmount(entry.charges)} + {fmt.amount(entry.charges)} {view.totalCharges === 0 ? "—" @@ -96,7 +97,7 @@ export function SummaryRoute() { {entry.monthId} - {formatAmount(entry.charges)} + {fmt.amount(entry.charges)} {view.totalCharges === 0 ? "—" diff --git a/src/ui/routes/YearRoute.tsx b/src/ui/routes/YearRoute.tsx index 54ae246..6d4a1a8 100644 --- a/src/ui/routes/YearRoute.tsx +++ b/src/ui/routes/YearRoute.tsx @@ -2,12 +2,13 @@ import { useState } from "react"; import { Link, useParams } from "react-router"; import { useDataset } from "../hooks/useDataset.ts"; import { yearView } from "../../domain/views.ts"; -import { formatMoney } from "../format.ts"; +import { useMoneyFormat } from "../hooks/useMoneyFormat.ts"; import { YearMatrix } from "../components/YearMatrix.tsx"; import { Segmented } from "../components/Segmented.tsx"; import { Section, Stat } from "../components/Section.tsx"; export function YearRoute() { + const fmt = useMoneyFormat(); const { year = "" } = useParams(); const dataset = useDataset(); const [mode, setMode] = useState<"spend" | "balance">("spend"); @@ -35,8 +36,8 @@ export function YearRoute() {
    - {formatMoney(view.totalIncome, base)} - {formatMoney(view.totalCharges, base)} + {fmt.money(view.totalIncome, base)} + {fmt.money(view.totalCharges, base)}
    diff --git a/src/ui/routes/settings/CurrencySection.tsx b/src/ui/routes/settings/CurrencySection.tsx index c29fd37..3a6ca09 100644 --- a/src/ui/routes/settings/CurrencySection.tsx +++ b/src/ui/routes/settings/CurrencySection.tsx @@ -10,6 +10,7 @@ import { addCurrency, removeCurrency, setBaseCurrency, + setDigits, setFxRate, removeFxRate, updateCurrency, @@ -68,30 +69,66 @@ export function CurrencySection() { > {error &&

    {error}

    } -
    - - { - const currency = event.target.value; - mutate((draft) => setBaseCurrency(draft, currency)); - }} - > - {dataset.currencies.map(({ code }) => ( - - ))} - +
    +
    + + { + const currency = event.target.value; + mutate((draft) => setBaseCurrency(draft, currency)); + }} + > + {dataset.currencies.map(({ code }) => ( + + ))} + +
    + +
    + + { + // Captured before mutate, which defers behind the write queue — + // React resets the DOM value first, so reading it inside the + // callback commits the OLD one. AGENTS.md §2. + const digits = Number(event.target.value); + // Guarded here rather than letting setDigits throw: this is an + // event handler, and no error boundary catches a throw from one. + if (!Number.isInteger(digits) || digits < 0 || digits > 4) return; + mutate((draft) => setDigits(draft, digits)); + }} + /> +
    +

    + Every currency rounds to this. Changing it does not rewrite amounts you + have already recorded — they keep the precision they were entered with, + and totals recompute at the new setting. +

    + {/* `-mr-2 pr-2`, not a bare `overflow-x-auto`: the row's remove button carries `-mr-2` so its label sits flush with the card's content edge rather than 8px inside it. That overhang is 8px of overflow to a @@ -107,7 +144,6 @@ export function CurrencySection() { Code Name Symbol - Decimals 1 unit = ? {base} Source @@ -146,22 +182,6 @@ export function CurrencySection() { }} /> - - { - const digits = Number(event.target.value); - if (!Number.isInteger(digits) || digits < 0 || digits > 4) return; - mutate((draft) => updateCurrency(draft, currency.code, { digits })); - }} - /> - {isBase ? ( 1 @@ -265,28 +285,21 @@ export function CurrencySection() { } /** - * 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). + * No decimals field: they are one setting for the whole dataset now, edited + * above the table rather than per currency. */ -function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; symbol?: string; name?: string }) => void }) { +function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; 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; + const canAdd = /^[A-Za-z]{2,8}$/.test(code.trim()); function add() { - onAdd({ code: code.trim(), digits: parsedDigits, symbol, name }); + onAdd({ code: code.trim(), symbol, name }); setCode(""); setName(""); setSymbol(""); - setDigits("2"); } return ( @@ -321,23 +334,6 @@ function AddCurrency({ onAdd }: { onAdd: (def: { code: Currency; digits: number; setSymbol(next); }} /> - {/* No visible label. Its three siblings are labelled by placeholder, and - the DECIMALS column heading sits directly above this field in the - table — a fourth label here read as a heading for the whole row. */} - { - const next = event.target.value; - setDigits(next); - }} - />