From 68a992d4ec04fd2d090042309ec98cb697573933 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 26 Aug 2026 17:55:38 +0300 Subject: [PATCH 1/4] =?UTF-8?q?ledger:=20restate=20every=20leg=20onto=20it?= =?UTF-8?q?s=20account=20currency's=20scale=20before=20the=20zero-sum=20ch?= =?UTF-8?q?eck=20(#304=20=C2=A7A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-currency zero-sum invariant was unsound in both directions, and two tests now pin it. `TransactionLeg::amount` is a `morph::math::Rational` used as a scaled integer -- the numerator counts the currency's minor units and `decimalPlaces` names the scale they are counted at, which is how `parseAmount`, `LedgerQmlBridge::storeTransaction` and every QML money label already read it. `Rational` reads the same triple differently: its value is `numerator/denominator` and `decimalPlaces` is a display tag that, by its own spec, "never changes a stored value" and is ignored entirely by comparison. Nothing restated a leg before summing it, and `Rational::operator+` adds numerators and propagates `std::max` of the two precisions, so: - $4.50 (`{450, dp 2}`) against -$45.00 (`{-450, dp 1}`) netted to a numerator of zero and was **accepted** -- a journal booking four dollars fifty against forty-five dollars, posted as balanced; - $4.50 written `{45, dp 1}` against -$4.50 written `{-450, dp 2}` netted to -405 and was **rejected**, though it balances. Both were reproduced against the real `StoreTransaction` path before this change: the first as "no exception was thrown where one was expected", the second as "zero-sum violation in USD: legs did not sum to zero". The fix is `ledger::restateMinorUnits` (new `ledger/core/money.hpp`), applied in `execute(StoreTransaction)` before the partitioning loop and in `storeJournalImpl` before its transaction opens, so undo and CSV import get it too. Restating is exact or nothing: widening goes through `morph::math::checkedMul` so an overflow is reported rather than saturated, and narrowing is refused outright when it would drop a non-zero digit. An amount with more precision than its currency has ($4.505 in USD), or a non-integral minor-unit count off the wire (`{"num":9,"den":2}`), is rejected with `ValidationError`. The model never rounds money. The restated amount is also what gets stored, which fixes a second, longer-lived symptom: `buildLedgerState` seeds each balance at the currency's precision and accumulates with the same `std::max` propagation, so one leg written at dp 4 in a USD account rendered that account's balance at a hundredth of its value for as long as the row existed. `SetBudgetLimit` restates a limit the same way -- a limit is compared against a sum of legs, and two amounts only compare as money on one scale -- and `GetBudgetReport` now seeds its running total at the report currency's own precision instead of a hardcoded 2, so a JPY budget stops reporting yen as if they had cents. Direction, since two were on the table. Moving leg amounts to `morph::units::Quantity` is what the design spec claimed the rung did, and it is not implementable: `Quantity` takes its unit as a compile-time non-type template parameter and a leg's currency is the account's runtime data, so `Money` cannot type a leg without putting a false USD tag on every EUR, JPY and KRW one -- a conclusion the spec's own §2 had already reached two paragraphs after asserting the opposite. So the encoding stays, and is now written down and enforced rather than assumed. `Money` is used where the currency *is* known at the point of use: `ledger::formatMoney` switches on it to render through `morph::units::toDecimalString`. `tests/test_ledger_rational_fuzz.cpp` had enshrined the false accept as correct behaviour. Its first case was titled "zero-sum check never false-positives across differing decimalPlaces in one currency" and claimed its two operands were "constructed to sum to true zero once both are reduced to a common scale" -- they are not reduced to a common scale, they are -$50.00 and +$0.50, and the case never reaches the model at all. It now says what it actually measures, and points at the model tests for the invariant. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-19-ledger-rung5-design.md | 118 +++++++-- examples/ledger/README.md | 80 +++++- examples/ledger/include/ledger/core/money.hpp | 174 +++++++++++++ .../include/ledger/models/ledger_model.hpp | 14 +- examples/ledger/src/models/budget_model.cpp | 33 ++- examples/ledger/src/models/ledger_model.cpp | 83 +++++- examples/ledger/tests/test_budget_model.cpp | 70 +++++ examples/ledger/tests/test_ledger_import.cpp | 66 +++++ examples/ledger/tests/test_ledger_model.cpp | 244 ++++++++++++++++++ examples/ledger/tests/test_ledger_units.cpp | 96 +++++++ tests/test_ledger_rational_fuzz.cpp | 26 +- 11 files changed, 950 insertions(+), 54 deletions(-) create mode 100644 examples/ledger/include/ledger/core/money.hpp diff --git a/docs/superpowers/specs/2026-08-19-ledger-rung5-design.md b/docs/superpowers/specs/2026-08-19-ledger-rung5-design.md index 00a6d33b..18540af1 100644 --- a/docs/superpowers/specs/2026-08-19-ledger-rung5-design.md +++ b/docs/superpowers/specs/2026-08-19-ledger-rung5-design.md @@ -77,18 +77,43 @@ throughout — this is itself a deliberate contrast with `bank::Money` (`examples/bank/include/bank/core/money.hpp`: `struct Money { int64_t minor; Currency currency; }`, whose `operator+`/`-` do not check currency match — exactly the class of bug this rung exists to make structurally -impossible). Every leg amount is `morph::units::Quantity` -(never `bank::Money`, never a bare `int64_t minor`); account kind, rule -trigger/action types are `enum class`; account/journal/category/budget/rule -identity are per-entity strong id types (`AccountId`, `JournalId`, ...) with -`hasValue()`. +impossible). Account kind and rule trigger/action types are `enum class`; +account/journal/category/budget/rule identity are per-entity strong id types +(`AccountId`, `JournalId`, ...) with `hasValue()`. + +**Money is the one field the palette cannot type.** `morph::units::Quantity` +takes its unit as a *compile-time* non-type template parameter +(`Quantity`), and a leg's currency is +the account's runtime data — §2 below sets out why there is no +`Quantity` spelling meaning "whichever currency this account +happens to hold", and `ledger::Money` +(`examples/ledger/include/ledger/core/units.hpp`) fixes `C` at compile time by +construction. A leg amount is therefore a `morph::math::Rational` carrying a +**whole number of the currency's minor units**, with `decimalPlaces` naming +the scale those units are counted in: `$4.50` is `{num: 450, den: 1, dp: 2}`, +`¥500` is `{num: 500, den: 1, dp: 0}`. Never `bank::Money`, and never a bare +`int64_t minor` — the scale travels with the value. `Money` *is* used where +the currency is known at the point of use: the display path (§7). + +That encoding is deliberately **not** `Rational`'s own reading of the same +triple. `include/morph/util/rational.hpp` defines the value as +`numerator/denominator` with `decimalPlaces` a display tag that "never changes +a stored value", and compares "purely value-based on the canonical +(numerator, denominator) pair", ignoring `decimalPlaces` entirely. `Rational` +therefore reads `{450, 1, dp 2}` and `{450, 1, dp 1}` as the same number where +this rung reads `$4.50` and `$45.00`. **The two readings agree only when every +operand is on one scale, so the model puts them on one scale before it does +any arithmetic** — see step 1 of the zero-sum decision below. The full +rationale, the encoding's rules, and the framework gaps it surfaced are in +`examples/ledger/README.md`'s "How money is represented" +(`ledger/core/money.hpp` is the code). **`StoreTransaction { description, date, legs[] }`** — one composite, all-or-nothing action. `legs: std::vector` where -`TransactionLeg { accountId: AccountId, amount: Quantity }` -(the currency lives in the account, so a leg's amount type is generic over -`Currency` and the account's own currency determines the concrete unit at -validation time — see §2 for why this can't be a compile-time +`TransactionLeg { accountId: AccountId, amount: Rational }` (the currency +lives in the account, so a leg's amount carries only a minor-unit count and a +scale, and the account's own currency supplies the denomination at validation +time — see §2 for why this can't be a compile-time `Quantity` per leg). `validate()` requires `allRequiredEngaged` plus: at least two legs, every `accountId` engaged. @@ -100,9 +125,23 @@ balancing across.* Concretely, `LedgerModel::execute(StoreTransaction)`: 1. Partitions `legs` by `currencyCode` (the leg's account's currency, looked up from `AccountRecord`, never a client-supplied field — the client cannot assert a leg's currency independent of its account). -2. For each currency partition, sums the `Rational` amounts (via - `Rational::operator+`, which propagates precision as the `max` of the - operands' own precisions) and asserts the sum is canonical zero (`0/1`). + **Restates every leg onto that account currency's scale** before it + enters a partition (`ledger::restateMinorUnits` against + `currencyDecimalPlaces` of the currency just looked up). Leg amounts are + minor-unit counts and nothing on the wire constrains the scale a client + sends them at; without this step the check is unsound in *both* + directions, because `Rational::operator+` adds numerators and propagates + `std::max` of the two precisions. `$4.50` (`{450, dp 2}`) and `-$45.00` + (`{-450, dp 1}`) net to numerator zero and are **accepted**; `$4.50` + written `{45, dp 1}` and `-$4.50` written `{-450, dp 2}` net to -405 and + are **rejected**. Restating also keeps every stored leg of an account on + that account's own scale, which `buildLedgerState` relies on when it seeds + each balance at the currency's precision. Restating is exact or nothing — + an amount with more precision than its currency has (`$4.505` in USD), or + a non-integral minor-unit count off the wire (`{"num":9,"den":2}`), is + rejected with `ValidationError`. **The model never rounds money.** +2. For each currency partition, sums the restated `Rational` amounts (via + `Rational::operator+`) and asserts the sum is canonical zero (`0/1`). Rejects with `ZeroSumViolation{currency, actualSum}` on any partition that fails — **never rounds, never auto-balances**, per the README. 3. A **foreign-amount pair** is two legs on accounts of different @@ -161,12 +200,13 @@ correctly), so JPY/KRW need no app-side workaround or `x-rules` gate — see also the corresponding fix to `examples/ledger/README.md`'s "Expected strain points" section, made alongside this spec. -A leg's wire-level amount is `Quantity` as a -DTO-level default, with the model re-deriving the *actual* decimal places -from the account's currency at validation time — the `DeclaredDecimals` -template parameter is a schema/UI hint, not the runtime authority; the -`Rational` payload's own `decimalPlaces` field (set from the account's -currency, not the client's claim) is. +A leg's wire-level amount is a bare `morph::math::Rational` — a minor-unit +count plus the scale it is counted at (§1) — and the model derives the +*actual* scale from the account's currency at validation time: every leg is +restated onto `currencyDecimalPlaces(theAccountsCurrency)` before the +zero-sum check and before it is written. The client's `dp` is therefore an +input to that restatement, never the authority; the stored +`decimalPlaces` is always the account currency's own. **Exchange rates** are `Rational`, exact by construction — never `double`. A foreign-amount pair (§1) carries its booked rate implicitly as the ratio @@ -350,12 +390,20 @@ model tests separately assert the zero-sum invariant holds under the model's real validation path). Generates sequences of `StoreTransaction`- shaped leg sets at ledger-realistic magnitudes (dp 2 currencies up to 10^9 minor units, matching README's motivating case of "amount × -exchange-rate with high-dp currencies") and asserts: (a) the zero-sum -check never accepts a non-zero sum and never rejects a true zero (no false -positive/negative from precision mismatches across legs of differing -`decimalPlaces` within one currency — a legal but easy-to-mishandle case, -e.g. a USD leg at dp=2 and a correcting USD leg at dp=4 in the same -journal), and (b) *documents* — as a comment plus this section, once +exchange-rate with high-dp currencies") and asserts: (a) that +`Rational::operator+` is **scale-blind** — two legs at different +`decimalPlaces` are summed on their numerators alone, so a USD leg at dp=2 +and one at dp=4 net to canonical zero whenever their numerators cancel, no +matter what money they denote. That is a property of `Rational`, not a +guarantee about the invariant: this test cannot say anything about false +accepts or false rejects, because it never reaches the model. What makes +the invariant sound is §1's restatement step, which restates every leg onto its +account currency's scale *before* summing, and the false-accept and +false-reject cases are pinned where the model can actually be driven, in +`examples/ledger/tests/test_ledger_model.cpp`. A leg at dp=4 in a USD +account stays legal, but only when it carries no digit below a cent +(`{45000, dp 4}` restates to `{450, dp 2}`; `{45001, dp 4}` is rejected). +And (b) *documents* — as a comment plus this section, once measured, not asserted defensively in production code — the row count and per-leg magnitude at which an intermediate cross-term (the multiplication inside `amount × exchangeRate` that a foreign-amount pair's rate @@ -389,6 +437,28 @@ left to the repo owner's triage per `FINDINGS.md`) with a test proving the clamp-then-incidentally-caught path, so the gap is on record rather than silently absorbed by an invariant that happens to catch it this time. +**The no-float rule, and where rendering happens.** No money value becomes a +`double` anywhere on the path from database to screen. The bridges still +publish each amount's exact `numerator`/`denominator`/`decimalPlaces`, so a +view that wants to format differently can, but they also publish the +**rendered text** (`balanceText`, `limitText`, `spentText`, `amountText`) and +that is what every QML label binds. The rendering is +`ledger::formatMoney(currency, amount)` +(`examples/ledger/include/ledger/core/money.hpp`): it recovers the decimal +value from the minor-unit count, wraps it in `Money` for the currency — +the one place in this rung where the currency *is* known at compile time, and +therefore the one place `morph::units` can type money at all — and lets +`morph::units::toDecimalString` produce the digits by exact integer long +division. + +Formatting in the view was the earlier design, and it was wrong: QML has only +IEEE doubles, so `numerator / denominator / Math.pow(10, places)` +re-introduced in the last three lines of the path exactly the imprecision +`Rational` exists to remove, and drifted for balances past 2^53 while the +payload beneath it stayed exact. `toDecimalString` renders shortest-form +(`$4.50` as `"4.5"`); that is a `Quantity` gap recorded in the rung README, +not a reason to hand-roll a second formatter. + ## 8. CSV/OFX import with dedup (step 6) **Decision**: generalizes the same op-id + applied-ops-ledger pattern diff --git a/examples/ledger/README.md b/examples/ledger/README.md index a0bd232e..73f3cee2 100644 --- a/examples/ledger/README.md +++ b/examples/ledger/README.md @@ -149,6 +149,77 @@ Forms: transaction entry uses `morph::forms` schemas — amount fields as `Rational` with per-currency `x-decimalPlaces`, category combo via `forms::Choice` backed by a list action. +## How money is represented + +Every money value in this rung — a transaction leg, a budget limit, an +account balance, a report total — is a `morph::math::Rational` carrying a +**whole number of the currency's minor units**, with `decimalPlaces` naming +the scale those units are counted in. `$4.50` is `{num: 450, den: 1, dp: 2}`; +`¥500` is `{num: 500, den: 1, dp: 0}`. `ledger/core/money.hpp` owns the +encoding and the two operations it needs. + +**This is not `Rational`'s own reading of that triple.** `rational.hpp` +defines the value as `numerator/denominator` and calls `decimalPlaces` a +display tag that "never changes a stored value"; comparison is "purely +value-based on the canonical (numerator, denominator) pair and ignores +`decimalPlaces` entirely". `Rational` therefore reads `{450, 1, dp 2}` and +`{450, 1, dp 1}` as the same number, where this rung reads `$4.50` and +`$45.00`. The two readings agree only when every operand is on one scale. + +**The model is what guarantees that.** `LedgerModel::execute(StoreTransaction)` +and `storeJournalImpl` restate every leg onto *its own account currency's* +scale (`ledger::restateMinorUnits`, `ledger::currencyDecimalPlaces`) before +the per-currency zero-sum check runs and before any row is written; +`BudgetModel::execute(SetBudgetLimit)` restates a limit the same way. Restating +is exact or nothing — an amount with more precision than its currency has +(`$4.505` in a USD account) or a non-integral minor-unit count off the wire +(`{"num":9,"den":2}`) is rejected with `ValidationError`. **The model never +rounds money.** + +Without that step the invariant is unsound in both directions, because +`Rational::operator+` adds numerators and propagates `std::max` of the two +precisions: `$4.50` at `dp 2` and `-$45.00` at `dp 1` both have numerator +±450, so they net to zero and a journal booking four dollars fifty against +forty-five dollars is *accepted*; and `$4.50` written `{45, dp 1}` against +`-$4.50` written `{-450, dp 2}` nets to -405 and a balanced pair is +*rejected*. Both are pinned as tests in `tests/test_ledger_model.cpp`. +Restating also keeps every stored leg of an account on that account's own +scale, which `buildLedgerState` relies on when it seeds each balance at the +currency's precision — one leg stored at a wider scale would otherwise pull +that account's rendered balance off by a factor of ten permanently. + +**Why not `morph::units::Quantity`.** `Quantity` takes its unit as a +*compile-time* non-type template parameter (`Quantity`), and a leg's currency is the account's runtime data — +there is no `Quantity` spelling meaning "whichever currency this account +happens to hold", which is the conclusion the design spec's §2 already +reached. Typing every leg `Money` would put a false unit tag +on every EUR, JPY and KRW leg. Leg amounts therefore stay `Rational` and the +encoding above is the rung's binding convention. `Money` *is* used where +the currency is known at the point of use — the display path. + +**Display.** `ledger::formatMoney(currency, amount)` is the single rendering +path: it recovers the decimal value from the minor-unit count, hands it to +`Money` for the named currency, and lets `morph::units::toDecimalString` +produce the digits by exact integer long division. The QML views bind the +pre-rendered `balanceText` / `limitText` / `spentText` / `amountText` the +bridges publish. + +### Findings this encoding surfaced + +- **No fixed-fraction-width rendering on `Quantity`.** + `morph::units::toDecimalString` renders shortest-form, so `$4.50` comes + back as `"4.5"` and a zero balance as `"0"`. A money column wants `"4.50"` + and `"0.00"`; there is no width knob to ask for it. The rung renders + shortest-form rather than hand-rolling a second formatter. +- **No public integer power of ten.** `morph::math::detail::powerOfTen` is + exactly what restating between scales needs, but it lives in the + framework's `detail` namespace; `ledger::detail::powerOfTen` writes it out + again rather than depend on a private symbol. +- **No runtime-unit `Quantity`.** The gap under "Why not + `morph::units::Quantity`" above is the reason this rung's money type is a + bare `Rational` with an out-of-band encoding at all. + ## morph subsystems exercised Exact `Rational` arithmetic under a hard invariant; schema-driven money @@ -188,9 +259,12 @@ data; the submit→poll job idiom. normalizer strips it anywhere — typing `1.5` submits **15**, a silent 10× money error. Pin the behavior, fix (positional grouping validation or reject), and mirror the vectors through `normalizeLocaleNumber` (D5). - Related: result *display* in the shipped renderer goes through `double` - division — balances beyond 2^53 drift on readback while the payload is - exact; presenter display must use the exact formatter. + Related: result *display* in the shipped forms renderer goes through + `double` division — balances beyond 2^53 drift on readback while the + payload is exact. This rung's own views do not: every money label binds + text the bridge pre-rendered through `ledger::formatMoney`, which is exact + integer long division (see "How money is represented" below). No QML file + divides anything. - **Recurring transactions (time-scheduled jobs — this rung owns the shape)**: Firefly-style schedules are the ladder's one cron-shaped server job — who ticks, on what thread, under what principal, journaled diff --git a/examples/ledger/include/ledger/core/money.hpp b/examples/ledger/include/ledger/core/money.hpp new file mode 100644 index 00000000..b9cc9879 --- /dev/null +++ b/examples/ledger/include/ledger/core/money.hpp @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once + +#include +#include +#include +#include +#include + +#include "ledger/core/units.hpp" + +/// @file +/// How this rung encodes money, and the two operations that encoding needs. +/// +/// Every money value in ledger -- a transaction leg, a budget limit, an +/// account balance, a report total -- is a `morph::math::Rational` carrying a +/// **whole number of the currency's minor units**, with `decimalPlaces` +/// naming the scale those units are counted in. `$4.50` is +/// `{num: 450, den: 1, dp: 2}`; `¥500` is `{num: 500, den: 1, dp: 0}`. +/// +/// That is deliberately **not** `Rational`'s own reading of the same triple. +/// `include/morph/util/rational.hpp` defines the value as +/// `numerator/denominator` and calls `decimalPlaces` a display tag that +/// "never changes a stored value"; comparison is "purely value-based on the +/// canonical (numerator, denominator) pair and ignores `decimalPlaces` +/// entirely". `Rational` therefore reads `{450, 1, dp 2}` and `{450, 1, dp 1}` +/// as the same number, where this rung reads `$4.50` and `$45.00`. **The two +/// readings agree only when every operand sits on one scale**, so the model +/// restates each leg onto its own account currency's scale before it does any +/// arithmetic on it -- `restateMinorUnits` below is that step. +/// +/// `examples/ledger/README.md`'s "How money is represented" carries the full +/// rationale, including why the leg amount cannot be a +/// `morph::units::Quantity` and which framework gaps this encoding surfaced. + +namespace ledger { + +/// @brief The number of minor-unit digits @p c is denominated in -- +/// `UnitTraits::meta(c).defaultDecimals`, named at the money +/// layer so call sites read as currency precision rather than as unit +/// metadata. 2 for USD/EUR, 0 for JPY/KRW. +/// @param c The currency to describe. +/// @return The currency's declared decimal places. +[[nodiscard]] constexpr std::uint32_t currencyDecimalPlaces(Currency c) noexcept { + return ::morph::units::UnitTraits::meta(c).defaultDecimals; +} + +namespace detail { + +/// @brief `10^exponent` as an exact `std::int64_t`. +/// +/// `morph::math::detail::powerOfTen` is the same function, but it sits +/// in the framework's `detail` namespace and is therefore not part of +/// morph's public surface -- a rung calling it would depend on an +/// implementation detail. Written out here instead. That a +/// scaled-decimal application needs an integer power of ten at all is +/// recorded as a finding in `examples/ledger/README.md`. +/// @param exponent The power to raise ten to. +/// @return `10^exponent`, or `0` when that does not fit `std::int64_t` (i.e. +/// when @p exponent exceeds `morph::math::kMaxDecimalPlaces`). +[[nodiscard]] constexpr std::int64_t powerOfTen(std::uint32_t exponent) noexcept { + if (exponent > ::morph::math::kMaxDecimalPlaces) { + return 0; + } + auto result = std::int64_t{1}; + for (std::uint32_t digit = 0; digit < exponent; ++digit) { + result *= 10; + } + return result; +} + +} // namespace detail + +/// @brief Restates @p amount -- a whole number of minor units at its own +/// scale -- as the same money at @p targetPlaces. +/// +/// This is the operation that makes the per-currency zero-sum invariant +/// sound. Leg amounts arrive at whatever scale a client chose, and +/// `Rational::operator+` cannot notice the difference: it adds numerators and +/// propagates `std::max` of the two precisions. So `$4.50` (`{450, dp 2}`) +/// and `-$45.00` (`{-450, dp 1}`) net to a numerator of zero and pass a check +/// they should fail, while `$4.50` written `{45, dp 1}` and `-$4.50` written +/// `{-450, dp 2}` net to -405 and fail one they should pass. Restating every +/// operand onto one scale first removes both. +/// +/// Restating is exact or nothing. Widening goes through +/// `morph::math::checkedMul`, which reports overflow rather than saturating, +/// and narrowing is refused outright when it would drop a non-zero digit -- +/// the model never rounds money. +/// +/// @param amount The amount to restate. Must be a whole number of minor +/// units, i.e. canonical denominator 1; anything else -- a wire +/// payload of `{"num":9,"den":2}`, say -- is refused. +/// @param targetPlaces The scale to restate onto, normally +/// `currencyDecimalPlaces()` of the owning account's currency. +/// @return The same money expressed at @p targetPlaces, or `std::nullopt` +/// when @p amount is not a whole number of minor units, when +/// narrowing would lose a non-zero digit, or when widening would +/// overflow `std::int64_t`. +[[nodiscard]] inline std::optional<::morph::math::Rational> restateMinorUnits(const ::morph::math::Rational& amount, + std::uint32_t targetPlaces) { + if (amount.denominator != 1) { + return std::nullopt; + } + const auto target = ::morph::math::DecimalPlaces{targetPlaces}; + const auto sourcePlaces = amount.decimalPlaces.value; + if (sourcePlaces == targetPlaces) { + return ::morph::math::Rational{::morph::math::Numerator{amount.numerator}, ::morph::math::Denominator{1}, + target}; + } + const bool widening = sourcePlaces < targetPlaces; + const auto exponent = widening ? targetPlaces - sourcePlaces : sourcePlaces - targetPlaces; + const auto factor = ::morph::math::Rational{::morph::math::Numerator{detail::powerOfTen(exponent)}, + ::morph::math::Denominator{1}, target}; + if (factor.numerator == 0) { + // `targetPlaces` came from a currency and `sourcePlaces` from a + // canonical Rational, so both are within range and this is only + // reachable through a hand-built out-of-range request. + return std::nullopt; + } + const auto restated = + widening ? ::morph::math::checkedMul(amount, factor) : ::morph::math::checkedDiv(amount, factor); + // A narrowing quotient that does not reduce back to denominator 1 carried + // a non-zero digit below the target scale -- `$4.505` in a USD account. + if (!restated.has_value() || restated->denominator != 1) { + return std::nullopt; + } + return ::morph::math::Rational{::morph::math::Numerator{restated->numerator}, ::morph::math::Denominator{1}, + target}; +} + +/// @brief Renders @p minorUnits, denominated in @p currency, as exact decimal +/// text -- the single rendering path every ledger view uses. +/// +/// The minor-unit count is divided by its own scale to recover the decimal +/// value, that value is handed to `Money` for the currency the caller +/// named, and `morph::units::toDecimalString` produces the digits by exact +/// integer long division. No `double` exists anywhere on the path, which is +/// what `examples/ledger/README.md` requires of display: a QML +/// `numerator / denominator / Math.pow(10, places)` drifts for balances past +/// 2^53 while the payload stays exact. +/// +/// `toDecimalString` renders shortest-form, so `$4.50` comes back as `"4.5"` +/// and a zero balance as `"0"`; `Quantity` offers no fixed-fraction-width +/// mode to ask for `"4.50"` instead. The rung renders shortest-form rather +/// than hand-rolling a second formatter, and records the gap as a finding in +/// its README. +/// +/// @param currency The currency @p minorUnits is denominated in -- normally +/// the owning account's own, never a client-supplied claim. +/// @param minorUnits The amount, as a whole number of minor units at its own +/// scale. An amount not on @p currency's scale is rendered at the +/// scale it arrived on rather than silently mis-scaled. +/// @return The exact decimal text, with no currency symbol or unit suffix. +[[nodiscard]] inline std::string formatMoney(Currency currency, const ::morph::math::Rational& minorUnits) { + const auto onScale = restateMinorUnits(minorUnits, currencyDecimalPlaces(currency)).value_or(minorUnits); + const auto scale = + ::morph::math::Rational{::morph::math::Numerator{detail::powerOfTen(onScale.decimalPlaces.value)}, + ::morph::math::Denominator{1}, onScale.decimalPlaces}; + const auto value = (onScale / scale).value_or(::morph::math::Rational::zero(onScale.decimalPlaces)); + switch (currency) { + case Currency::EUR: + return ::morph::units::toDecimalString(Money{value}.atDeclaredPrecision()); + case Currency::JPY: + return ::morph::units::toDecimalString(Money{value}.atDeclaredPrecision()); + case Currency::KRW: + return ::morph::units::toDecimalString(Money{value}.atDeclaredPrecision()); + case Currency::USD: + default: + return ::morph::units::toDecimalString(Money{value}.atDeclaredPrecision()); + } +} + +} // namespace ledger diff --git a/examples/ledger/include/ledger/models/ledger_model.hpp b/examples/ledger/include/ledger/models/ledger_model.hpp index c56a3635..a57c7ae1 100644 --- a/examples/ledger/include/ledger/models/ledger_model.hpp +++ b/examples/ledger/include/ledger/models/ledger_model.hpp @@ -301,9 +301,9 @@ class LedgerModel { /// keeps its own inline insert logic rather than calling this /// helper, since threading its opId/cascade logic through this /// signature would be more churn than sharing is worth -- this - /// helper exists solely for `execute(UndoTransaction)` to call, - /// mirroring `setCategoryImpl`'s own role as a single-caller - /// extraction, not a refactor of `execute(StoreTransaction)`. + /// helper is called by `execute(UndoTransaction)` (once, for the + /// reversing entry) and by `execute(ImportLedgerChunk)` (once per + /// CSV row), never by `execute(StoreTransaction)`. /// /// Does not re-run `execute(StoreTransaction)`'s zero-sum /// partitioning loop -- it trusts its caller already knows the @@ -311,6 +311,14 @@ class LedgerModel { /// already-zero-sum set is itself zero-sum). Any future caller /// that cannot make that guarantee must validate before calling /// this helper. + /// + /// It *does* restate every leg onto its own account currency's + /// scale, exactly as `execute(StoreTransaction)` does, because + /// that is a property of the rows this method writes rather than + /// of the caller's own checking -- see `restateLegAmounts` in + /// `src/models/ledger_model.cpp`. A leg that is not a whole + /// number of its currency's minor units is rejected with + /// `ValidationError` before any transaction is opened. /// @param mapper The data mapper to mutate through -- opens its own /// `Lightweight::SqlTransaction` on this mapper's connection. /// @param ledgerId The ledger the new journal belongs to. diff --git a/examples/ledger/src/models/budget_model.cpp b/examples/ledger/src/models/budget_model.cpp index b7ab2631..300f9d59 100644 --- a/examples/ledger/src/models/budget_model.cpp +++ b/examples/ledger/src/models/budget_model.cpp @@ -12,6 +12,7 @@ #include "clock.hpp" #include "ledger/core/errors.hpp" +#include "ledger/core/money.hpp" #include "ledger/db/ledger_entity.hpp" namespace ledger { @@ -191,12 +192,22 @@ BudgetId BudgetModel::execute(const SetBudgetLimit& action) { if (budgetRows.empty()) { throw NotFound{"SetBudgetLimit: no such budget"}; } + // Onto the limit currency's own scale before it is stored, the same way + // LedgerModel restates a transaction leg onto its account currency's -- + // a limit is compared against a sum of legs, and two values only compare + // as money when they sit on one scale. Rejected rather than rounded when + // the amount carries a digit the currency does not have. + const auto limit = restateMinorUnits(action.limit, currencyDecimalPlaces(action.currency)); + if (!limit.has_value()) { + throw ValidationError{std::string{"SetBudgetLimit: limit is not a whole number of "} + + std::string{currencyToCode(action.currency)} + " minor units"}; + } db::BudgetLimitRecord limitRow; limitRow.budget = budgetRows.front(); limitRow.month = action.month; - limitRow.limitNum = action.limit.numerator; - limitRow.limitDen = action.limit.denominator; - limitRow.limitDp = static_cast(action.limit.decimalPlaces.value); + limitRow.limitNum = limit->numerator; + limitRow.limitDen = limit->denominator; + limitRow.limitDp = static_cast(limit->decimalPlaces.value); limitRow.currencyCode = currencyToCode(action.currency); // Task 7's helper mapper.Create(limitRow); logAction(action, action.budgetId); @@ -249,7 +260,15 @@ GetBudgetReportResult BudgetModel::execute(const GetBudgetReport& action) { accountIds.push_back(accountRow.id.Value()); } - morph::math::Rational spent{morph::math::Numerator{0}, morph::math::Denominator{1}, morph::math::DecimalPlaces{2}}; + // Seeded at the report currency's own scale rather than a hardcoded 2, so + // a zero-decimal budget (JPY, KRW) does not report its total tagged as if + // it had cents. `Rational::operator+` propagates `std::max` of the two + // precisions, and every leg LedgerModel stores now sits on its account + // currency's scale, so the running total stays on this one. + const auto reportCurrency = + limitRows.empty() ? Currency::USD : codeToCurrency(limitRows.front().currencyCode.Value().ToStringView()); + morph::math::Rational spent = + morph::math::Rational::zero(morph::math::DecimalPlaces{currencyDecimalPlaces(reportCurrency)}); if (!accountIds.empty()) { const auto [monthStartMs, monthEndMs] = monthRangeMs(action.month); auto journalRows = @@ -279,16 +298,16 @@ GetBudgetReportResult BudgetModel::execute(const GetBudgetReport& action) { } } - Currency currency = Currency::USD; + // No limit row for this month means no limit was ever set, which reports + // as a limit equal to what was spent rather than as an error. morph::math::Rational limit = spent; if (!limitRows.empty()) { limit = morph::math::Rational{ morph::math::Numerator{limitRows.front().limitNum.Value()}, morph::math::Denominator{limitRows.front().limitDen.Value()}, morph::math::DecimalPlaces{static_cast(limitRows.front().limitDp.Value())}}; - currency = codeToCurrency(limitRows.front().currencyCode.Value().ToStringView()); } - return GetBudgetReportResult{.limit = limit, .spent = spent, .currency = currency}; + return GetBudgetReportResult{.limit = limit, .spent = spent, .currency = reportCurrency}; } } // namespace ledger diff --git a/examples/ledger/src/models/ledger_model.cpp b/examples/ledger/src/models/ledger_model.cpp index b2828891..ecb9c4a3 100644 --- a/examples/ledger/src/models/ledger_model.cpp +++ b/examples/ledger/src/models/ledger_model.cpp @@ -23,6 +23,7 @@ #include "clock.hpp" #include "ledger/core/errors.hpp" +#include "ledger/core/money.hpp" #include "ledger/core/time_util.hpp" #include "ledger/core/units.hpp" #include "ledger/db/ledger_entity.hpp" @@ -143,6 +144,52 @@ namespace { return result; } +/// @brief Restates every leg's amount onto its own account currency's scale, +/// so the zero-sum partitioning that follows compares like with like. +/// +/// A leg amount is a whole number of minor units at whatever +/// `decimalPlaces` the client chose (`ledger/core/money.hpp` documents +/// the encoding and why it is not `Rational`'s own reading of the same +/// triple). Nothing on the wire constrains that scale to the account's, +/// and `Rational::operator+` cannot notice the difference: it adds +/// numerators and propagates `std::max` of the two precisions. So +/// `{450, dp 2}` ($4.50) and `{-450, dp 1}` (-$45.00) sum to a +/// numerator of zero and pass a check they should fail, while +/// `{45, dp 1}` ($4.50) and `{-450, dp 2}` (-$4.50) sum to -405 and +/// fail one they should pass. Restating first removes both. +/// +/// It also keeps every stored leg of an account on that account's own +/// scale, which the read side depends on: `buildLedgerState` seeds each +/// balance at the currency's precision and accumulates with the same +/// `std::max` propagation, so a single leg written at a wider scale +/// would pull that account's rendered balance off by a power of ten +/// for as long as the row exists. +/// @param legs The legs to restate, positionally aligned with @p legAccounts. +/// @param legAccounts Each leg's own account row, in the same order. +/// @param actionName The action name to prefix a rejection message with. +/// @return One restated amount per leg, in leg order. +/// @throws ValidationError When a leg's amount is not a whole number of its +/// account currency's minor units -- either more precision than the +/// currency has (`$4.505` in a USD account) or a non-integral +/// minor-unit count off the wire (`{"num":9,"den":2}`). Rejected, not +/// rounded: the model never rounds money (design spec §1). +[[nodiscard]] std::vector restateLegAmounts(const std::vector& legs, + const std::vector& legAccounts, + std::string_view actionName) { + std::vector restated; + restated.reserve(legs.size()); + for (std::size_t i = 0; i < legs.size(); ++i) { + const std::string code{legAccounts[i].currencyCode.Value().ToStringView()}; + auto amount = restateMinorUnits(legs[i].amount, currencyDecimalPlaces(codeToCurrency(code))); + if (!amount.has_value()) { + throw ValidationError{std::string{actionName} + ": leg amount is not a whole number of " + code + + " minor units"}; + } + restated.push_back(*amount); + } + return restated; +} + /// @brief Splits @p text on every occurrence of @p delimiter, keeping empty /// fields (so `"a,,b"` yields `{"a", "", "b"}`, and a trailing /// delimiter yields a trailing empty field) -- the plain building @@ -571,7 +618,6 @@ GetLedgerResult LedgerModel::execute(const StoreTransaction& action) { // Partition legs by the account's OWN currency, never a client-supplied // field (design spec §1) -- look up every referenced account first. - std::map sumsByCurrency; std::vector legAccounts; legAccounts.reserve(action.legs.size()); for (const auto& leg : action.legs) { @@ -582,12 +628,22 @@ GetLedgerResult LedgerModel::execute(const StoreTransaction& action) { throw NotFound{"StoreTransaction: no such account"}; } legAccounts.push_back(rows.front()); - const std::string currency{legAccounts.back().currencyCode.Value().ToStringView()}; + } + + // Every leg onto its own account currency's scale before anything sums + // them, and the restated amounts -- not the client's -- are what get + // stored below. See restateLegAmounts for why the invariant is unsound in + // both directions without this. + const auto legAmounts = restateLegAmounts(action.legs, legAccounts, "StoreTransaction"); + + std::map sumsByCurrency; + for (std::size_t i = 0; i < action.legs.size(); ++i) { + const std::string currency{legAccounts[i].currencyCode.Value().ToStringView()}; auto it = sumsByCurrency.find(currency); if (it == sumsByCurrency.end()) { - sumsByCurrency.emplace(currency, leg.amount); + sumsByCurrency.emplace(currency, legAmounts[i]); } else { - it->second = it->second + leg.amount; + it->second = it->second + legAmounts[i]; } } for (const auto& [currency, sum] : sumsByCurrency) { @@ -629,9 +685,11 @@ GetLedgerResult LedgerModel::execute(const StoreTransaction& action) { db::TransactionLegRecord legRow; legRow.journal = journalRow; legRow.account = legAccounts[i]; - legRow.amountNum = action.legs[i].amount.numerator; - legRow.amountDen = action.legs[i].amount.denominator; - legRow.amountDp = static_cast(action.legs[i].amount.decimalPlaces.value); + // The restated amount, never the client's: the stored scale is always + // the account currency's own. + legRow.amountNum = legAmounts[i].numerator; + legRow.amountDen = legAmounts[i].denominator; + legRow.amountDp = static_cast(legAmounts[i].decimalPlaces.value); legRow.currencyCode = legAccounts[i].currencyCode.Value(); // Foreign-amount triple: display/audit metadata only, never read by // the zero-sum partitioning loop above (design spec §1 step 3). @@ -1224,6 +1282,11 @@ GetLedgerResult LedgerModel::storeJournalImpl(Lightweight::DataMapper& mapper, c const std::vector& legs, const std::vector& legAccounts, std::optional causalParentId) { + // Before the transaction opens, so a rejected leg never leaves one behind: + // the same restatement `execute(StoreTransaction)` performs, applied here + // too because this path has its own callers (undo, CSV import) that reach + // the leg columns without going through that method. + const auto legAmounts = restateLegAmounts(legs, legAccounts, "storeJournalImpl"); Lightweight::SqlTransaction sqlTxn{mapper.Connection(), Lightweight::SqlTransactionMode::ROLLBACK}; db::TransactionJournalRecord journalRow; journalRow.description = description; @@ -1242,9 +1305,9 @@ GetLedgerResult LedgerModel::storeJournalImpl(Lightweight::DataMapper& mapper, c db::TransactionLegRecord legRow; legRow.journal = journalRow; legRow.account = legAccounts[i]; - legRow.amountNum = legs[i].amount.numerator; - legRow.amountDen = legs[i].amount.denominator; - legRow.amountDp = static_cast(legs[i].amount.decimalPlaces.value); + legRow.amountNum = legAmounts[i].numerator; + legRow.amountDen = legAmounts[i].denominator; + legRow.amountDp = static_cast(legAmounts[i].decimalPlaces.value); legRow.currencyCode = legAccounts[i].currencyCode.Value(); const auto& foreignAmount = legs[i].foreignAmount; legRow.foreignAmountNum = foreignAmount ? std::optional{foreignAmount->numerator} : std::nullopt; diff --git a/examples/ledger/tests/test_budget_model.cpp b/examples/ledger/tests/test_budget_model.cpp index 6a30969a..4e56406e 100644 --- a/examples/ledger/tests/test_budget_model.cpp +++ b/examples/ledger/tests/test_budget_model.cpp @@ -198,3 +198,73 @@ TEST_CASE("CreateCategory records a LogEntry once a log is attached, and is a no CHECK(entries[0].outcome == morph::journal::Outcome::Succeeded); CHECK(entries[0].entityKey == std::to_string(*ledgerId)); } + +TEST_CASE("SetBudgetLimit restates a limit onto its currency's scale, and refuses what it cannot", + "[ledger][budget]") { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Personal"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + const ScopedPrincipal principal{"alice"}; + ledger::BudgetModel budgetModel; + auto categoryId = budgetModel.execute(ledger::CreateCategory{.ledgerId = ledgerId, .name = "Food"}); + auto budgetId = budgetModel.execute( + ledger::CreateBudget{.ledgerId = ledgerId, .name = "Monthly groceries", .categoryId = categoryId}); + + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + + // $200 written at dp 0 is 200 dollars, which is 20000 cents once it is on + // USD's own scale. A limit is compared against a sum of legs, and the + // legs are all on the account currency's scale, so the limit must be too. + budgetModel.execute( + ledger::SetBudgetLimit{.budgetId = budgetId, + .month = "2026-01", + .limit = morph::math::Rational{Numerator{200}, Denominator{1}, DecimalPlaces{0}}, + .currency = ledger::Currency::USD}); + auto report = budgetModel.execute(ledger::GetBudgetReport{.budgetId = budgetId, .month = "2026-01"}); + CHECK(report.limit.numerator == 20000); + CHECK(report.limit.decimalPlaces == DecimalPlaces{2}); + + // Sub-cent precision is rejected, not rounded. + CHECK_THROWS_AS(budgetModel.execute(ledger::SetBudgetLimit{ + .budgetId = budgetId, + .month = "2026-02", + .limit = morph::math::Rational{Numerator{20001}, Denominator{1}, DecimalPlaces{3}}, + .currency = ledger::Currency::USD}), + ledger::ValidationError); +} + +TEST_CASE("GetBudgetReport reports a zero-decimal currency's total on its own scale", "[ledger][budget]") { + // Without this, `spent` was seeded at a hardcoded dp 2 and a JPY budget + // reported its total tagged as if yen had cents. + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Tokyo trip"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + const ScopedPrincipal principal{"alice"}; + ledger::BudgetModel budgetModel; + auto categoryId = budgetModel.execute(ledger::CreateCategory{.ledgerId = ledgerId, .name = "Food"}); + auto budgetId = + budgetModel.execute(ledger::CreateBudget{.ledgerId = ledgerId, .name = "Ramen", .categoryId = categoryId}); + budgetModel.execute(ledger::SetBudgetLimit{ + .budgetId = budgetId, + .month = "2026-01", + .limit = morph::math::Rational{morph::math::Numerator{30000}, morph::math::Denominator{1}, + morph::math::DecimalPlaces{0}}, + .currency = ledger::Currency::JPY}); + + auto report = budgetModel.execute(ledger::GetBudgetReport{.budgetId = budgetId, .month = "2026-01"}); + CHECK(report.currency == ledger::Currency::JPY); + CHECK(report.limit.numerator == 30000); + CHECK(report.limit.decimalPlaces == morph::math::DecimalPlaces{0}); + CHECK(report.spent.numerator == 0); + CHECK(report.spent.decimalPlaces == morph::math::DecimalPlaces{0}); +} diff --git a/examples/ledger/tests/test_ledger_import.cpp b/examples/ledger/tests/test_ledger_import.cpp index 512ef311..75e33286 100644 --- a/examples/ledger/tests/test_ledger_import.cpp +++ b/examples/ledger/tests/test_ledger_import.cpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include #include "ledger/core/errors.hpp" #include "ledger/db/ledger_entity.hpp" @@ -190,3 +192,67 @@ TEST_CASE("ImportLedgerChunk rejects a malformed CSV row", "[ledger][import]") { .opId = ledger::ImportOpId::fromOptional(std::optional{"chunk-bad"})}), ledger::ValidationError); } + +TEST_CASE("ImportLedgerChunk lands every spelling of an amount on the account currency's scale", "[ledger][import]") { + // A CSV amount's scale is however many digits the file happened to write + // after the point: "-4.5" parses at dp 1 and "-4.50" at dp 2, and they are + // the same money. Both must land on USD's own scale of 2, or the two rows + // add up as if one of them were ten times the other -- `Rational::operator+` + // adds numerators and cannot see the scales. + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Personal"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + ledger::LedgerModel model; + const ScopedPrincipal principal{"alice"}; + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Checking", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::USD}); + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Suspense", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::USD}); + auto ledgerState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + const auto checkingId = ledgerState.accounts[0].id; + const auto suspenseId = ledgerState.accounts[1].id; + + const auto account = std::to_string(*checkingId); + const std::string csv = + "date,description,account_id,amount\n" + "2026-01-01T00:00:00Z,Coffee," + + account + + ",-4.5\n" + "2026-01-02T00:00:00Z,Tea," + + account + ",-4.50\n"; + + auto result = model.execute(ledger::ImportLedgerChunk{ + .ledgerId = ledgerId, + .counterAccountId = suspenseId, + .csvChunk = csv, + .opId = ledger::ImportOpId::fromOptional(std::optional{"chunk-scales"})}); + CHECK(result.imported == 2); + + auto finalState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + auto checking = std::ranges::find_if(finalState.accounts, [&](const auto& a) { return a.id == checkingId; }); + REQUIRE(checking != finalState.accounts.end()); + // -$4.50 twice is -$9.00, i.e. -900 cents at dp 2 -- not -495 at dp 2, + // which is what summing 45-at-dp-1 with 450-at-dp-2 produces. + CHECK(checking->balance.numerator == -900); + CHECK(checking->balance.decimalPlaces == morph::math::DecimalPlaces{2}); + + // Every stored leg carries USD's scale, whatever the file wrote. + auto legRows = mapper.Query() + .Where(::Lightweight::FieldNameOf<&ledger::db::TransactionLegRecord::account>, "=", + static_cast(*checkingId)) + .All(); + REQUIRE(legRows.size() == 2); + for (const auto& legRow : legRows) { + CHECK(legRow.amountDp.Value() == 2); + CHECK(legRow.amountDen.Value() == 1); + CHECK(legRow.amountNum.Value() == -450); + } +} diff --git a/examples/ledger/tests/test_ledger_model.cpp b/examples/ledger/tests/test_ledger_model.cpp index 48d0dfd9..c8c2e169 100644 --- a/examples/ledger/tests/test_ledger_model.cpp +++ b/examples/ledger/tests/test_ledger_model.cpp @@ -8,6 +8,7 @@ #include #include "ledger/core/errors.hpp" +#include "ledger/core/money.hpp" #include "ledger/db/ledger_entity.hpp" #include "ledger/models/budget_model.hpp" #include "ledger/models/ledger_model.hpp" @@ -598,3 +599,246 @@ TEST_CASE("UndoTransaction produces an exact negation that re-passes zero-sum an } } } + +// ───────────────────────────────────────────────────────────────────────── +// Per-currency scale: a leg's `decimalPlaces` is the scale its numerator is +// expressed in, so two legs at different scales are not comparable until +// both are restated at the account currency's own scale. These two cases +// are the ones morph#304 §A1 predicted; both are checked through the real +// `StoreTransaction` path, not against the partitioning helper directly. +// ───────────────────────────────────────────────────────────────────────── + +TEST_CASE("StoreTransaction rejects legs that balance only because their scales differ", "[ledger][model]") { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Personal"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + ledger::LedgerModel model; + const ScopedPrincipal principal{"alice"}; + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Checking", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::USD}); + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Groceries", + .kind = ledger::AccountKind::Expense, + .currency = ledger::Currency::USD}); + auto ledgerState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + // "4.50" is 450 at a scale of 2; "-45.0" is -450 at a scale of 1. They + // are $4.50 and -$45.00 -- forty dollars fifty apart -- and the pair + // must be refused. Summing the two numerators without restating either + // at USD's own scale reads them as 450 and -450 and calls it balanced. + CHECK_THROWS_AS( + model.execute(ledger::StoreTransaction{ + .ledgerId = ledgerId, + .description = "Scale-mismatched pair", + .date = morph::time::Timestamp::now(), + .legs = {ledger::TransactionLeg{ + .accountId = ledgerState.accounts[0].id, + .amount = morph::math::Rational{Numerator{450}, Denominator{1}, DecimalPlaces{2}}}, + ledger::TransactionLeg{ + .accountId = ledgerState.accounts[1].id, + .amount = morph::math::Rational{Numerator{-450}, Denominator{1}, DecimalPlaces{1}}}}}), + ledger::ZeroSumViolation); +} + +TEST_CASE("StoreTransaction accepts a balanced pair written at different scales", "[ledger][model]") { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Personal"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + ledger::LedgerModel model; + const ScopedPrincipal principal{"alice"}; + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Checking", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::USD}); + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Groceries", + .kind = ledger::AccountKind::Expense, + .currency = ledger::Currency::USD}); + auto ledgerState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + const auto checkingId = ledgerState.accounts[0].id; + const auto groceriesId = ledgerState.accounts[1].id; + + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + // "4.5" is 45 at a scale of 1; "-4.50" is -450 at a scale of 2. Both are + // $4.50, so the pair balances and must be accepted -- and both legs must + // land on USD's own scale of 2, whatever scale they arrived on. + auto result = model.execute(ledger::StoreTransaction{ + .ledgerId = ledgerId, + .description = "Balanced pair at mixed scales", + .date = morph::time::Timestamp::now(), + .legs = { + ledger::TransactionLeg{.accountId = checkingId, + .amount = morph::math::Rational{Numerator{45}, Denominator{1}, DecimalPlaces{1}}}, + ledger::TransactionLeg{ + .accountId = groceriesId, + .amount = morph::math::Rational{Numerator{-450}, Denominator{1}, DecimalPlaces{2}}}}}); + + REQUIRE(result.accounts.size() == 2); + auto checking = std::ranges::find_if(result.accounts, [&](const auto& a) { return a.id == checkingId; }); + auto groceries = std::ranges::find_if(result.accounts, [&](const auto& a) { return a.id == groceriesId; }); + REQUIRE(checking != result.accounts.end()); + REQUIRE(groceries != result.accounts.end()); + CHECK(checking->balance.numerator == 450); + CHECK(checking->balance.decimalPlaces == DecimalPlaces{2}); + CHECK(groceries->balance.numerator == -450); + CHECK(groceries->balance.decimalPlaces == DecimalPlaces{2}); +} + +TEST_CASE("StoreTransaction rejects a leg carrying more precision than its currency has", "[ledger][model]") { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Personal"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + ledger::LedgerModel model; + const ScopedPrincipal principal{"alice"}; + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Checking", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::USD}); + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Groceries", + .kind = ledger::AccountKind::Expense, + .currency = ledger::Currency::USD}); + auto ledgerState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + // $4.505 in a USD account. Restating dp 3 onto USD's dp 2 would have to + // drop a non-zero digit, and the model never rounds money -- so the pair + // is rejected outright even though it is perfectly self-balancing. + CHECK_THROWS_AS( + model.execute(ledger::StoreTransaction{ + .ledgerId = ledgerId, + .description = "Sub-cent leg", + .date = morph::time::Timestamp::now(), + .legs = {ledger::TransactionLeg{ + .accountId = ledgerState.accounts[0].id, + .amount = morph::math::Rational{Numerator{-4505}, Denominator{1}, DecimalPlaces{3}}}, + ledger::TransactionLeg{ + .accountId = ledgerState.accounts[1].id, + .amount = morph::math::Rational{Numerator{4505}, Denominator{1}, DecimalPlaces{3}}}}}), + ledger::ValidationError); + + // A wider scale is fine when it carries no digit below a cent: $4.50 + // written at dp 4 restates to {450, dp 2}. + auto result = model.execute(ledger::StoreTransaction{ + .ledgerId = ledgerId, + .description = "Wide but exact", + .date = morph::time::Timestamp::now(), + .legs = {ledger::TransactionLeg{ + .accountId = ledgerState.accounts[0].id, + .amount = morph::math::Rational{Numerator{-45000}, Denominator{1}, DecimalPlaces{4}}}, + ledger::TransactionLeg{ + .accountId = ledgerState.accounts[1].id, + .amount = morph::math::Rational{Numerator{45000}, Denominator{1}, DecimalPlaces{4}}}}}); + REQUIRE(result.accounts.size() == 2); + CHECK(result.accounts[0].balance.numerator == -450); + CHECK(result.accounts[0].balance.decimalPlaces == DecimalPlaces{2}); +} + +TEST_CASE("StoreTransaction rejects a leg that is not a whole number of minor units", "[ledger][model]") { + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Personal"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + ledger::LedgerModel model; + const ScopedPrincipal principal{"alice"}; + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Checking", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::USD}); + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Groceries", + .kind = ledger::AccountKind::Expense, + .currency = ledger::Currency::USD}); + auto ledgerState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + // `{"num":9,"den":2}` off the wire: nine halves of a cent is not a + // quantity of money this rung can store, and `Rational`'s codec clamps + // rather than rejects, so the model is the only thing that can refuse it. + CHECK_THROWS_AS(model.execute(ledger::StoreTransaction{ + .ledgerId = ledgerId, + .description = "Fractional minor units", + .date = morph::time::Timestamp::now(), + .legs = {ledger::TransactionLeg{ + .accountId = ledgerState.accounts[0].id, + .amount = morph::math::Rational{Numerator{-9}, Denominator{2}, DecimalPlaces{2}}}, + ledger::TransactionLeg{.accountId = ledgerState.accounts[1].id, + .amount = morph::math::Rational{Numerator{9}, Denominator{2}, + DecimalPlaces{2}}}}}), + ledger::ValidationError); +} + +TEST_CASE("A JPY leg stores and renders as a true integer", "[ledger][model]") { + // The README's own named test for a zero-decimal currency. JPY declares + // dp 0, so a leg is a whole number of yen: a client that sends it at + // dp 2 (the majority default) has it restated onto JPY's scale, and the + // stored row and rendered text both come back as whole yen -- no + // `x-rules` gate, no app-side workaround. + morph::ladder::testkit::DbFixture fixture; + Lightweight::DataMapper mapper; + ledger::db::LedgerRecord ledgerRow; + ledgerRow.name = "Tokyo trip"; + mapper.Create(ledgerRow); + const auto ledgerId = ledger::LedgerId{static_cast(ledgerRow.id.Value())}; + + ledger::LedgerModel model; + const ScopedPrincipal principal{"alice"}; + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Yen wallet", + .kind = ledger::AccountKind::Asset, + .currency = ledger::Currency::JPY}); + model.execute(ledger::OpenAccount{.ledgerId = ledgerId, + .name = "Ramen", + .kind = ledger::AccountKind::Expense, + .currency = ledger::Currency::JPY}); + auto ledgerState = model.execute(ledger::GetLedger{.ledgerId = ledgerId}); + + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + auto result = model.execute(ledger::StoreTransaction{ + .ledgerId = ledgerId, + .description = "Ramen", + .date = morph::time::Timestamp::now(), + .legs = {ledger::TransactionLeg{ + .accountId = ledgerState.accounts[0].id, + .amount = morph::math::Rational{Numerator{-1500}, Denominator{1}, DecimalPlaces{0}}}, + // The same ¥1500, sent at the dp-2 default a generic client + // would use. + ledger::TransactionLeg{ + .accountId = ledgerState.accounts[1].id, + .amount = morph::math::Rational{Numerator{150000}, Denominator{1}, DecimalPlaces{2}}}}}); + + REQUIRE(result.accounts.size() == 2); + CHECK(result.accounts[0].balance.numerator == -1500); + CHECK(result.accounts[0].balance.decimalPlaces == DecimalPlaces{0}); + CHECK(result.accounts[1].balance.numerator == 1500); + CHECK(result.accounts[1].balance.decimalPlaces == DecimalPlaces{0}); + CHECK(ledger::formatMoney(ledger::Currency::JPY, result.accounts[1].balance) == "1500"); +} diff --git a/examples/ledger/tests/test_ledger_units.cpp b/examples/ledger/tests/test_ledger_units.cpp index 3a7bba1e..73c123d3 100644 --- a/examples/ledger/tests/test_ledger_units.cpp +++ b/examples/ledger/tests/test_ledger_units.cpp @@ -3,6 +3,7 @@ #include #include +#include "ledger/core/money.hpp" #include "ledger/core/units.hpp" TEST_CASE("USD default decimals is 2", "[ledger][units]") { @@ -27,3 +28,98 @@ TEST_CASE("A JPY-denominated Quantity round-trips as a whole number", "[ledger][ REQUIRE(amount.payload.has_value()); CHECK(amount.payload->decimalPlaces == morph::math::DecimalPlaces{0}); } + +TEST_CASE("currencyDecimalPlaces names each currency's own scale", "[ledger][units][money]") { + CHECK(ledger::currencyDecimalPlaces(ledger::Currency::USD) == 2); + CHECK(ledger::currencyDecimalPlaces(ledger::Currency::EUR) == 2); + CHECK(ledger::currencyDecimalPlaces(ledger::Currency::JPY) == 0); + CHECK(ledger::currencyDecimalPlaces(ledger::Currency::KRW) == 0); +} + +TEST_CASE("restateMinorUnits widens, narrows exactly, and refuses the rest", "[ledger][units][money]") { + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + using morph::math::Rational; + + const auto at = [](std::int64_t numerator, std::uint32_t places) { + return Rational{Numerator{numerator}, Denominator{1}, DecimalPlaces{places}}; + }; + + // Same scale in, same value out -- the common case, and the one every + // stored row already satisfies. + auto identity = ledger::restateMinorUnits(at(450, 2), 2); + REQUIRE(identity.has_value()); + CHECK(identity->numerator == 450); + CHECK(identity->decimalPlaces == DecimalPlaces{2}); + + // Widening: "$4.5" at dp 1 is 450 cents. + auto widened = ledger::restateMinorUnits(at(45, 1), 2); + REQUIRE(widened.has_value()); + CHECK(widened->numerator == 450); + CHECK(widened->denominator == 1); + CHECK(widened->decimalPlaces == DecimalPlaces{2}); + + // Sign survives widening. + auto negative = ledger::restateMinorUnits(at(-45, 1), 2); + REQUIRE(negative.has_value()); + CHECK(negative->numerator == -450); + + // Narrowing when nothing is lost: $4.50 written at dp 4 is 450 cents. + auto narrowed = ledger::restateMinorUnits(at(45000, 4), 2); + REQUIRE(narrowed.has_value()); + CHECK(narrowed->numerator == 450); + CHECK(narrowed->decimalPlaces == DecimalPlaces{2}); + + // Narrowing to a zero-decimal currency. + auto toWholeUnits = ledger::restateMinorUnits(at(150000, 2), 0); + REQUIRE(toWholeUnits.has_value()); + CHECK(toWholeUnits->numerator == 1500); + CHECK(toWholeUnits->decimalPlaces == DecimalPlaces{0}); + + // Zero restates to zero at the target scale, not to a stray precision. + auto zero = ledger::restateMinorUnits(at(0, 0), 2); + REQUIRE(zero.has_value()); + CHECK(zero->numerator == 0); + CHECK(zero->decimalPlaces == DecimalPlaces{2}); + + // Refused: narrowing would drop a non-zero digit ($4.505 in USD). + CHECK_FALSE(ledger::restateMinorUnits(at(4505, 3), 2).has_value()); + + // Refused: not a whole number of minor units at all. + CHECK_FALSE(ledger::restateMinorUnits(Rational{Numerator{9}, Denominator{2}, DecimalPlaces{2}}, 2).has_value()); + + // Refused: widening this far overflows int64 rather than saturating + // silently -- `checkedMul`, not `operator*`. + CHECK_FALSE(ledger::restateMinorUnits(at(9'000'000'000'000'000'000LL, 0), 2).has_value()); +} + +TEST_CASE("formatMoney renders exact decimal text without a float", "[ledger][units][money]") { + using morph::math::DecimalPlaces; + using morph::math::Denominator; + using morph::math::Numerator; + using morph::math::Rational; + + const auto at = [](std::int64_t numerator, std::uint32_t places) { + return Rational{Numerator{numerator}, Denominator{1}, DecimalPlaces{places}}; + }; + + CHECK(ledger::formatMoney(ledger::Currency::USD, at(450, 2)) == "4.5"); + CHECK(ledger::formatMoney(ledger::Currency::USD, at(-4500, 2)) == "-45"); + CHECK(ledger::formatMoney(ledger::Currency::USD, at(451, 2)) == "4.51"); + CHECK(ledger::formatMoney(ledger::Currency::USD, at(0, 2)) == "0"); + CHECK(ledger::formatMoney(ledger::Currency::EUR, at(4523, 2)) == "45.23"); + + // Zero-decimal currencies render as whole numbers, with no invented + // fractional part. + CHECK(ledger::formatMoney(ledger::Currency::JPY, at(1500, 0)) == "1500"); + CHECK(ledger::formatMoney(ledger::Currency::KRW, at(-1500, 0)) == "-1500"); + + // An amount off the currency's own scale is restated first, so the two + // spellings of ¥1500 render identically. + CHECK(ledger::formatMoney(ledger::Currency::JPY, at(150000, 2)) == "1500"); + + // Past 2^53, where the double division the QML views used to do drifts + // and this does not: 9007199254740993 cents is 90071992547409.93. + CHECK(ledger::formatMoney(ledger::Currency::USD, at(9007199254740993LL, 2)) == "90071992547409.93"); +} diff --git a/tests/test_ledger_rational_fuzz.cpp b/tests/test_ledger_rational_fuzz.cpp index 33f803b1..164c7f74 100644 --- a/tests/test_ledger_rational_fuzz.cpp +++ b/tests/test_ledger_rational_fuzz.cpp @@ -5,23 +5,35 @@ #include #include -TEST_CASE("Zero-sum check never false-positives across differing decimalPlaces in one currency", - "[ledger][rational][fuzz]") { +TEST_CASE("Rational addition is exact, and blind to decimalPlaces", "[ledger][rational][fuzz]") { using morph::math::DecimalPlaces; using morph::math::Denominator; using morph::math::Numerator; using morph::math::Rational; - // A USD leg at dp=2 and a correcting USD leg at dp=4 in the same - // journal, constructed to sum to true zero once both are reduced to a - // common scale. Assert Rational::operator+ over the two produces - // canonical zero (num=0, den=1) -- not a "close to zero" approximation. + // This case used to be titled "zero-sum check never false-positives + // across differing decimalPlaces in one currency" and claimed the two + // operands below were "constructed to sum to true zero once both are + // reduced to a common scale". They are not reduced to a common scale, and + // nothing here reaches the zero-sum check: `Rational::operator+` adds + // numerators and propagates `std::max` of the two precisions, so under + // ledger's minor-unit encoding these two are -$50.00 and +$0.50 and they + // "cancel" only because the addition cannot see the scales. + // + // What the case actually measures -- and now says -- is that `Rational` + // arithmetic is exact and scale-blind. That is a property of the value + // type, not a guarantee about the ledger's invariant. The invariant is + // made sound by `LedgerModel` restating every leg onto its account + // currency's scale *before* summing, and the false-accept and + // false-reject cases are pinned where the model can actually be driven, + // in `examples/ledger/tests/test_ledger_model.cpp`. Rational a{Numerator{-5000}, Denominator{1}, DecimalPlaces{2}}; Rational b{Numerator{5000}, Denominator{1}, DecimalPlaces{4}}; auto sum = a + b; - // -5000 + 5000 = 0 demonstrates exact arithmetic across decimal places CHECK(sum.numerator == 0); CHECK(sum.denominator == 1); + // The wider precision wins, and neither operand's value moved. + CHECK(sum.decimalPlaces == DecimalPlaces{4}); } TEST_CASE("Measure the row count at which partial-sum overflow occurs at ledger-realistic magnitudes", From 2c93bcab4250f7a630279f4c442d3e97c1812dd3 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 26 Aug 2026 17:55:52 +0300 Subject: [PATCH 2/4] =?UTF-8?q?ledger:=20render=20money=20in=20the=20bridg?= =?UTF-8?q?es,=20not=20with=20IEEE=20division=20in=20QML=20(#304=20=C2=A7A?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three QML views computed their own money text from the exact triple the bridges publish -- `(numerator / denominator) / Math.pow(10, places)` in `LedgerView`, `BudgetView` and `ReportView`. QML has only IEEE doubles, so those three lines undid `Rational`'s exactness at the very end of a path built to preserve it, and drifted for balances past 2^53 while the payload beneath them stayed exact. `examples/ledger/README.md` forbids exactly this, and `ledger_qml_bridge.cpp`'s own doc comment had promised a `balanceText` since the bridge was written; `grep balanceText` matched that comment and nothing else. The bridges now emit it: `balanceText` on each account, `limitText`/`spentText` on a budget report, `amountText` on each report line, all rendered by `ledger::formatMoney`, which recovers the decimal value from the minor-unit count, wraps it in `Money` for the currency -- the one place in this rung where the currency is a compile-time fact, and so the one place `morph::units` can type money at all -- and lets `morph::units::toDecimalString` produce the digits by exact integer long division. The exact triple is still published alongside, for a view that wants the parts. `toDecimalString` renders shortest-form, so $4.50 comes back as "4.5" and a zero balance as "0". `Quantity` has no fixed-fraction-width mode to ask for "4.50" instead; the rung renders shortest-form and records the gap as a finding in its README rather than growing a second, hand-rolled formatter next to the framework's. `BudgetQmlBridge::setBudgetLimit` also takes its scale from the currency the caller named instead of a hardcoded 2, so a JPY limit is counted in whole yen rather than being restated a hundredfold smaller by the model. Co-Authored-By: Claude Opus 5 (1M context) --- examples/ledger/gui/qml/BudgetView.qml | 15 ++++--- examples/ledger/gui/qml/LedgerView.qml | 23 ++++------- examples/ledger/gui/qml/ReportView.qml | 10 ++--- examples/ledger/gui_lib/budget_qml_bridge.cpp | 39 ++++++++++++------- examples/ledger/gui_lib/ledger_qml_bridge.cpp | 21 ++++++++-- examples/ledger/gui_lib/report_qml_bridge.cpp | 10 +++++ .../ledger/tests/test_budget_qml_bridge.cpp | 4 ++ .../ledger/tests/test_ledger_qml_bridge.cpp | 5 +++ 8 files changed, 84 insertions(+), 43 deletions(-) diff --git a/examples/ledger/gui/qml/BudgetView.qml b/examples/ledger/gui/qml/BudgetView.qml index 2b6d42c7..a8669ab0 100644 --- a/examples/ledger/gui/qml/BudgetView.qml +++ b/examples/ledger/gui/qml/BudgetView.qml @@ -11,14 +11,19 @@ ColumnLayout { property var bridge: null spacing: 8 - /// Formats an exact triple from the report map under `prefix`. + /// Reads the text the bridge pre-rendered for `prefix`, or "-" before a + /// report has arrived. + /// + /// The rendering itself is `ledger::formatMoney`, in the bridge: QML has + /// only IEEE doubles, so dividing the exact + /// numerator/denominator/decimalPlaces here would undo `Rational`'s + /// exactness in the last three lines of the path (design spec §7's + /// no-float rule). The exact triple is still published alongside. function formatPart(report, prefix) { - if (!report || report[prefix + "Denominator"] === undefined) { + if (!report || report[prefix + "Text"] === undefined) { return "-"; } - const denominator = report[prefix + "Denominator"] === 0 ? 1 : report[prefix + "Denominator"]; - const places = report[prefix + "DecimalPlaces"]; - return ((report[prefix + "Numerator"] / denominator) / Math.pow(10, places)).toFixed(places); + return report[prefix + "Text"]; } Label { text: qsTr("Budgets"); font.bold: true } diff --git a/examples/ledger/gui/qml/LedgerView.qml b/examples/ledger/gui/qml/LedgerView.qml index f525dd13..a2316329 100644 --- a/examples/ledger/gui/qml/LedgerView.qml +++ b/examples/ledger/gui/qml/LedgerView.qml @@ -11,21 +11,12 @@ ColumnLayout { property var bridge: null spacing: 8 - /// Renders an exact Rational triple as text. - /// - /// The bridge publishes numerator/denominator/decimalPlaces rather than - /// one pre-divided number (design spec §7's no-float rule), so the - /// formatting decision lives here, in the view, where it belongs -- and - /// the model never produced a double that could round. - function formatAmount(entry) { - if (!entry) { - return ""; - } - const denominator = entry.balanceDenominator === 0 ? 1 : entry.balanceDenominator; - const places = entry.balanceDecimalPlaces; - const scaled = entry.balanceNumerator / denominator; - return (scaled / Math.pow(10, places)).toFixed(places); - } + // Balances are bound as `balanceText`, which the bridge pre-renders with + // `ledger::formatMoney` (design spec §7's no-float rule). This file does + // no arithmetic on money: QML has only IEEE doubles, so dividing the + // exact numerator/denominator/decimalPlaces here would undo `Rational`'s + // exactness in the last three lines of the path and drift past 2^53. The + // exact triple is still published for a view that needs the parts. Label { text: qsTr("Accounts") @@ -42,7 +33,7 @@ ColumnLayout { Label { text: modelData.name; Layout.fillWidth: true } Label { text: modelData.kind; opacity: 0.7 } Label { text: modelData.currency; opacity: 0.7 } - Label { text: view.formatAmount(modelData); font.family: "monospace" } + Label { text: modelData.balanceText; font.family: "monospace" } } } diff --git a/examples/ledger/gui/qml/ReportView.qml b/examples/ledger/gui/qml/ReportView.qml index c29f60e0..2500b50a 100644 --- a/examples/ledger/gui/qml/ReportView.qml +++ b/examples/ledger/gui/qml/ReportView.qml @@ -68,11 +68,11 @@ ColumnLayout { opacity: 0.8 } Label { - text: { - const denominator = modelData.denominator === 0 ? 1 : modelData.denominator; - const places = modelData.decimalPlaces; - return ((modelData.numerator / denominator) / Math.pow(10, places)).toFixed(places); - } + // Pre-rendered by the bridge with `ledger::formatMoney` + // (design spec §7's no-float rule): QML has only IEEE + // doubles, so dividing the exact triple here would undo + // `Rational`'s exactness in the last three lines of the path. + text: modelData.amountText font.family: "monospace" } } diff --git a/examples/ledger/gui_lib/budget_qml_bridge.cpp b/examples/ledger/gui_lib/budget_qml_bridge.cpp index 49d50c8f..5168026f 100644 --- a/examples/ledger/gui_lib/budget_qml_bridge.cpp +++ b/examples/ledger/gui_lib/budget_qml_bridge.cpp @@ -5,6 +5,7 @@ #include #include "gui/id_qml.hpp" +#include "ledger/core/money.hpp" #include "ledger/core/units.hpp" namespace ledger::gui { @@ -14,18 +15,25 @@ namespace { using ::morph::ladder::gui::idFromText; using ::morph::ladder::gui::idText; -/// @brief An exact `Rational` as the triple QML binds to, under @p prefix. +/// @brief An exact `Rational` as the triple QML binds to, under @p prefix, +/// plus the text the view actually displays. /// /// Never a single pre-divided number: design spec §7's no-float rule -/// holds at this boundary, so the view receives the exact parts and -/// formats them itself. -/// @param out The map to write into. -/// @param prefix The key prefix, e.g. `"limit"`. -/// @param value The exact value to publish. -void putRational(QVariantMap& out, const QString& prefix, const morph::math::Rational& value) { +/// holds at this boundary, so the exact parts cross it. The rendering +/// happens here rather than in the view because QML has only IEEE +/// doubles -- `numerator / denominator / Math.pow(10, places)` in a +/// `.qml` file undoes `Rational`'s exactness in the last three lines +/// of the path. `ledger::formatMoney` is exact integer long division +/// through `Money` and `morph::units::toDecimalString`. +/// @param out The map to write into. +/// @param prefix The key prefix, e.g. `"limit"`. +/// @param value The exact value to publish. +/// @param currency The currency @p value is denominated in, for the text. +void putRational(QVariantMap& out, const QString& prefix, const morph::math::Rational& value, Currency currency) { out.insert(prefix + "Numerator", static_cast(value.numerator)); out.insert(prefix + "Denominator", static_cast(value.denominator)); out.insert(prefix + "DecimalPlaces", static_cast(value.decimalPlaces.value)); + out.insert(prefix + "Text", QString::fromStdString(formatMoney(currency, value))); } } // namespace @@ -43,8 +51,8 @@ BudgetQmlBridge::BudgetQmlBridge(::morph::bridge::Bridge& bridge, ::morph::exec: connect(&_presenter, &BudgetPresenter::limitSet, this, [this](BudgetId) { emit limitSet(); }); connect(&_presenter, &BudgetPresenter::reportReady, this, [this](const GetBudgetReportResult& result) { QVariantMap report; - putRational(report, QStringLiteral("limit"), result.limit); - putRational(report, QStringLiteral("spent"), result.spent); + putRational(report, QStringLiteral("limit"), result.limit, result.currency); + putRational(report, QStringLiteral("spent"), result.spent, result.currency); const auto code = currencyToCode(result.currency); report.insert(QStringLiteral("currency"), QString::fromUtf8(code.data(), static_cast(code.size()))); _report = std::move(report); @@ -87,12 +95,15 @@ void BudgetQmlBridge::setBudgetLimit(const QString& budgetId, const QString& mon using morph::math::DecimalPlaces; using morph::math::Denominator; using morph::math::Numerator; - // Minor units in, exact Rational out -- cents over a denominator of 1 at - // two decimal places, so the limit a user typed is the limit stored. - constexpr std::uint32_t kMinorUnitPlaces = 2; + // Minor units in, exact Rational out -- a whole number of the chosen + // currency's own minor units, so the limit a user typed is the limit + // stored. The scale comes from the currency rather than a hardcoded 2: + // a JPY limit is counted in whole yen, and tagging it dp 2 would have + // `BudgetModel` restate it a hundredfold smaller. + const auto chosen = codeToCurrency(currency.toStdString()); const auto limit = morph::math::Rational{Numerator{static_cast(limitMinor)}, Denominator{1}, - DecimalPlaces{kMinorUnitPlaces}}; - _presenter.setBudgetLimit(idFromText(budgetId), month, limit, codeToCurrency(currency.toStdString())); + DecimalPlaces{currencyDecimalPlaces(chosen)}}; + _presenter.setBudgetLimit(idFromText(budgetId), month, limit, chosen); emit busyChanged(); } diff --git a/examples/ledger/gui_lib/ledger_qml_bridge.cpp b/examples/ledger/gui_lib/ledger_qml_bridge.cpp index 3f8b6f81..edc62d1b 100644 --- a/examples/ledger/gui_lib/ledger_qml_bridge.cpp +++ b/examples/ledger/gui_lib/ledger_qml_bridge.cpp @@ -6,6 +6,7 @@ #include #include "gui/id_qml.hpp" +#include "ledger/core/money.hpp" #include "ledger/core/units.hpp" namespace ledger::gui { @@ -62,19 +63,29 @@ using ::morph::ladder::gui::idNumber; /// no-float rule applies at the QML boundary exactly as it does on /// the wire, and a view that wants to format differently still has /// the exact numerator/denominator to do it from. +/// +/// `balanceText` is what `LedgerView` actually binds. It has to be +/// rendered here rather than in the view because QML has only IEEE +/// doubles: the `numerator / denominator / Math.pow(10, places)` the +/// view used to compute re-introduced, in the last three lines of the +/// path, exactly the imprecision `Rational` exists to remove, and +/// drifted past 2^53 while the payload beneath it stayed exact. +/// `ledger::formatMoney` is exact integer long division through +/// `Money` and `morph::units::toDecimalString`. /// @param account The account to render. /// @return The QML-ready map. [[nodiscard]] QVariantMap toVariantMap(const AccountInfo& account) { const auto& balance = account.balance; + const auto code = currencyToCode(account.currency); return QVariantMap{ {"id", idNumber(account.id)}, {"name", QString::fromStdString(account.name)}, {"kind", kindToText(account.kind)}, - {"currency", QString::fromUtf8(currencyToCode(account.currency).data(), - static_cast(currencyToCode(account.currency).size()))}, + {"currency", QString::fromUtf8(code.data(), static_cast(code.size()))}, {"balanceNumerator", static_cast(balance.numerator)}, {"balanceDenominator", static_cast(balance.denominator)}, {"balanceDecimalPlaces", static_cast(balance.decimalPlaces.value)}, + {"balanceText", QString::fromStdString(formatMoney(account.currency, balance))}, }; } @@ -139,7 +150,11 @@ void LedgerQmlBridge::storeTransaction(const QString& fromAccountId, const QStri const auto to = idFromText(toAccountId); // Minor units in, exact Rational out: cents are numerator over a // denominator of 1 at 2 decimal places, so nothing is ever rounded on the - // way through this boundary. + // way through this boundary. The scale is the gesture's own, not the + // accounts' -- this view only knows account ids -- and `LedgerModel` + // restates each leg onto its account currency's scale on arrival, so a + // transfer between two zero-decimal accounts entered here as cents lands + // as the whole units it divides into. constexpr std::uint32_t kMinorUnitPlaces = 2; const auto debit = morph::math::Rational{Numerator{-static_cast(amountMinor)}, Denominator{1}, DecimalPlaces{kMinorUnitPlaces}}; diff --git a/examples/ledger/gui_lib/report_qml_bridge.cpp b/examples/ledger/gui_lib/report_qml_bridge.cpp index cdae5ee8..228cd4e0 100644 --- a/examples/ledger/gui_lib/report_qml_bridge.cpp +++ b/examples/ledger/gui_lib/report_qml_bridge.cpp @@ -7,6 +7,8 @@ #include #include "gui/id_qml.hpp" +#include "ledger/core/money.hpp" +#include "ledger/core/units.hpp" namespace ledger::gui { @@ -34,11 +36,19 @@ ReportQmlBridge::ReportQmlBridge(::morph::bridge::Bridge& bridge, ::morph::exec: QVariantList lines; lines.reserve(static_cast(decoded.size())); for (const auto& line : decoded) { + // `amountText` is what `ReportView` binds; the exact triple stays + // alongside it for any view that wants to format differently. The + // rendering cannot happen in QML, which has only IEEE doubles -- + // see `ledger::formatMoney`. + const auto total = morph::math::Rational{morph::math::Numerator{line.numerator}, + morph::math::Denominator{line.denominator}, + morph::math::DecimalPlaces{line.decimalPlaces}}; lines.push_back(QVariantMap{ {"currency", QString::fromStdString(line.currency)}, {"numerator", static_cast(line.numerator)}, {"denominator", static_cast(line.denominator)}, {"decimalPlaces", static_cast(line.decimalPlaces)}, + {"amountText", QString::fromStdString(formatMoney(codeToCurrency(line.currency), total))}, {"transactionCount", static_cast(line.transactionCount)}, }); } diff --git a/examples/ledger/tests/test_budget_qml_bridge.cpp b/examples/ledger/tests/test_budget_qml_bridge.cpp index c0a78597..da654704 100644 --- a/examples/ledger/tests/test_budget_qml_bridge.cpp +++ b/examples/ledger/tests/test_budget_qml_bridge.cpp @@ -80,6 +80,10 @@ TEST_CASE("BudgetQmlBridge publishes a report as exact triples, not a rounded nu CHECK(report.value("limitDecimalPlaces").toLongLong() == 2); CHECK(report.value("spentNumerator").toLongLong() == 0); CHECK(report.value("currency").toString() == "USD"); + // The text BudgetView binds, rendered by `ledger::formatMoney` here + // rather than divided out of the triple in QML. + CHECK(report.value("limitText").toString() == "300"); + CHECK(report.value("spentText").toString() == "0"); CHECK(bridge.lastError().isEmpty()); } diff --git a/examples/ledger/tests/test_ledger_qml_bridge.cpp b/examples/ledger/tests/test_ledger_qml_bridge.cpp index 7270053a..862c9526 100644 --- a/examples/ledger/tests/test_ledger_qml_bridge.cpp +++ b/examples/ledger/tests/test_ledger_qml_bridge.cpp @@ -109,8 +109,13 @@ TEST_CASE("LedgerQmlBridge carries balances exactly, never as a float", "[ledger CHECK(map.value("balanceDecimalPlaces").toLongLong() == 2); if (map.value("name").toString() == "Checking") { CHECK(numerator == -5000); + // `balanceText` is what LedgerView binds, and it is rendered by + // `ledger::formatMoney` on this side of the boundary -- QML has + // only IEEE doubles, so the view can no longer compute it. + CHECK(map.value("balanceText").toString() == "-50"); } else { CHECK(numerator == 5000); + CHECK(map.value("balanceText").toString() == "50"); } } CHECK(bridge.lastError().isEmpty()); From ca168ce1bc9aa96dbe7fd2a0eeb6a67190daeebd Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 26 Aug 2026 17:56:06 +0300 Subject: [PATCH 3/4] ledger: fail a report job whose ledger has vanished instead of settling it Done (#250) `RunReportJob` checked its own job row and threw `NotFound` for a job that does not exist, but never checked the ledger the job names. A job whose ledger had since been deleted therefore aggregated an empty account set, produced `[]`, and settled `Done` -- so a caller could not tell "no such ledger" from "a ledger with no activity", and the App's failure arm was unreachable that way. Every sibling action already has the guard: `OpenAccount`, `StoreTransaction`, `ImportLedgerChunk`, `SubmitReport` and `storeJournalImpl` all refuse a ledger they cannot find. The guard is raised *inside* the aggregation's own `try`, which is the part worth explaining. Throwing out of the method instead would leave the row `Pending`, and `App::runPendingReportsOnce` re-sweeps every `Pending` row on every pass -- the same doomed job would be re-dispatched forever, which is precisely the failure the existing characterisation test was written to guard against ("a row left Pending is what a poller spins on forever"). Raising it where the existing catch-all can see it settles the row `Failed`: terminal, and distinguishable from the empty-but-real report. The characterisation test moves with the behaviour it characterises, in this same commit, and now asserts `Failed` with no body. Co-Authored-By: Claude Opus 5 (1M context) --- examples/ledger/src/models/ledger_model.cpp | 20 ++++++++++++ examples/ledger/tests/test_app.cpp | 34 ++++++++++++--------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/examples/ledger/src/models/ledger_model.cpp b/examples/ledger/src/models/ledger_model.cpp index ecb9c4a3..a7a5728f 100644 --- a/examples/ledger/src/models/ledger_model.cpp +++ b/examples/ledger/src/models/ledger_model.cpp @@ -1162,6 +1162,26 @@ RunReportJobResult LedgerModel::execute(const RunReportJob& action) { } try { + // The ledger guard every sibling action already has -- OpenAccount, + // StoreTransaction, ImportLedgerChunk, SubmitReport and + // storeJournalImpl all refuse a ledger they cannot find, and this + // action checked only its own job row. Without it a job whose ledger + // has since been deleted aggregates an empty account set, produces + // `[]` and settles Done, so a caller cannot tell "no such ledger" + // from "a ledger with no activity" (morph#250). + // + // Raised *inside* this try on purpose. Throwing out of the method + // instead would leave the row Pending, and ledger::app::App re-sweeps + // every Pending row on every pass -- the same doomed job would be + // re-dispatched forever. The catch below settles it Failed, which is + // terminal, which is the property the row needs. + auto ledgerRows = mapper.Query() + .Where(::Lightweight::FieldNameOf<&db::LedgerRecord::id>, "=", *action.ledgerId) + .All(); + if (ledgerRows.empty()) { + throw NotFound{"RunReportJob: no such ledger"}; + } + std::string resultJson; { // Read-transaction snapshot pinning (IMPLEMENTATION.md rule 4's diff --git a/examples/ledger/tests/test_app.cpp b/examples/ledger/tests/test_app.cpp index d7b140b4..2183f9b6 100644 --- a/examples/ledger/tests/test_app.cpp +++ b/examples/ledger/tests/test_app.cpp @@ -238,14 +238,20 @@ TEST_CASE("The App runs a job for every ledger, not only the first", "[ledger][a CHECK(model.execute(ledger::GetReportStatus{.jobId = secondJob}).status == ledger::ReportStatus::Done); } -TEST_CASE("A job whose ledger no longer exists settles Done with an empty report", "[ledger][app]") { - // Characterisation, not aspiration: this pins what the runner *actually* - // does with a job whose ledger is gone, which is not what one would guess. - // The aggregation does not fail -- it finds no accounts, produces `[]`, - // and the job settles Done. So the App's failure arm is not reachable this - // way, and a caller cannot tell "no such ledger" from "a ledger with no - // activity". Filed as morph#250; asserted here so the behaviour cannot - // change silently while that is open. +TEST_CASE("A job whose ledger no longer exists settles Failed", "[ledger][app]") { + // morph#250, now closed. This test previously pinned the opposite: the + // aggregation found no accounts, produced `[]`, and the job settled Done, + // so a caller could not tell "no such ledger" from "a ledger with no + // activity" and the App's failure arm was unreachable this way. + // `RunReportJob` now checks its ledger row the way every sibling action + // does (OpenAccount, StoreTransaction, ImportLedgerChunk, SubmitReport, + // storeJournalImpl). + // + // The check is raised inside the aggregation's own try block, so the job + // still settles *terminally*, which is the property the original test was + // written to guard. Throwing out of the action instead would leave the row + // Pending, and `runPendingReportsOnce` re-sweeps every Pending row on + // every pass -- the same doomed job would be re-dispatched forever. // // The row is written directly rather than through `SubmitReport`, which // would refuse a ledger it cannot find -- the shape under test is a row @@ -272,11 +278,11 @@ TEST_CASE("A job whose ledger no longer exists settles Done with an empty report REQUIRE(pumpUntil([&app] { return !app.reportsInFlight(); })); } - // Terminal either way: a row left Pending is what a poller spins on - // forever, and that is the property worth pinning regardless of which - // terminal state is chosen later. + // Terminal, and distinguishable: `Failed` with no body, rather than + // `Done` with an empty one that a real ledger with no activity would also + // produce. A row left Pending is what a poller spins on forever, so + // terminality is the other half of the property. const auto status = model.execute(ledger::GetReportStatus{.jobId = orphanId}); - CHECK(status.status == ledger::ReportStatus::Done); - REQUIRE(status.result.has_value()); - CHECK(*status.result == "[]"); + CHECK(status.status == ledger::ReportStatus::Failed); + CHECK_FALSE(status.result.has_value()); } From c8c63b16131de67069a233452b8367ea759b54d2 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Wed, 26 Aug 2026 17:56:21 +0300 Subject: [PATCH 4/4] =?UTF-8?q?ledger:=20use=20the=20shared=20errorText=20?= =?UTF-8?q?helper=20in=20ReportJobPoller=20(#304=20=C2=A7A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ReportJobPoller`'s dispatch-error callback re-implemented `morph::ladder::gui::errorText` and called `std::rethrow_exception(err)` with no null check. A default-constructed `std::exception_ptr` is a legal thing for a backend to hand an error callback, and rethrowing one is undefined behaviour -- in a `Completion` error callback, which is exactly where an escaping failure takes the process down rather than reaching the user. `examples/common/gui/error_text.hpp` documents that case and `error_text.cpp` handles it, and this rung's four presenters (`report_presenter`, `ledger_presenter`, `budget_presenter`, `rule_presenter`) already route through it. The copy is deleted. One behavioural difference, deliberate: the fallback text for a non-`std` exception becomes the helper's `"unknown error"` rather than this poller's own `"report status poll failed"`. Nothing pinned the old string, and one wording across the rung is worth more than a per-site one. The regression test drives a null `exception_ptr` through the error path, which previously would have been undefined behaviour rather than a message. Co-Authored-By: Claude Opus 5 (1M context) --- examples/ledger/gui_lib/report_job_poller.cpp | 17 +++++------ .../ledger/tests/test_report_job_poller.cpp | 29 +++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/examples/ledger/gui_lib/report_job_poller.cpp b/examples/ledger/gui_lib/report_job_poller.cpp index b42fc204..6fc491d2 100644 --- a/examples/ledger/gui_lib/report_job_poller.cpp +++ b/examples/ledger/gui_lib/report_job_poller.cpp @@ -3,6 +3,8 @@ #include +#include "gui/error_text.hpp" + namespace ledger::gui { ReportJobPoller::ReportJobPoller(::morph::bridge::Bridge& bridge, ReportJobId jobId, Dispatch dispatch, OnDone onDone, @@ -66,16 +68,13 @@ void ReportJobPoller::pollOnce() { // Pending" bug hides. The presenter above decides whether to // resubmit. disarm(); - QString message = QStringLiteral("report status poll failed"); - try { - std::rethrow_exception(err); - } catch (const std::exception& ex) { - message = QString::fromStdString(ex.what()); - } catch (...) { - // keep the generic message - } + // The shared helper, not a fourth hand-written copy of it: this + // one called std::rethrow_exception with no null check, which is + // undefined behaviour on a null exception_ptr. `errorText` + // documents and handles that case, and the rung's four + // presenters already route through it. if (_onFailed) { - _onFailed(message); + _onFailed(::morph::ladder::gui::errorText(err)); } }); } diff --git a/examples/ledger/tests/test_report_job_poller.cpp b/examples/ledger/tests/test_report_job_poller.cpp index a59f9fc7..bc48b470 100644 --- a/examples/ledger/tests/test_report_job_poller.cpp +++ b/examples/ledger/tests/test_report_job_poller.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include @@ -126,3 +127,31 @@ TEST_CASE("ReportJobPoller treats a dispatch error as terminal, not a retry loop REQUIRE_FALSE(pumpUntil([] { return false; }, 120ms)); CHECK(dispatches == dispatchesAtFailure); } + +TEST_CASE("ReportJobPoller survives a null exception_ptr on the error path", "[ledger][gui][poller]") { + // The poller used to re-implement `morph::ladder::gui::errorText` and call + // `std::rethrow_exception` with no null check, which is undefined + // behaviour on a null `exception_ptr` -- a default-constructed one is a + // legal thing for a backend to hand an onError callback. The shared helper + // documents and handles that case; this pins that the poller reaches it. + BackendRig rig{Mode::Local, 1}; + + int failedCalls = 0; + QString message; + ledger::gui::ReportJobPoller poller{ + rig.bridge(0), + ledger::ReportJobId{4}, + [&](ledger::ReportJobId, ledger::gui::ReportJobPoller::OnSuccess, + ledger::gui::ReportJobPoller::OnError onError) { onError(std::exception_ptr{}); }, + [&](std::string) {}, + [&](const QString& msg) { + ++failedCalls; + message = msg; + }, + /*interval=*/10ms}; + + REQUIRE(pumpUntil([&] { return failedCalls > 0; })); + CHECK(failedCalls == 1); + CHECK(message == QStringLiteral("unknown error")); + CHECK(poller.finished()); +}