feat(money): one decimals setting for the dataset, not one per currency

`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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-03 03:12:14 +02:00
1 parent ebef56eef4
commit 8d2b2ca881
47 files changed
+1039 -426

No files matched your search

+92 -14
View File
@@ -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