From f4cb01b3f9912ee2441f5b7e9546e43955bc7a92 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 11 Oct 2026 19:37:52 -0700 Subject: [PATCH] fix: money strings always carry cents money() printed whatever scale a Decimal happened to have, so an empty sum came back as "0" while every other amount read "207.68". Amounts now have at least two places ("0.00", "1000.00"), padded and never rounded: an exact FX-converted amount with more places keeps them. Negative zero normalizes to "0.00". Test: money_always_has_cents_and_never_rounds; three stdio assertions that pinned the old "0" now expect "0.00". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu --- crates/ledger-mcp/src/server.rs | 26 +++++++++++++++++++++++++- crates/ledger-mcp/tests/mcp_stdio.rs | 6 +++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index d593883..e8c4ede 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -204,8 +204,14 @@ fn resolve_class(store: &Store, class: &str) -> Result { }) } +/// A money amount as a decimal string with at least two places: "0.00", +/// "207.68", "1000.00". Padding only, never rounding: an exact amount with +/// more places (an FX-converted line) keeps them. Never "-0". fn money(n: Decimal) -> String { - // Decimal's Display never emits floats or "-0" for exact values. + let mut n = if n.is_zero() { Decimal::ZERO } else { n }; + if n.scale() < 2 { + n.rescale(2); + } n.to_string() } @@ -2999,3 +3005,21 @@ impl ServerHandler for BooksServer { ))) } } + +#[cfg(test)] +mod tests { + use super::money; + use rust_decimal::Decimal; + use std::str::FromStr; + + #[test] + fn money_always_has_cents_and_never_rounds() { + let m = |s: &str| money(Decimal::from_str(s).unwrap()); + assert_eq!(money(Decimal::ZERO), "0.00"); + assert_eq!(m("-0"), "0.00"); + assert_eq!(m("1000"), "1000.00"); + assert_eq!(m("5.8"), "5.80"); + assert_eq!(m("-207.68"), "-207.68"); + assert_eq!(m("12.3456"), "12.3456"); + } +} diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 5d70f07..3154c28 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -201,7 +201,7 @@ fn mcp_end_to_end_over_stdio() { .iter() .find(|l| l["code"] == "6000") .unwrap(); - assert_eq!(rent_line["balance_before"], "0"); + assert_eq!(rent_line["balance_before"], "0.00"); assert_eq!(rent_line["balance_after"], "100.00"); // Confirmed post without a receipt is rejected: >$25, no receipt. @@ -275,7 +275,7 @@ fn mcp_end_to_end_over_stdio() { assert_eq!(rev["status"], "posted"); let tb2 = mcp.call("books.trial_balance", json!({})); assert_eq!(tb2["balanced"], true); - assert_eq!(tb2["total_debits"], "0"); + assert_eq!(tb2["total_debits"], "0.00"); } #[test] @@ -381,7 +381,7 @@ fn mcp_assets_end_to_end_over_stdio() { assert_eq!(preview["status"], "preview"); assert_eq!(preview["book_value"], "58451.61"); assert_eq!(preview["gain_loss"], "1548.39"); - assert_eq!(preview["final_depreciation"], "0"); + assert_eq!(preview["final_depreciation"], "0.00"); assert_eq!(preview["receipt_required"], true); // Confirmed without the bill of sale: rejected.