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
@@ -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> = {}): Post {
|
||||
|
||||
function dataset(overrides: Partial<Dataset> = {}): 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> = {}): 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: [
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
+15
-12
@@ -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: [],
|
||||
|
||||
+9
-10
@@ -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<PostId, number> {
|
||||
const totals = new Map<PostId, number>();
|
||||
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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
+1
-2
@@ -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<MonthId, Map<PostId, PostMonthFigures>>;
|
||||
*/
|
||||
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;
|
||||
|
||||
+9
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
+3
-6
@@ -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);
|
||||
}
|
||||
+6
-6
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
+2
-3
@@ -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);
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+21
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
+3
-4
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user