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:
1 parent
ebef56eef4
commit
8d2b2ca881
47 files changed
+1039
-426
No files matched your search
+19
-21
@@ -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",
|
||||
|
||||
+31
-17
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user