From 47d301bcfec600336462573803586b50e471b087 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Wed, 14 Oct 2026 15:53:57 -0600 Subject: [PATCH] viewer: the overview's numbers checked against ledger-core Kpis gets a spec (cash, liabilities, receivables, month-to-date against the same days of last month, fiscal year to date, burn). tests/view_model.rs computes each from ledger-core on random books (month ends, leap days, any fiscal start) along with the period ranges, cash history, account labels, journal count, receipt rule, origins and parties, the blob root, and integrity counts. Burn rounds half away from zero, like the rest of wanderbooks (it was banker's rounding). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-gui/src/books.rs | 29 ++- crates/ledger-gui/tests/view_model.rs | 297 ++++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 crates/ledger-gui/tests/view_model.rs diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 5b224e7..12926fa 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -132,6 +132,30 @@ impl Integrity { } /// The headline numbers. +/// +/// ```text +/// GOAL: the overview's numbers are the books' numbers. +/// SOURCE: the module's rule ("every number here comes from ledger-core; +/// this module only indexes and arranges"); DECISION (the +/// comparisons and the burn estimate, as defined here). +/// INVARIANTS (all at `today`; m0 = the first of today's month): +/// 1. cash, cash_30d_ago: the cash accounts' balances (normal side) at +/// the end of today and of today - 30 days. +/// 2. liabilities: the balance sheet's total liabilities at today. +/// 3. receivables: the balances of the asset accounts whose name says +/// "receivable". +/// 4. revenue_mtd, expenses_mtd: the P&L from m0 through today. +/// revenue_prev_month, expenses_prev_month: the P&L from the first +/// of last month through the same day of it (its last day, if +/// shorter). +/// 5. net_ytd: revenue minus expenses from the fiscal year's first day +/// (ledger_config) through today. +/// 6. monthly_burn: over the three full months before m0, expenses +/// minus revenue, divided by the months of those the books cover +/// (from the first posted entry's month); Some only when positive, +/// rounded to the cent half away from zero. +/// 7. first_entry: the earliest posted entry's date. +/// ``` #[derive(Clone, Debug, Default)] pub struct Kpis { pub cash: Decimal, @@ -437,7 +461,10 @@ impl Books { Some(_) => 3, None => 0, }; - (loss > Decimal::ZERO && months > 0).then(|| (loss / Decimal::from(months)).round_dp(2)) + (loss > Decimal::ZERO && months > 0).then(|| { + (loss / Decimal::from(months)) + .round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero) + }) }); Kpis { cash: cash[1], diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs new file mode 100644 index 0000000..f0a7d43 --- /dev/null +++ b/crates/ledger-gui/tests/view_model.rs @@ -0,0 +1,297 @@ +//! The viewer's numbers are the books' numbers: the view model +//! (books::Books, books::Kpis) against ledger-core, computed here +//! independently, on random books. + +use chrono::{Datelike, Days, Months, NaiveDate}; +use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period, trial_balance_as_of}; +use ledger_core::{AccountType, Currency, DocumentLine, Money, Side}; +use ledger_gui::{Source, data, fmt}; +use ledger_store::Store; +use proptest::prelude::*; +use rust_decimal::{Decimal, RoundingStrategy}; +use std::path::PathBuf; + +const CHART: [(&str, &str, AccountType); 9] = [ + ("1000", "Checking", AccountType::Asset), + ("1010", "Savings", AccountType::Asset), + ("1200", "Accounts Receivable", AccountType::Asset), + ("1500", "Equipment", AccountType::Asset), + ("2000", "Accounts Payable", AccountType::Liability), + ("2100", "Due to owners", AccountType::Liability), + ("3000", "Capital", AccountType::Equity), + ("4000", "Revenue", AccountType::Revenue), + ("6000", "Supplies", AccountType::Expense), +]; +const CASH: [&str; 2] = ["1000", "1010"]; + +#[derive(Clone, Debug)] +struct Plan { + today: NaiveDate, + fy_month: u32, + /// (days before today, debit index, credit index, cents) + entries: Vec<(u64, usize, usize, i64)>, +} + +fn plan() -> impl Strategy { + ( + // Month ends and leap days included. + prop_oneof![ + Just(NaiveDate::from_ymd_opt(2026, 3, 31).unwrap()), + Just(NaiveDate::from_ymd_opt(2028, 2, 29).unwrap()), + Just(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()), + (0u64..900).prop_map(|d| NaiveDate::from_ymd_opt(2025, 1, 1).unwrap() + Days::new(d)), + ], + 1u32..=12, + proptest::collection::vec((0u64..200, 0usize..9, 0usize..9, 1i64..5_000_000), 0..25), + ) + .prop_map(|(today, fy_month, entries)| Plan { + today, + fy_month, + entries: entries.into_iter().filter(|e| e.1 != e.2).collect(), + }) +} + +fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("ledger-gui-vm-{}-{tag}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn build(plan: &Plan, tag: &str) -> (Source, ledger_core::Ledger) { + let d = dir(tag); + let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); + let ids: Vec<_> = CHART + .iter() + .map(|(c, n, t)| s.add_account(c, n, *t, None, false).unwrap()) + .collect(); + for &(back, dr, cr, cents) in &plan.entries { + let e = s.create_entry(plan.today - Days::new(back), "x").unwrap(); + let m = Money::new(Decimal::new(cents, 2), Currency::USD); + s.add_line(e, ids[dr], Side::Debit, m).unwrap(); + s.add_line(e, ids[cr], Side::Credit, m).unwrap(); + s.post_entry(e).unwrap(); + } + std::fs::write( + d.join("wanderbooks.toml"), + format!( + "[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"{:02}-01\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nreceipt_threshold = \"25.00\"\n", + plan.fy_month + ), + ) + .unwrap(); + let ledger = s.ledger().clone(); + ( + Source { + data_dir: d.join("books-data"), + books_toml: Some(d.join("wanderbooks.toml")), + today: Some(plan.today), + }, + ledger, + ) +} + +/// An account's normal-side balance at the end of `date`, from the trial +/// balance. +fn balance(l: &ledger_core::Ledger, code: &str, date: NaiveDate) -> Decimal { + let t = l.chart().find_by_code(code).unwrap().account_type; + let tb = trial_balance_as_of(l, date); + let net = tb + .lines + .iter() + .find(|x| x.code == code) + .map(|x| x.debit - x.credit) + .unwrap_or_default(); + match t { + AccountType::Asset | AccountType::Expense => net, + _ => -net, + } +} + +fn cash(l: &ledger_core::Ledger, date: NaiveDate) -> Decimal { + CASH.iter().map(|c| balance(l, c, date)).sum() +} + +fn first_of(d: NaiveDate) -> NaiveDate { + NaiveDate::from_ymd_opt(d.year(), d.month(), 1).unwrap() +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })] + + /// Kpis INVARIANTS 1-7 and the period helpers, against ledger-core. + #[test] + fn the_overview_numbers_are_the_books_numbers(p in plan()) { + let (source, l) = build(&p, "kpi"); + let b = data::load_once(&source).unwrap(); + let k = &b.kpis; + let t = p.today; + let m0 = first_of(t); + prop_assert_eq!(b.month_start(), m0); + prop_assert_eq!(k.cash, cash(&l, t)); + prop_assert_eq!(k.cash_30d_ago, cash(&l, t - Days::new(30))); + prop_assert_eq!(k.liabilities, balance_sheet_as_of(&l, t).unwrap().total_liabilities); + prop_assert_eq!(k.receivables, balance(&l, "1200", t)); + let pl = |s, e| profit_and_loss_for_period(&l, s, e, None).unwrap(); + let mtd = pl(m0, t); + prop_assert_eq!(k.revenue_mtd, mtd.total_revenue); + prop_assert_eq!(k.expenses_mtd, mtd.total_expenses); + let prev_start = m0 - Months::new(1); + let prev_last = m0 - Days::new(1); + let same = NaiveDate::from_ymd_opt(prev_start.year(), prev_start.month(), t.day()) + .unwrap_or(prev_last); + let prev = pl(prev_start, same); + prop_assert_eq!(k.revenue_prev_month, prev.total_revenue); + prop_assert_eq!(k.expenses_prev_month, prev.total_expenses); + // Fiscal year: its first day is the last fiscal-start first-of-month + // on or before today. + let mut fy = NaiveDate::from_ymd_opt(t.year(), p.fy_month, 1).unwrap(); + if fy > t { + fy = fy - Months::new(12); + } + prop_assert_eq!(b.fiscal_ytd(), (fy, t)); + let ytd = pl(fy, t); + prop_assert_eq!(k.net_ytd, ytd.total_revenue - ytd.total_expenses); + let first = p.entries.iter().map(|e| t - Days::new(e.0)).min(); + prop_assert_eq!(k.first_entry, first); + // Burn: the three full months before m0, averaged over those the + // books cover. + let three = pl(m0 - Months::new(3), prev_last); + let loss = three.total_expenses - three.total_revenue; + let covered = match first { + None => 0, + Some(f) => (0..3) + .filter(|i| first_of(m0 - Months::new(i + 1)) >= first_of(f)) + .count() as i64, + }; + let burn = (loss > Decimal::ZERO && covered > 0).then(|| { + (loss / Decimal::from(covered)) + .round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero) + }); + prop_assert_eq!(k.monthly_burn, burn); + // Trailing months: n months ending with this one. + for n in 1..=12u32 { + prop_assert_eq!(b.trailing_months(n), (m0 - Months::new(n - 1), t)); + } + // Cash history: each point is the cash balance that day, dates + // strictly rising, through the last day cash moved. + let h = b.cash_history(); + for w in h.windows(2) { + prop_assert!(w[0].0 < w[1].0); + } + for (d, v) in &h { + prop_assert_eq!(*v, cash(&l, *d), "cash on {}", d); + } + // Names and labels. + for (code, name, _) in CHART { + let id = l.chart().find_by_code(code).unwrap().id; + prop_assert_eq!(b.account_label(id), format!("{code} {name}")); + prop_assert_eq!(b.account_name(id), name); + } + prop_assert_eq!(b.company_name(), "Oracle Co"); + let events = std::fs::read_to_string(source.data_dir.join("journal.jsonl")) + .unwrap() + .lines() + .count() as u64; + prop_assert_eq!(b.seq(), events); + prop_assert_eq!(b.receipt_rule(), "over the $25.00 receipt threshold"); + let _ = std::fs::remove_dir_all(source.data_dir.parent().unwrap()); + } +} + +/// Where each entry came from, and who it was with, from the journal. +#[test] +fn origins_and_parties() { + let d = dir("origin"); + let today = NaiveDate::from_ymd_opt(2026, 5, 20).unwrap(); + let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let sup = s + .add_account("6000", "Supplies", AccountType::Expense, None, false) + .unwrap(); + let cust = s.add_customer("Acme Buyer").unwrap(); + let vend = s.add_vendor("Paper Co").unwrap(); + let line = |a: i64| { + vec![DocumentLine { + description: "x".into(), + amount: Decimal::new(a, 2), + }] + }; + let inv = s + .create_invoice(cust, today, today + Days::new(30), line(10_000)) + .unwrap(); + s.create_bill(vend, today, today + Days::new(30), line(5_000)) + .unwrap(); + let paid = s + .receive_payment(inv, today, Decimal::new(10_000, 2), cash) + .unwrap(); + let e = s.create_entry(today, "Paper").unwrap(); + let m = Money::new(Decimal::new(1_000, 2), Currency::USD); + s.add_line(e, sup, Side::Debit, m).unwrap(); + s.add_line(e, cash, Side::Credit, m).unwrap(); + s.post_entry(e).unwrap(); + let (blob, _, _) = s + .attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF receipt", None) + .unwrap(); + let rev = s.reverse_entry(e, today, "undo").unwrap(); + drop(s); + std::fs::write( + d.join("wanderbooks.toml"), + "[books]\nreceipt_threshold = \"0.00\"\n", + ) + .unwrap(); + let source = Source { + data_dir: d.join("books-data"), + books_toml: Some(d.join("wanderbooks.toml")), + today: Some(today), + }; + let b = data::load_once(&source).unwrap(); + let by_origin = |o: &str| b.entries.iter().filter(|x| x.origin == o).count(); + assert_eq!(by_origin("Invoice"), 1); + assert_eq!(by_origin("Bill"), 1); + assert_eq!(by_origin("Customer payment"), 1); + assert_eq!(by_origin("Reversal"), 1); + assert_eq!(b.entry(paid).unwrap().party.as_deref(), Some("Acme Buyer")); + let bill_entry = b.entries.iter().find(|x| x.origin == "Bill").unwrap(); + assert_eq!(bill_entry.party.as_deref(), Some("Paper Co")); + assert_eq!(b.entry(e).unwrap().reversed_by, Some(rev)); + assert_eq!(b.entry(rev).unwrap().reversal_of, Some(e)); + // Every entry's journal events name it. + for x in &b.entries { + assert!(!x.events.is_empty(), "entry {} has no events", x.id); + } + assert_eq!(b.receipt_rule(), "every entry needs a receipt"); + // The receipt's bytes are where the blob root says. + let a = b.attachments.iter().find(|a| a.id == blob).unwrap(); + let path = b + .blob_root() + .join(&a.blob_sha[..2]) + .join(&a.blob_sha[2..4]) + .join(&a.blob_sha); + assert_eq!(std::fs::read(path).unwrap(), b"%PDF receipt"); + assert_eq!(b.integrity.files_ok, 1); + assert!(b.integrity.file_failures.is_empty()); + let _ = std::fs::remove_dir_all(&d); +} + +/// fmt helpers the viewer shows: percent change and tax forms. +#[test] +fn percent_change_and_tax_forms() { + let d = |s: &str| s.parse::().unwrap(); + assert_eq!(fmt::pct_change(d("0"), d("5")), None); + assert_eq!(fmt::pct_change(d("100"), d("150")), Some(50.0)); + assert_eq!(fmt::pct_change(d("200"), d("150")), Some(-25.0)); + assert_eq!(fmt::pct_change(d("-100"), d("-50")), Some(50.0)); + use ledger_core::TaxForm::*; + for (f, number) in [ + (ScheduleC, "Schedule C"), + (Form1065, "1065"), + (Form1120S, "1120-S"), + (Form1120, "1120"), + ] { + assert!(fmt::tax_form(f).contains(number), "{f:?}"); + } + assert_eq!(fmt::compact(d("-1234")), "\u{2212}$1.2k"); +}