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

+49 -24
View File
@@ -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);
});
});