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) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-03 03:12:14 +02:00
co-authored by Claude Opus 5
parent ebef56eef4
commit 8d2b2ca881
47 changed files with 1039 additions and 426 deletions
+24 -11
View File
@@ -15,16 +15,26 @@ and `docs/specs/`, and is not repeated here.
(`src/domain/smoke.test.ts` imports it without one).
- **Money is a float, rounded at every boundary.** Use `roundMoney` from
`src/domain/money.ts` after any division, after any FX conversion, and before
persisting.
persisting. DISPLAY is a boundary too: `src/ui/format.ts` takes digits like
everything else, and `useMoneyFormat()` is how a component gets them bound to
the dataset. A pinned 2 lived in that file once and made the decimals setting
invisible at 0 and lossy at 3.
- **`roundMoney(amount, digits)` takes a DIGIT COUNT, not a currency code.**
Decimal places live in `Dataset.currencies`, because the owner defines
currencies; resolve them with `digitsFor(dataset.currencies, code)`. The
argument is required on purpose — a default of 2 is right for every currency
the app shipped with, so a missed call site would pass every test and be
silently wrong for the first zero-decimal currency added.
Decimal places are ONE setting for the whole dataset — `settings.digits` —
and every currency rounds to it. Read that field; there is no `digitsFor`,
and `CurrencyDef` has no `digits`. The argument is required on purpose: a
default of 2 is right while the setting is 2, so a missed call site would
pass every test and go silently wrong the moment the owner moves it.
A dataset mixing minor units (yen at 0 beside kroner at 2) CANNOT be
described, and the app does not warn — the owner chose that, and
`docs/specs/2026-09-02-global-decimals-design.md` records the argument and
what re-adding a per-currency override would cost. Do not "fix" it by
reintroducing per-currency digits without that decision being revisited.
- **When splitting an amount, never round the last part independently.** Compute
it as `total - sum(others)` so the parts sum exactly to the whole.
- **`Currency` is an open string code; `Dataset.currencies` is the authority.**
- **`Currency` is an open string code; `Dataset.currencies` is the authority**
on which codes exist and on their names and symbols — NOT on their decimals,
which are dataset-wide per the rule above.
There is no global list of valid currencies. A code is IDENTITY — it keys the
FX table and every stored `Money` — so codes are normalised uppercase, are
never editable, and a currency still referenced cannot be removed
@@ -82,11 +92,14 @@ ship twice — re-introducing one is a regression, not a style choice.
rejection: the button appears to do nothing and the user is told nothing.
`useMutate` is the guard for most call sites.
→ `src/ui/storeWriteErrors.test.ts`
- **`roundMoney` call sites must resolve real digits**, per §1.
- **`roundMoney` call sites must read `settings.digits`**, per §1 — never a
literal, and never a per-currency lookup.
→ `src/domain/currencyDigits.test.ts` is the only test that can tell a real
implementation from one that assumes 2: it drives a JPY-style zero-decimal
currency through rounding, both FX directions, both split modes, plan
division, the fold and the month view.
implementation from one that assumes 2: it drives a dataset at ZERO decimals
through rounding, both FX directions, both split modes, plan division, the
fold and the month view, and asserts that raising the setting to 2 changes
what the fold produces. It cannot prove digits vary per currency, because
they no longer do.
- **`process.env.BUN_PUBLIC_*` must be read via `try/catch`** — see
`readBasePathEnv`. Bun inlines the literal only when the variable is set;
unset, the bare reference reaches the browser and throws before the app boots.
+15 -8
View File
@@ -95,14 +95,21 @@ A float, deliberately, with two rules that make it safe:
**`FxRate.baseUnitsPerOne` is never rounded.** Rates are stored at six decimals
and are not money; quantizing one to cents is wrong in kind.
**Decimal places are data, not a constant.** `Dataset.currencies` records each
currency's code, name, symbol and digits, because the owner can define their
own. `roundMoney` therefore takes a digit count and every call site resolves it
with `digitsFor` — required rather than defaulted, so a missed site is a
compile error instead of something that works for every 2-decimal currency and
breaks on the first zero-decimal one. `src/domain/currencyDigits.test.ts` drives
a zero-decimal currency through the whole stack, which is the only test that can
tell a real implementation from one that assumes 2.
**Decimal places are data, not a constant — one value for the whole dataset.**
`Settings.digits` is what every amount rounds to, whatever its currency;
`Dataset.currencies` records only a code, name and symbol. `roundMoney` takes a
digit count and every call site reads that setting, required rather than
defaulted, so a missed site is a compile error instead of something that works
while the setting is 2 and breaks the moment it is not.
`src/domain/currencyDigits.test.ts` drives a dataset at zero decimals through
the whole stack, which is the only test that can tell a real implementation
from one that assumes 2.
The cost is explicit: a dataset mixing minor units — yen at 0 beside kroner at
2 — cannot be described, and nothing in the app will warn about it. That was
the owner's call, argued in full in
[`specs/2026-09-02-global-decimals-design.md`](specs/2026-09-02-global-decimals-design.md),
which also says what re-adding a per-currency override would take.
## Splits and finance plans compose
+16 -8
View File
@@ -18,7 +18,7 @@ record.
no household mode. → [`PRODUCT.md`](PRODUCT.md)
- **Schema migrations are mandatory, not optional.** `schemaVersion` plus
ordered migration functions applied on load, because IndexedDB holds the
user's only copy of their financial data. Now at version 5.
user's only copy of their financial data. Now at version 6.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **A destructive write downloads a backup first.** Import and reset both export
before replacing, and abort if the export fails. → commit `ac3d18a`
@@ -33,13 +33,21 @@ record.
last part as `total - sum(others)`.
→ [`specs/2026-09-01-budget-app-design.md`](specs/2026-09-01-budget-app-design.md)
- **`roundMoney` takes a digit count, and the argument is required.** Rejected a
default of 2: it is correct for every currency the app shipped with, so a
missed call site would pass every test and fail on the first zero-decimal
currency. → [`AGENTS.md`](../AGENTS.md) §1, commit `cdece63`
- **`Currency` is an open string code; `Dataset.currencies` is the authority.**
Rejected a closed union, which would have made "add a currency" a code change.
The compile-time safety is replaced by validation at the boundaries —
`parseDatasetJson` rejects a code no currency defines.
default of 2: it is correct while the decimals setting is 2, so a missed call
site would pass every test and fail the moment it moves.
→ [`AGENTS.md`](../AGENTS.md) §1, commit `cdece63`
- **Decimal places are ONE dataset-wide setting, not per currency.**
`Settings.digits`; `CurrencyDef.digits` is gone. Chosen over a per-currency
default and over display-only rounding, and knowingly over correctness for
mixed minor units: a dataset holding both yen and kroner can no longer be
described, and the app does not warn. The owner asked for the simpler model
with that trade-off in front of them.
→ [`specs/2026-09-02-global-decimals-design.md`](specs/2026-09-02-global-decimals-design.md)
- **`Currency` is an open string code; `Dataset.currencies` is the authority**
on which codes exist, their names and their symbols — but no longer on their
decimals. Rejected a closed union, which would have made "add a currency" a
code change. The compile-time safety is replaced by validation at the
boundaries — `parseDatasetJson` rejects a code no currency defines.
→ `src/domain/types.ts`, commit `cdece63`
- **Every derived number comes from one left fold.** `src/domain/fold.ts` is the
only implementation; year and summary views aggregate over it rather than
@@ -0,0 +1,260 @@
# One decimal setting for the whole dataset
Replaces `CurrencyDef.digits` — decimal places defined per currency — with a
single `Settings.digits` that every rounding site uses, whatever the currency.
Requested directly by the owner, with the trade-off below stated and accepted
before the work started. This document is the record of what was given up, so
that a later reader does not mistake it for an oversight.
## What this costs
A dataset whose currencies have different minor units can no longer be
described. That is not a corner case; it is the case the current design exists
for:
| currencies held | `settings.digits` | result |
| --- | --- | --- |
| DKK (2), JPY (0) | `2` | ¥1234.56 — yen have no minor unit, so this is not an amount that can exist |
| DKK (2), JPY (0) | `0` | 49.95 DKK rounds to 50; the fold's carry drifts from the bank's |
| DKK (2), USD (2), EUR (2), GBP (2) | `2` | correct — every currency agrees |
The third row is the shipped dataset, which is why this is safe *today*. It
stops being safe the first time a zero- or three-decimal currency is added, and
nothing in the app will say so. Adding a per-currency override later is the
escape hatch, and §"If this needs undoing" says what that would take.
This directly supersedes the AGENTS.md §1 invariant "`roundMoney(amount,
digits)` takes a DIGIT COUNT... Decimal places live in `Dataset.currencies`".
That bullet gets rewritten, not left standing — three files disagreeing about
where digits live is worse than the change itself.
## The shape
### 1. Types
```ts
export interface Settings {
baseCurrency: Currency;
foldStartMonth: MonthId;
schemaVersion: number;
/** Decimal places for EVERY amount, whatever its currency. Integer 0-4. */
digits: number;
fxApiUrl?: string;
}
export interface CurrencyDef {
code: Currency;
symbol?: string;
name?: string;
// `digits` removed.
}
```
`digits` is required on `Settings`, not optional. An optional field would mean
every read needs a `?? 2` fallback, and a fallback of 2 is exactly the silent
wrongness the old required-argument rule was built to prevent.
`DEFAULT_CURRENCY_DIGITS` is renamed `DEFAULT_DIGITS` and keeps its one job:
the value a brand-new dataset seeds with.
### 2. `digitsFor` is deleted, not re-pointed
`digitsFor(currencies, code)` resolves a code against a table. With one global
value the code argument is meaningless, and a function that accepts an argument
it ignores invites call sites to believe it still matters.
So the sixteen call sites, spread over ten files, resolve
`dataset.settings.digits` directly, and the domain functions that took
`currencies: readonly CurrencyDef[]` *only* to resolve digits take
`digits: number` instead:
| file | today | after |
| --- | --- | --- |
| `domain/allocation.ts` | `resolveRule(..., currencies)` | `resolveRule(..., digits)` |
| `domain/fx.ts` | `toBase/fromBase(..., currencies)` | `..., digits` (rates still passed) |
| `domain/charges.ts` | three `digitsFor` calls: purchase currency, base, base again | one `digits` |
| `domain/fold.ts`, `domain/views.ts` | `digitsFor(dataset.currencies, base)` | `dataset.settings.digits` |
| `store/actions.ts` | `digitsFor(draft.currencies, money.currency)` | `draft.settings.digits` |
| `export/ods.ts` | `digitsFor(dataset.currencies, base)` | `dataset.settings.digits` |
| `ui/components/{PlanEditor,SplitEditor,BulkLines}.tsx` | `digitsFor(useDataset().currencies, draft.currency)` | `useDataset().settings.digits` |
If the recurring-costs change (`2026-09-03-recurring-costs-design.md`, schema
6) has landed first, `domain/occurrences.ts` and the expected-charge half of
`domain/charges.ts` are two more call sites resolving `settings.digits` the same
way. Neither changes the shape of this work.
This is a genuine simplification: those domain functions never wanted a
currency table, only a number, and the table was threaded through purely to
resolve one. `currencies` stays a parameter only where it is used for something
else — symbols, names, validation.
`currencyCodes`, `findCurrency`, `normalizeCurrencyCode` and `currencyUsage`
are untouched. Codes remain identity; nothing here changes that.
### 3. Migration to the next free schema version
This step takes **whichever version is next when it is built**: 6 if it lands
before the recurring-costs step, 7 if after. The two are independent and
compose in either order, so neither spec can name its number in advance without
presuming which is implemented first. Code is still at `SCHEMA_VERSION = 5`, so
nothing is committed to yet.
Call it N below. `SCHEMA_VERSION` becomes N, and one step is appended.
**Implemented as 6**: this landed before the recurring-costs step, which is
therefore reassigned to 7. The constant is `V6_FALLBACK_DIGITS`.
- sets `settings.digits` to the **maximum** of the per-currency `digits` it
finds in `data.currencies`;
- strips `digits` from every currency entry;
- falls back to a frozen local `const VN_FALLBACK_DIGITS = 2` — named for the
version it lands as — when the table is missing, empty, or carries no usable
number.
Maximum rather than the base currency's value, because the migration must not
make an amount already stored less precise than it is. A dataset holding KWD at
3 has amounts with three decimals in it; migrating to the base's 2 would mean
the next edit of one of those amounts silently truncates it. Taking the maximum
can only ever keep more precision than before, never less.
Per AGENTS.md §2 the step reads no live default — that fallback is a
frozen local literal, and `data.currencies` is the user's own data, not a
default. Existing steps are not edited.
Behaviour on the shipped dataset is preserving by construction: every seeded
currency records 2, so the maximum is 2 and not one stored amount rounds
differently than it did before.
### 4. Import validation
In `export/json.ts`:
- `requireCurrencyTable` stops validating a per-entry `digits`, and stops
copying it into the parsed entry. Duplicate-code and code-shape checks stay.
- a new `settings.digits` check: present, a number, an integer, `0 <= n <= 4`.
Same bounds the per-currency check used, same error style.
- `parseDatasetJson` already calls `migrate` before validating, so a file
exported at the previous version imports correctly through this step. One that still carries
per-currency `digits` after migration has them ignored rather than rejected —
an unknown field is not a reason to refuse the owner's only backup.
### 5. UI
`ui/routes/settings/CurrencySection.tsx`:
- the `Decimals` column leaves the table — header, cell, and the `w-16` input.
The table loses ~80px, which is what prompted this;
- `AddCurrency` loses its decimals field and its `digits` state;
- one new control beside "Base currency", labelled `Decimals (all amounts)`:
a `type="number"` `min=0 max=4 step=1` input writing through a new
`setDigits(draft, n)` action, rejecting out-of-range input the way the old
per-row input did;
- `setDigits` goes in `store/actions.ts` beside `setBaseCurrency`, and the call
site captures `event.target.value` into a `const` before `mutate` — AGENTS.md
§2, the deferred-write rule.
**Changing the setting does not rewrite stored amounts.** An amount stored as
49.95 stays 49.95 when the setting moves to 0; it displays as 50 and every
total recomputes at the new precision, but storage is left alone. Rewriting
every `Money` in the dataset is a destructive whole-dataset write, which
AGENTS.md §1 requires a backup export for, and it is lossy in one direction —
going 0 → 2 cannot bring back what 2 → 0 discarded. The setting is a rounding
rule going forward, not a data rewrite. The control gets a hint saying so.
### 6. Tests
- **`domain/currencyDigits.test.ts` is rewritten, not deleted.** Its teeth come
from driving a non-2 digit count end-to-end, and that still works: the
dataset gets `settings.digits: 0` globally and the same paths are exercised —
`roundMoney`, both FX directions, both split modes, plan division, the fold,
the month view. It can no longer prove digits flow *per currency*, because
they no longer do; it still proves they flow *from the dataset* rather than
being assumed to be 2, which is what the guard is for.
- **`domain/currencies.test.ts`** loses its `digitsFor` block.
- **`store/migrations.test.ts`** gains a case for this step: mixed per-currency digits
collapse to the maximum, entries come out without `digits`, an absent table
yields the frozen fallback. The existing no-live-default assertion covers the
new step automatically.
- **`export/json.test.ts`** gains `settings.digits` acceptance and rejection
cases (missing, `2.5`, `5`, `-1`), and loses per-currency `digits` rejection.
- 41 `digits:` literals across 15 test files construct `CurrencyDef`s or
datasets and need mechanical updating. This is the bulk of the diff and the
main risk of the change: a test left constructing a currency with `digits`
will still typecheck as an excess property in some positions.
### 7. Documents
- **AGENTS.md §1** — the `roundMoney` bullet is rewritten around
`settings.digits`; the `Dataset.currencies` bullet drops "how many decimals
they round to" from what the table is authority for.
- **AGENTS.md §2** — the `currencyDigits.test.ts` guard bullet is rewritten to
describe the global-zero-decimals drive, and to say plainly that a
mixed-decimal dataset is no longer representable, so nobody re-adds the old
guard as a "missing" check.
- **docs/DECISIONS.md** — a new entry under "Money and the data model" pointing
here, and the schema line updated to N. That line reads "Now at version 5"
today; if the recurring-costs step lands first it will already say 6, so
match what is actually there rather than searching for the 5.
- **docs/ARCHITECTURE.md** — updated wherever it describes digits as living per
currency.
## Alternatives considered
- **A default for new currencies, per-currency kept in the data.** One visible
setting feeding `AddCurrency`, the column gone from the table, `digitsFor`
untouched. Gets the table narrower with no migration and no invariant
rewritten, and keeps a JPY expressible. Rejected by the owner in favour of
the genuinely global setting; it remains the cheapest path back.
- **Display-only rounding.** A preference that rounds what the UI shows while
storage keeps full precision. Rejected: displayed parts stop summing to
displayed totals, which reads as a bug in a budgeting app.
- **Keeping the column.** The horizontal scrollbar that prompted this turned
out to be an 8px negative margin on the row's remove button, fixed separately
and already resolved, so nothing about the table's width requires this change.
It proceeds on the owner's preference for a simpler model, not on necessity.
## If this needs undoing
Re-adding an optional `CurrencyDef.digits` that overrides `settings.digits`
when present is a later migration that needs no data change — absent means
"use the global". The domain functions would take `digits: number` still, with
resolution moving up to the call site. The expensive half of this change, the
41 test constructions, does not have to be undone to get there.
## Display, which this spec got wrong
An earlier draft put formatting out of scope, on the assumption that
`src/ui/format.ts` already read digits and would simply follow the new source.
It did not: it pinned `minimumFractionDigits: 2, maximumFractionDigits: 2` in a
module-level `Intl.NumberFormat`, so display never consulted the dataset at
all. That was equally broken before this change — a zero-decimal currency
rendered "3,333.00" under the per-currency table too — so it is a pre-existing
gap rather than a regression, but shipping a prominent Settings control that
display ignores would have made the setting look broken at 0 and been LOSSY at
3 and 4, hiding precision the dataset really holds.
So display is treated as the rounding boundary it is:
- the four functions in `format.ts` take `digits` explicitly, with no default,
exactly as `roundMoney` does. `Intl.NumberFormat` instances are cached per
digit count, since constructing one is the expensive part and a table of
thirty money cells would otherwise build thirty per render;
- `src/ui/hooks/useMoneyFormat.ts` binds them to `settings.digits` and is what
components call — 64 call sites across 10 files. A hook rather than threaded
props because display is needed at every depth and the setting is one value
for the whole dataset; the underlying functions stay pure and testable
without a React tree;
- `postRowCells` in `PostTable.tsx` is a plain helper, not a component, so it
takes a `MoneyFormat` argument instead of calling the hook.
Verified in Chrome: the same month reads `600` / `206` at zero decimals and
`600.000` / `205.561` at three — so the figures are recomputed, not merely
reformatted.
## Out of scope
Parsing (`ui/moneyInput.ts`, `ui/purchaseForm.ts`, `ui/bulkEntry.ts`) already
takes digits as an argument and needed no change beyond its new source.
Currency symbols, names, the FX table, the rate service and the fold's math are
all untouched.
+12 -9
View File
@@ -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: [
+5 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+9 -21
View File
@@ -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", () => {
+8 -25
View File
@@ -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[] {
+49 -24
View File
@@ -5,20 +5,28 @@ import { equalSlices, sliceTotal } from "./plans.ts";
import { resolveRule } from "./allocation.ts";
import { foldBalances, figuresFor } from "./fold.ts";
import { monthView } from "./views.ts";
import { SCHEMA_VERSION } from "./seed.ts";
import type { CurrencyDef, Dataset } from "./types.ts";
/**
* Every currency the app shipped with has two decimal places, so code that
* hardcodes 2 is indistinguishable from code that reads the dataset. Now that
* the owner can define currencies, that stops being true — and a zero-decimal
* currency is the only thing that can prove digits actually flow from the
* dataset to each rounding site rather than being assumed.
* Decimal places are ONE setting for the whole dataset — `settings.digits` —
* and every currency rounds to it. See
* docs/specs/2026-09-02-global-decimals-design.md for what that gave up.
*
* JPY is the real-world case: yen have no minor unit, so ¥3333.3 is not a
* number that can exist.
* Two decimals is right for every currency the app ships with, so code that
* hardcodes 2 is indistinguishable from code that reads the setting. Driving a
* dataset at zero decimals is the only thing that tells them apart, and that
* is what this file exists for: it proves digits reach each rounding site FROM
* THE DATASET. It can no longer prove they vary per currency, because they no
* longer do.
*
* Yen are the reason zero is a real setting and not a contrivance: ¥3333.3 is
* not a number that can exist.
*/
const JPY: CurrencyDef = { code: "JPY", digits: 0, symbol: "¥" };
const USD: CurrencyDef = { code: "USD", digits: 2, symbol: "$" };
const DIGITS = 0;
const JPY: CurrencyDef = { code: "JPY", symbol: "¥" };
const USD: CurrencyDef = { code: "USD", symbol: "$" };
const CURRENCIES = [JPY, USD];
describe("rounding takes its digits from the dataset", () => {
@@ -31,52 +39,59 @@ describe("rounding takes its digits from the dataset", () => {
expect(roundMoney(1.2345, 3)).toBe(1.234);
});
test("a percentage allocation in a zero-decimal currency yields whole units", () => {
test("a percentage allocation at zero decimals yields whole units", () => {
const rate = resolveRule(
{ kind: "percentOfIncome", percent: 33.333 },
10_000,
"JPY",
[],
CURRENCIES,
DIGITS,
);
expect(rate).toBe(3333);
expect(Number.isInteger(rate)).toBe(true);
});
test("an FX conversion into a zero-decimal base yields whole units", () => {
test("an FX conversion at zero decimals yields whole units both ways", () => {
const rates = [
{ currency: "USD", baseUnitsPerOne: 151.234567, updatedAt: "2026-01-01", source: "manual" as const },
];
expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, CURRENCIES)).toBe(1512);
// And back out: 1512 / 151.234567 = 9.9977..., rounded to USD's TWO
// decimals, so 10 — not the three-decimal 9.998.
expect(fromBase(1512, "USD", "JPY", rates, CURRENCIES)).toBe(10);
expect(toBase({ amount: 10, currency: "USD" }, "JPY", rates, DIGITS)).toBe(1512);
// Back out: 1512 / 151.234567 = 9.9977..., and the SAME setting rounds it,
// so 10. Under per-currency digits this rounded to USD's two places; the
// arithmetic differs, the "no fractional unit escapes" property does not.
expect(fromBase(1512, "USD", "JPY", rates, DIGITS)).toBe(10);
expect(Number.isInteger(fromBase(1512, "USD", "JPY", rates, DIGITS))).toBe(true);
});
test("a split distributed in a zero-decimal currency still sums exactly", () => {
const parts = distributeByWeight(1000, [1, 1, 1], 2, 0);
test("a split distributed at zero decimals still sums exactly", () => {
const parts = distributeByWeight(1000, [1, 1, 1], 2, DIGITS);
expect(parts).toEqual([333, 333, 334]);
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
expect(parts.every(Number.isInteger)).toBe(true);
});
test("fixed amounts distributed in a zero-decimal currency still sum exactly", () => {
const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, 0);
test("fixed amounts distributed at zero decimals still sum exactly", () => {
const parts = distributeByAmount(1000, [333.4, 333.4, 0], 2, DIGITS);
expect(parts.reduce((a, b) => a + b, 0)).toBe(1000);
expect(parts.every(Number.isInteger)).toBe(true);
});
test("a finance plan divides into whole units and sums exactly", () => {
const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, 0);
const slices = equalSlices({ amount: 1000, currency: "JPY" }, "2026-01", 3, DIGITS);
expect(slices.map((s) => s.amount.amount)).toEqual([333, 333, 334]);
expect(sliceTotal(slices, 0)).toBe(1000);
expect(sliceTotal(slices, DIGITS)).toBe(1000);
});
});
describe("the fold in a zero-decimal currency", () => {
describe("the fold at zero decimals", () => {
function dataset(): Dataset {
return {
settings: { baseCurrency: "JPY", foldStartMonth: "2026-01", schemaVersion: 3 },
settings: {
baseCurrency: "JPY",
foldStartMonth: "2026-01",
schemaVersion: SCHEMA_VERSION,
digits: DIGITS,
},
currencies: CURRENCIES,
fxRates: [],
posts: [
@@ -116,4 +131,14 @@ describe("the fold in a zero-decimal currency", () => {
expect(Number.isInteger(view.unallocated)).toBe(true);
expect(view.unallocated).toBe(10_000 - 3333);
});
/**
* The setting is what is read, not the base currency's old per-currency
* entry and not a constant: moving it to 2 has to change the numbers.
*/
test("raising the setting to two decimals changes what the fold produces", () => {
const twoPlaces = { ...dataset(), settings: { ...dataset().settings, digits: 2 } };
const fold = foldBalances(twoPlaces, "2026-01");
expect(figuresFor(fold, "food", "2026-01").allocation).toBe(3333.3);
});
});
+8 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+15 -6
View File
@@ -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
View File
@@ -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;
}
+7 -4
View File
@@ -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
View File
@@ -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();
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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);
});
+56 -15
View File
@@ -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);
}
});
});
+92 -14
View File
@@ -164,29 +164,29 @@ describe("v2 -> v3: currencies become data", () => {
};
}
test("the three shipped currencies are added, with their decimals", () => {
test("the three shipped currencies are added", () => {
const migrated = migrate(v2());
expect(migrated.settings.schemaVersion).toBe(SCHEMA_VERSION);
// The first three come from this step, frozen at what it always produced.
// GBP is appended later, by 4 -> 5; see that block.
// GBP is appended later, by 4 -> 5; see that block. The decimals this step
// wrote per currency are collapsed into one setting by 5 -> 6, so they no
// longer appear on the definitions here.
expect(migrated.currencies).toEqual([
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "USD", digits: 2, symbol: "$", name: "US dollar" },
{ code: "EUR", digits: 2, symbol: "€", name: "Euro" },
{ code: "GBP", digits: 2, symbol: "£", name: "British pound" },
{ code: "DKK", symbol: "kr", name: "Danish krone" },
{ code: "USD", symbol: "$", name: "US dollar" },
{ code: "EUR", symbol: "€", name: "Euro" },
{ code: "GBP", symbol: "£", name: "British pound" },
]);
});
/**
* The whole point of the migration being safe: every currency that existed
* had two decimal places, and the seeded table says two, so not one stored
* amount can round differently than it did before.
* had two decimal places, this step wrote two, and 5 -> 6 takes the maximum
* of those — two. So not one stored amount can round differently than it did
* before, across the whole chain.
*/
test("no amount can round differently than it did under v2", () => {
const migrated = migrate(v2());
for (const currency of migrated.currencies) {
expect(currency.digits).toBe(2);
}
expect(migrate(v2()).settings.digits).toBe(2);
});
test("nothing else about the dataset is touched", () => {
@@ -312,7 +312,15 @@ describe("v4 -> v5: GBP joins the prebaked currencies", () => {
// definition, and a migration that corrected it would be editing data.
const mine = { code: "GBP", digits: 0, symbol: "quid" };
const out = migrate(v4([DKK, mine]));
expect(out.currencies.find((c) => c.code === "GBP")).toEqual(mine);
// Their symbol survives; their 0 does not, because 5 -> 6 collapses every
// per-currency figure into one setting and takes the maximum — DKK's 2.
// This is the cost that spec records: sterling and kroner can disagree
// about decimals in a v5 dataset and cannot in a v6 one.
expect(out.currencies.find((c) => c.code === "GBP")).toEqual({
code: "GBP",
symbol: "quid",
});
expect(out.settings.digits).toBe(2);
});
test("a GBP rate the owner already has keeps their number", () => {
@@ -329,7 +337,8 @@ describe("v4 -> v5: GBP joins the prebaked currencies", () => {
test("everything else is left untouched", () => {
const out = migrate(v4([DKK], [USD_RATE]));
expect(out.fxRates.find((r) => r.currency === "USD")).toEqual(USD_RATE);
expect(out.currencies[0]).toEqual(DKK);
const { digits: _dropped, ...dkkWithoutDigits } = DKK;
expect(out.currencies[0]).toEqual(dkkWithoutDigits);
});
test("GBP is not added twice when the migration is somehow reapplied", () => {
@@ -339,6 +348,75 @@ describe("v4 -> v5: GBP joins the prebaked currencies", () => {
});
});
describe("v5 -> v6: decimals become one setting for the whole dataset", () => {
const v5 = (currencies: any[]) => ({
settings: { baseCurrency: "DKK", foldStartMonth: "2026-01", schemaVersion: 5 },
currencies,
fxRates: [],
posts: [],
months: [],
purchases: [],
});
test("the setting takes the MAXIMUM of the per-currency digits", () => {
// Not the base currency's 2: a dataset holding KWD has three-decimal
// amounts already stored, and migrating to 2 would truncate the next edit
// of one. The maximum can only ever keep more precision, never less.
const out = migrate(v5([
{ code: "DKK", digits: 2 },
{ code: "JPY", digits: 0 },
{ code: "KWD", digits: 3 },
]));
expect(out.settings.digits).toBe(3);
});
test("digits are stripped from every currency entry", () => {
const out = migrate(v5([
{ code: "DKK", digits: 2, symbol: "kr", name: "Danish krone" },
{ code: "JPY", digits: 0 },
]));
for (const currency of out.currencies) {
expect("digits" in currency).toBe(false);
}
// Everything else about the definition survives.
expect(out.currencies[0]).toEqual({ code: "DKK", symbol: "kr", name: "Danish krone" });
});
test("a table where every currency agrees is behaviour-preserving", () => {
// The shipped dataset: four currencies, all at 2, so nothing rounds
// differently after the step than before it.
const out = migrate(v5([
{ code: "DKK", digits: 2 },
{ code: "USD", digits: 2 },
{ code: "EUR", digits: 2 },
{ code: "GBP", digits: 2 },
]));
expect(out.settings.digits).toBe(2);
});
test.each([
["an empty table", []],
["digits that are not numbers", [{ code: "DKK", digits: "two" }]],
["entries with no digits at all", [{ code: "DKK" }]],
])("%s falls back to two places rather than NaN", (_label, currencies) => {
const out = migrate(v5(currencies as any[]));
expect(out.settings.digits).toBe(2);
});
test("a missing currencies table does not throw", () => {
const data: any = v5([]);
delete data.currencies;
expect(migrate(data).settings.digits).toBe(2);
});
test("everything else in settings is left untouched", () => {
const out = migrate(v5([{ code: "DKK", digits: 2 }]));
expect(out.settings.baseCurrency).toBe("DKK");
expect(out.settings.foldStartMonth).toBe("2026-01");
expect(out.settings.schemaVersion).toBe(SCHEMA_VERSION);
});
});
/**
* A migration's output must not change when today's defaults change. The 2 -> 3
* step originally seeded `SEED_CURRENCIES` by reference, so adding GBP to that
+39
View File
@@ -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 {
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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"} &middot;{" "}
<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>
);
+5 -5
View File
@@ -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>
+14 -8
View File
@@ -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">
+4 -4
View File
@@ -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>
+6 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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}`;
}
+37
View File
@@ -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>;
+7 -5
View File
@@ -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
+9 -8
View File
@@ -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>
))}
+9 -8
View File
@@ -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} &middot; {year}
</h1>
<p className="text-sm text-muted-foreground">
Allocated {formatMoney(row.totalAllocation, base)} &middot; spent{" "}
{formatMoney(row.totalCharges, base)} &middot; closing balance{" "}
{formatSignedMoney(row.closingBalance, base)}
Allocated {fmt.money(row.totalAllocation, base)} &middot; spent{" "}
{fmt.money(row.totalCharges, base)} &middot; 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>
);
+7 -6
View File
@@ -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
? "—"
+4 -3
View File
@@ -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>
+64 -68
View File
@@ -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>