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"); +}