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:
co-authored by
Claude Opus 5
parent
ebef56eef4
commit
8d2b2ca881
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+19
-9
@@ -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<string, unknown>): CurrencyDef[] {
|
||||
const raw = data.currencies;
|
||||
@@ -88,12 +93,6 @@ function requireCurrencyTable(data: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>;
|
||||
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}"`,
|
||||
|
||||
+2
-3
@@ -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([
|
||||
|
||||
+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",
|
||||
|
||||
@@ -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<Array<HTMLInputElement | null>>([]);
|
||||
const noteRefs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
@@ -135,7 +135,7 @@ export function BulkLines({ draft, onChange }: Props) {
|
||||
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{filledCount} line{filledCount === 1 ? "" : "s"} ·{" "}
|
||||
<span className="font-money">{formatMoney(bulkTotal(draft, digits), draft.currency)}</span>
|
||||
<span className="font-money">{fmt.money(bulkTotal(draft, digits), draft.currency)}</span>
|
||||
</p>
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
<p className={`text-xs ${balance === 0 ? "text-muted-foreground" : ""}`}>
|
||||
{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.`}
|
||||
</p>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="hidden overflow-x-auto sm:block">
|
||||
@@ -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 (
|
||||
<tbody key={row.post.id} className="group">
|
||||
<tr className="transition-colors group-hover:bg-accent/60">
|
||||
@@ -224,7 +230,7 @@ export function PostTable({ monthId, rows, onChangeRule }: Props) {
|
||||
|
||||
<ul className="space-y-4 sm:hidden">
|
||||
{rows.map((row) => {
|
||||
const cells = postRowCells(row);
|
||||
const cells = postRowCells(row, fmt);
|
||||
return (
|
||||
<li key={row.post.id} className="space-y-1.5">
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
|
||||
@@ -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<SplitDraft>) {
|
||||
@@ -133,7 +133,7 @@ export function SplitEditor({ draft, posts, onChange }: Props) {
|
||||
<span className={balance === 0 ? "text-muted-foreground" : ""}>
|
||||
{balance === 0
|
||||
? "Splits balance exactly."
|
||||
: `${formatAmount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
|
||||
: `${fmt.amount(balance)} ${unit} unassigned — the rounding post absorbs it.`}
|
||||
</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -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) {
|
||||
<td className="py-2">Income</td>
|
||||
{view.incomeByMonth.map((income, i) => (
|
||||
<td key={i} className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(income)}
|
||||
{fmt.amount(income)}
|
||||
</td>
|
||||
))}
|
||||
<td className="font-money py-2 pl-4 text-right whitespace-nowrap">
|
||||
{formatAmount(view.totalIncome)}
|
||||
{fmt.amount(view.totalIncome)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -61,7 +62,7 @@ export function YearMatrix({ view, mode }: Props) {
|
||||
mode === "balance" && value < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatAmount(value)}
|
||||
{fmt.amount(value)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
@@ -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)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
+38
-11
@@ -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");
|
||||
});
|
||||
|
||||
+39
-13
@@ -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<number, Intl.NumberFormat>();
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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<typeof useMoneyFormat>;
|
||||
@@ -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() {
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Allocated">{formatMoney(view.totalAllocation, base)}</Stat>
|
||||
<Stat label="Allocated">{fmt.money(view.totalAllocation, base)}</Stat>
|
||||
<Stat
|
||||
label="Unallocated"
|
||||
tone={view.unallocated < 0 ? "overspend" : "default"}
|
||||
@@ -95,7 +96,7 @@ export function MonthRoute() {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatSignedMoney(view.unallocated, base)}
|
||||
{fmt.signedMoney(view.unallocated, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
<Meter
|
||||
@@ -108,7 +109,7 @@ export function MonthRoute() {
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end justify-between gap-x-10 gap-y-2 border-t border-budget-rule pt-3">
|
||||
<dl>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
{/* 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({
|
||||
</span>
|
||||
)}
|
||||
<span className="font-money shrink-0 tabular-nums">
|
||||
{formatMoney(slice.amount, slice.currency)}
|
||||
{fmt.money(slice.amount, slice.currency)}
|
||||
</span>
|
||||
|
||||
{/* From `sm:` up: revealed on hover, and held at a fixed width so
|
||||
|
||||
@@ -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)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Allocated</dt>
|
||||
<dd className="font-money">{formatMoney(row?.figures.allocation ?? 0, base)}</dd>
|
||||
<dd className="font-money">{fmt.money(row?.figures.allocation ?? 0, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Spent</dt>
|
||||
<dd className="font-money">{formatMoney(row?.figures.charges ?? 0, base)}</dd>
|
||||
<dd className="font-money">{fmt.money(row?.figures.charges ?? 0, base)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">Remaining</dt>
|
||||
<dd
|
||||
className={`font-money ${(row?.figures.remaining ?? 0) < 0 ? "text-overspend" : ""}`}
|
||||
>
|
||||
{formatSignedMoney(row?.figures.remaining ?? 0, base)}
|
||||
{fmt.signedMoney(row?.figures.remaining ?? 0, base)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -107,7 +108,7 @@ export function PostMonthRoute() {
|
||||
{purchase.splits.length > 1 && (
|
||||
<span className="text-xs text-muted-foreground">split</span>
|
||||
)}
|
||||
<span className="font-money">{formatMoney(amount, base)}</span>
|
||||
<span className="font-money">{fmt.money(amount, base)}</span>
|
||||
</li>
|
||||
))}
|
||||
{thisMonth.length === 0 && <li className="py-2 text-muted-foreground">Nothing yet.</li>}
|
||||
@@ -123,7 +124,7 @@ export function PostMonthRoute() {
|
||||
<span className="w-20">{slice.month}</span>
|
||||
<span className="flex-1">{purchase.description}</span>
|
||||
<span className="font-money">
|
||||
{formatMoney(slice.amount.amount, slice.amount.currency)}
|
||||
{fmt.money(slice.amount.amount, slice.amount.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -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}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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)}
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -55,16 +56,16 @@ export function PostYearRoute() {
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.carriedIn, base)}
|
||||
{fmt.signedMoney(figures.carriedIn, base)}
|
||||
</td>
|
||||
<td className="font-money py-2 text-right">{formatAmount(figures.allocation)}</td>
|
||||
<td className="font-money py-2 text-right">{formatAmount(figures.charges)}</td>
|
||||
<td className="font-money py-2 text-right">{fmt.amount(figures.allocation)}</td>
|
||||
<td className="font-money py-2 text-right">{fmt.amount(figures.charges)}</td>
|
||||
<td
|
||||
className={`font-money py-2 text-right font-medium ${
|
||||
figures.remaining < 0 ? "text-overspend" : ""
|
||||
}`}
|
||||
>
|
||||
{formatSignedMoney(figures.remaining, base)}
|
||||
{fmt.signedMoney(figures.remaining, base)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
<dl className="mt-5 flex flex-wrap gap-x-10 gap-y-4 border-t border-budget-rule pt-5">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Difference" tone={difference < 0 ? "overspend" : "default"}>
|
||||
{formatMoney(difference, base)}
|
||||
{fmt.money(difference, base)}
|
||||
</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
@@ -81,7 +82,7 @@ export function SummaryRoute() {
|
||||
? view.byPost.map((entry) => (
|
||||
<tr key={entry.post.id} className="border-b border-budget-rule transition-colors last:border-0 hover:bg-accent/60">
|
||||
<td className="py-2.5">{entry.post.name}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
@@ -96,7 +97,7 @@ export function SummaryRoute() {
|
||||
{entry.monthId}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{formatAmount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right">{fmt.amount(entry.charges)}</td>
|
||||
<td className="font-money py-2.5 pl-6 text-right text-budget-ink-muted">
|
||||
{view.totalCharges === 0
|
||||
? "—"
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<Section>
|
||||
<dl className="flex flex-wrap gap-x-10 gap-y-4">
|
||||
<Stat label="Income">{formatMoney(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{formatMoney(view.totalCharges, base)}</Stat>
|
||||
<Stat label="Income">{fmt.money(view.totalIncome, base)}</Stat>
|
||||
<Stat label="Spent">{fmt.money(view.totalCharges, base)}</Stat>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
addCurrency,
|
||||
removeCurrency,
|
||||
setBaseCurrency,
|
||||
setDigits,
|
||||
setFxRate,
|
||||
removeFxRate,
|
||||
updateCurrency,
|
||||
@@ -68,30 +69,66 @@ export function CurrencySection() {
|
||||
>
|
||||
{error && <p className="mb-3 text-sm text-destructive">{error}</p>}
|
||||
|
||||
<div className="mb-5 space-y-1.5">
|
||||
<Label
|
||||
htmlFor="base-currency"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="base-currency"
|
||||
className="w-40"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
<div className="mb-5 flex flex-wrap items-end gap-x-8 gap-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="base-currency"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Base currency (all totals use this)
|
||||
</Label>
|
||||
<NativeSelect
|
||||
id="base-currency"
|
||||
className="w-40"
|
||||
value={base}
|
||||
onChange={(event) => {
|
||||
const currency = event.target.value;
|
||||
mutate((draft) => setBaseCurrency(draft, currency));
|
||||
}}
|
||||
>
|
||||
{dataset.currencies.map(({ code }) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="digits"
|
||||
className="text-[0.6875rem] font-medium uppercase tracking-wider text-budget-ink-muted"
|
||||
>
|
||||
Decimals (all amounts)
|
||||
</Label>
|
||||
<Input
|
||||
id="digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money h-9 w-20"
|
||||
value={dataset.settings.digits}
|
||||
onChange={(event) => {
|
||||
// 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));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mb-5 max-w-[70ch] text-xs leading-relaxed text-budget-ink-muted">
|
||||
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.
|
||||
</p>
|
||||
|
||||
{/* `-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() {
|
||||
<th className="py-2 font-medium">Code</th>
|
||||
<th className="py-2 pl-4 font-medium">Name</th>
|
||||
<th className="py-2 pl-4 font-medium">Symbol</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Decimals</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">1 unit = ? {base}</th>
|
||||
<th className="py-2 pl-4 text-right font-medium">Source</th>
|
||||
<th className="py-2 pl-4" />
|
||||
@@ -146,22 +182,6 @@ export function CurrencySection() {
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
className="font-money ml-auto h-8 w-16 text-right"
|
||||
aria-label={`Decimal places for ${currency.code}`}
|
||||
value={currency.digits}
|
||||
onChange={(event) => {
|
||||
const digits = Number(event.target.value);
|
||||
if (!Number.isInteger(digits) || digits < 0 || digits > 4) return;
|
||||
mutate((draft) => updateCurrency(draft, currency.code, { digits }));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
{isBase ? (
|
||||
<span className="font-money text-budget-ink-muted">1</span>
|
||||
@@ -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. */}
|
||||
<Input
|
||||
id="new-digits"
|
||||
type="number"
|
||||
min="0"
|
||||
max="4"
|
||||
step="1"
|
||||
aria-label="Decimal places for the new currency"
|
||||
className="font-money h-9 w-16 text-right"
|
||||
value={digits}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
setDigits(next);
|
||||
}}
|
||||
/>
|
||||
<Button variant="outline" disabled={!canAdd} onClick={add}>
|
||||
Add currency
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user