From 8d2b2ca88154366daa31d390014f672e75d12dc7 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Thu, 3 Sep 2026 03:12:14 +0200 Subject: [PATCH] feat(money): one decimals setting for the dataset, not one per currency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Settings.digits` replaces `CurrencyDef.digits`. Every amount rounds to it, whatever its currency, and `digitsFor` is gone: the sixteen call sites read the setting, and the domain functions that took a currency table purely to look digits up take a `digits: number` instead. Schema 6. The owner asked for this with the cost in front of them, and the cost is real: a dataset mixing minor units cannot be described any more. Yen at 0 beside kroner at 2 has no correct global value — 2 gives ¥1234.56, an amount yen cannot represent; 0 rounds 49.95 DKK to 50 and drifts the carry from the bank. Nothing in the app warns. docs/specs/2026-09-02-global-decimals-design.md argues it in full, including what re-adding a per-currency override would take, and AGENTS.md §1 now says not to reintroduce one without revisiting the decision. The 5 -> 6 step takes the MAXIMUM of the per-currency digits, not the base currency's. A dataset holding a three-decimal currency already has three-decimal amounts in it, and adopting the base's 2 would truncate the next edit of one; the maximum can only keep more precision, never less. On every dataset this app has written all four currencies record 2, so nothing rounds differently than before. Changing the setting does not rewrite stored amounts. 49.95 stays 49.95 and shows as 50; totals recompute at the new precision. Rewriting each Money is a destructive whole-dataset write, which §1 owes a backup export first, and it is lossy one way. Display turned out to be the hole in the plan. src/ui/format.ts pinned minimumFractionDigits: 2 in a module-level Intl.NumberFormat, so it never read the dataset at all — equally broken under per-currency digits, where a JPY rendered "3,333.00", so a pre-existing gap rather than a regression. Shipping a Settings control that display ignores would look broken at 0 and be lossy at 3, hiding precision the dataset holds. So format.ts takes digits like roundMoney does, useMoneyFormat() binds them for the 64 call sites, and postRowCells takes the formatter as an argument because it is a plain helper, not a component. currencyDigits.test.ts is rewritten rather than deleted. Driving a dataset at ZERO decimals through rounding, both FX directions, both split modes, plan division, the fold and the month view still tells a real implementation from one assuming 2 — it just cannot prove per-currency flow, because there is none. It also asserts that raising the setting to 2 changes what the fold produces. Verified in Chrome, not only in tests: a v5 dataset with digits 2/0/3 written straight into IndexedDB migrates to a setting of 3, the table loses its Decimals column, and editing the control persists schema 6 with digits stripped from each currency and income left at 20000.55. The same month reads 600/206 at zero decimals and 600.000/205.561 at three, so the figures recompute rather than merely reformat. Not verified here: the .ods in a real spreadsheet. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 35 ++- docs/ARCHITECTURE.md | 23 +- docs/DECISIONS.md | 24 +- .../2026-09-02-global-decimals-design.md | 260 ++++++++++++++++++ src/domain/allocation.test.ts | 21 +- src/domain/allocation.ts | 12 +- src/domain/charges.test.ts | 27 +- src/domain/charges.ts | 19 +- src/domain/currencies.test.ts | 30 +- src/domain/currencies.ts | 33 +-- src/domain/currencyDigits.test.ts | 73 +++-- src/domain/fold.test.ts | 13 +- src/domain/fold.ts | 3 +- src/domain/fx.test.ts | 22 +- src/domain/fx.ts | 9 +- src/domain/money.ts | 12 +- src/domain/plans.test.ts | 4 +- src/domain/plans.ts | 5 +- src/domain/seed.ts | 5 +- src/domain/types.test.ts | 21 +- src/domain/types.ts | 30 +- src/domain/views.test.ts | 11 +- src/domain/views.ts | 7 +- src/export/json.test.ts | 30 ++ src/export/json.ts | 28 +- src/export/ods.ts | 5 +- src/store/actions.test.ts | 40 ++- src/store/actions.ts | 48 ++-- src/store/bakedRates.test.ts | 2 +- src/store/currencyActions.test.ts | 71 ++++- src/store/migrations.test.ts | 106 ++++++- src/store/migrations.ts | 39 +++ src/store/snapshot.test.ts | 2 +- src/ui/components/BulkLines.tsx | 8 +- src/ui/components/PlanEditor.tsx | 10 +- src/ui/components/PostTable.tsx | 22 +- src/ui/components/SplitEditor.tsx | 8 +- src/ui/components/YearMatrix.tsx | 11 +- src/ui/format.test.ts | 49 +++- src/ui/format.ts | 52 +++- src/ui/hooks/useMoneyFormat.ts | 37 +++ src/ui/routes/MonthRoute.tsx | 12 +- src/ui/routes/PostMonthRoute.tsx | 17 +- src/ui/routes/PostYearRoute.tsx | 17 +- src/ui/routes/SummaryRoute.tsx | 13 +- src/ui/routes/YearRoute.tsx | 7 +- src/ui/routes/settings/CurrencySection.tsx | 132 +++++---- 47 files changed, 1039 insertions(+), 426 deletions(-) create mode 100644 docs/specs/2026-09-02-global-decimals-design.md create mode 100644 src/ui/hooks/useMoneyFormat.ts 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); - }} - />