diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6a1a11e..1dba94d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -88,6 +88,15 @@ 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. + ## Splits and finance plans compose A purchase can be split across posts *and* spread over months at once. The trick diff --git a/CLAUDE.md b/CLAUDE.md index ff40d3d..f01c9e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,13 +111,29 @@ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.m without an explicit argument. It must be testable with no DOM. - Money is a float. Always round with `roundMoney` from `src/domain/money.ts` after any division, after any FX conversion, and before persisting. +- **`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. - When splitting an amount, never round the last part independently. Compute it as `total - sum(others)` so the parts sum exactly to the whole. - `MonthId` is the string `"YYYY-MM"`. Stored data never contains `Date` objects. +- **`Currency` is an open string code; `Dataset.currencies` is the authority.** + There is no global list of valid currencies any more. 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 (`currencyUsage` names what refers to it). `parseDatasetJson` + validates the table and every `Money` against it; that boundary check is + what replaced the compile-time safety of the old closed union. - The envelope rollover fold has exactly one implementation, in `src/domain/fold.ts`. Year and summary views aggregate over it — they never reimplement the math. - Over-budget is always allowed. Never add validation that blocks it. +- An amount field parses its own currency: "30$" is thirty dollars. Use + `parseMoneyInput` (`src/ui/moneyInput.ts`) and keep the input `type="text"` — + a number input discards the "$" before the parser ever sees it. - Base path comes from `BUN_PUBLIC_BASE_PATH`; never hardcode a subpath, and read it through a `try/catch` accessor — see the gotcha below. - A post's allocation is a dated series, `Post.rules: RuleVersion[]`, resolved by @@ -148,8 +164,16 @@ choice. unset, the bare reference reaches the browser and throws before the app boots. A `typeof process` guard does not work — it survives inlining and discards the inlined value, silently breaking subpath deploys. -- **Never define a bare `--accent` CSS variable.** shadcn owns it; overriding it - drops hover contrast below WCAG AA app-wide. Project tokens are `--budget-*`. +- **Never define a bare `--accent` CSS variable.** shadcn owns it as a hover + BACKGROUND paired with `--accent-foreground`; setting one without the other + drops hover contrast below WCAG AA app-wide. Redefine shadcn tokens only in + pairs — `src/cssPairs.test.ts` enforces it — and put brand colour on + `--primary`, never `--accent`. Project tokens are `--budget-*`. +- **A `var(--token)` naming a token `src/index.css` does not define paints + NOTHING, silently.** An undefined custom property invalidates the whole + declaration: no warning, no throw, no fallback. Renaming `--rule` this way + killed the carry meter and no test noticed. `src/ui/cssTokens.test.ts` guards + it. - **A React error boundary does not catch event-handler throws.** Domain functions that throw must be guarded at the call site when called from one. - **Do not "fix" a domain throw by making it return a fallback.** Silently wrong