//! 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); } // ... and it isn't empty when a cash account moved: its last point // is the last day one did (a transfer between them included). let cash_idx = [0usize, 1]; let last_move = p .entries .iter() .filter(|e| cash_idx.contains(&e.1) || cash_idx.contains(&e.2)) .map(|e| t - Days::new(e.0)) .max(); prop_assert_eq!(h.last().map(|x| x.0), last_move); // 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"); } fn store_at(tag: &str) -> (PathBuf, Store) { let d = dir(tag); let s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); (d, s) } fn source(d: &std::path::Path, toml: &str, today: NaiveDate) -> Source { std::fs::write(d.join("wanderbooks.toml"), toml).unwrap(); Source { data_dir: d.join("books-data"), books_toml: Some(d.join("wanderbooks.toml")), today: Some(today), } } fn post(s: &mut Store, date: NaiveDate, dr: u64, cr: u64, cents: i64) -> u64 { let e = s.create_entry(date, "x").unwrap(); let m = Money::new(Decimal::new(cents, 2), Currency::USD); s.add_line(e, dr, Side::Debit, m).unwrap(); s.add_line(e, cr, Side::Credit, m).unwrap(); s.post_entry(e).unwrap(); e } /// Kpis INVARIANTS 4 and 6 on fixed dates: the prior month runs to the /// same day; burn averages over only the months the books cover. #[test] fn comparisons_and_burn_on_fixed_dates() { let (d, mut s) = store_at("burn"); let cash = s .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let rev = s .add_account("4000", "Sales", AccountType::Revenue, None, false) .unwrap(); let exp = s .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); let today = NaiveDate::from_ymd_opt(2026, 5, 10).unwrap(); // The books start in March: burn covers March and April, not February. post( &mut s, NaiveDate::from_ymd_opt(2026, 3, 3).unwrap(), exp, cash, 30_000, ); post( &mut s, NaiveDate::from_ymd_opt(2026, 4, 10).unwrap(), exp, cash, 10_000, ); post( &mut s, NaiveDate::from_ymd_opt(2026, 4, 11).unwrap(), cash, rev, 7_000, ); // after the same day post( &mut s, NaiveDate::from_ymd_opt(2026, 5, 2).unwrap(), cash, rev, 1_000, ); drop(s); let b = data::load_once(&source(&d, "[books]\ncash_accounts = [\"1000\"]\n", today)).unwrap(); let k = &b.kpis; assert_eq!(k.revenue_mtd, Decimal::new(1_000, 2)); assert_eq!(k.expenses_prev_month, Decimal::new(10_000, 2), "April 1-10"); assert_eq!( k.revenue_prev_month, Decimal::ZERO, "April 11 is after the same day" ); // Feb-Apr loss: 300 + 100 - 70 = 330 over the 2 months covered. assert_eq!(k.monthly_burn, Some(Decimal::new(16_500, 2))); // A profitable stretch has no burn; books starting this month have none. let (d2, mut s2) = store_at("burn-new"); let c2 = s2 .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let e2 = s2 .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); post( &mut s2, NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(), e2, c2, 5_000, ); drop(s2); let b2 = data::load_once(&source(&d2, "", today)).unwrap(); assert_eq!(b2.kpis.monthly_burn, None); // Three full months of loss: divided by 3, half away from zero. let (d3, mut s3) = store_at("burn-three"); let c3 = s3 .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let e3 = s3 .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); post( &mut s3, NaiveDate::from_ymd_opt(2025, 12, 1).unwrap(), e3, c3, 1, ); post( &mut s3, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), e3, c3, 1_000, ); drop(s3); let b3 = data::load_once(&source(&d3, "", today)).unwrap(); // 10.00 / 3 = 3.333... -> 3.33 assert_eq!(b3.kpis.monthly_burn, Some(Decimal::new(333, 2))); // Books covering only last month: one month, not two. let (d4, mut s4) = store_at("burn-one"); let c4 = s4 .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let e4 = s4 .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); post( &mut s4, NaiveDate::from_ymd_opt(2026, 4, 20).unwrap(), e4, c4, 9_000, ); drop(s4); let b4 = data::load_once(&source(&d4, "", today)).unwrap(); assert_eq!(b4.kpis.monthly_burn, Some(Decimal::new(9_000, 2))); // Breaking even burns nothing. let (d5, mut s5) = store_at("burn-even"); let c5 = s5 .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let r5 = s5 .add_account("4000", "Sales", AccountType::Revenue, None, false) .unwrap(); let e5 = s5 .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); post( &mut s5, NaiveDate::from_ymd_opt(2026, 3, 5).unwrap(), e5, c5, 4_000, ); post( &mut s5, NaiveDate::from_ymd_opt(2026, 3, 6).unwrap(), c5, r5, 4_000, ); drop(s5); let b5 = data::load_once(&source(&d5, "", today)).unwrap(); assert_eq!(b5.kpis.monthly_burn, None); for x in [d, d2, d3, d4, d5] { let _ = std::fs::remove_dir_all(x); } } /// The view model's indexes: cash accounts by name when the settings name /// none, card accounts, each entry's accounts (debits first, once each), /// and what's new since the last refresh. #[test] fn indexes_cash_cards_accounts_and_fresh() { let (d, mut s) = store_at("index"); let checking = s .add_account("1000", "Business Checking", AccountType::Asset, None, false) .unwrap(); let savings = s .add_account("1010", "Savings", AccountType::Asset, None, false) .unwrap(); let group = s .add_account("1090", "Cash accounts", AccountType::Asset, None, true) .unwrap(); let equip = s .add_account("1500", "Equipment", AccountType::Asset, None, false) .unwrap(); let card = s .add_account( "2050", "Chase Ink Card", AccountType::Liability, None, false, ) .unwrap(); let cardish = s .add_account("1600", "Card reader", AccountType::Asset, None, false) .unwrap(); let rent = s .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); let today = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap(); // Two debits to one account, then two credits. let e = s.create_entry(today, "split").unwrap(); let m = |c| Money::new(Decimal::new(c, 2), Currency::USD); s.add_line(e, rent, Side::Debit, m(100)).unwrap(); s.add_line(e, rent, Side::Debit, m(200)).unwrap(); s.add_line(e, card, Side::Credit, m(250)).unwrap(); s.add_line(e, checking, Side::Credit, m(50)).unwrap(); s.post_entry(e).unwrap(); drop(s); let src = source(&d, "", today); let b = data::load_once(&src).unwrap(); let mut cash = b.cash_accounts.clone(); cash.sort(); assert_eq!( cash, vec![checking, savings], "by name, postable assets only" ); let _ = (group, equip, cardish); assert_eq!(b.card_accounts, vec![card], "liabilities named card"); assert_eq!(b.entry(e).unwrap().accounts, vec![rent, card, checking]); assert!(b.fresh.is_empty(), "a first load marks nothing new"); // A new entry after a refresh is the only fresh one. let seq = b.seq(); let mut s = Store::open(&d.join("books-data")).unwrap(); let e2 = post(&mut s, today, rent, checking, 999); drop(s); let snap = ledger_store::Snapshot::load(&src.data_dir).unwrap(); let settings = ledger_config::load(src.books_toml.as_ref().unwrap()).unwrap(); let mut verified = std::collections::HashSet::new(); let integrity = ledger_gui::books::Integrity::check(&snap, &mut verified); let b2 = ledger_gui::books::Books::build(&snap, settings, today, Some(seq), integrity).unwrap(); assert_eq!(b2.fresh, [e2].into_iter().collect()); let _ = std::fs::remove_dir_all(&d); } /// Integrity counts every stored file, read once: a second check with the /// same memory counts them again without rereading; a damaged one fails. #[test] fn integrity_counts_files_once_verified() { let (d, mut s) = store_at("integrity"); let cash = s .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let rent = s .add_account("6000", "Rent", AccountType::Expense, None, false) .unwrap(); let e = post( &mut s, NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), rent, cash, 100, ); s.attach_receipt(Some(e), "a.pdf", "application/pdf", b"one", None) .unwrap(); s.attach_receipt(Some(e), "b.pdf", "application/pdf", b"two", None) .unwrap(); drop(s); let snap = ledger_store::Snapshot::load(&d.join("books-data")).unwrap(); let mut verified = std::collections::HashSet::new(); let first = ledger_gui::books::Integrity::check(&snap, &mut verified); assert_eq!((first.files_ok, first.file_failures.len()), (2, 0)); let again = ledger_gui::books::Integrity::check(&snap, &mut verified); assert_eq!((again.files_ok, again.file_failures.len()), (2, 0)); let _ = std::fs::remove_dir_all(&d); } /// Origins and parties for the rest of the journal's kinds: the invoice /// itself shows its customer, a bill payment its vendor, and a payroll /// tax remittance is labeled as one (so the receipt rule for hand-made /// entries doesn't flag it). #[test] fn more_origins_and_parties() { let (d, mut s) = store_at("origin2"); let cash = s .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); let today = NaiveDate::from_ymd_opt(2026, 7, 15).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), }] }; s.create_invoice(cust, today, today + Days::new(30), line(10_000)) .unwrap(); let bill = s .create_bill(vend, today, today + Days::new(30), line(5_000)) .unwrap(); let paid = s .pay_bill(bill, today, Decimal::new(5_000, 2), cash) .unwrap(); s.run_payroll( today, vec![ledger_core::PayrollLine { employee_name: "Pat".into(), gross_pay: Decimal::new(100_000, 2), federal_withholding: Decimal::new(10_000, 2), state_withholding: Decimal::new(3_000, 2), ss_employee: Decimal::new(6_200, 2), medicare_employee: Decimal::new(1_450, 2), ss_employer: Decimal::new(6_200, 2), medicare_employer: Decimal::new(1_450, 2), other_deductions: Decimal::ZERO, }], cash, ) .unwrap(); let remit = s.remit_payroll_taxes(today, cash).unwrap(); drop(s); let b = data::load_once(&source( &d, "[books]\nreceipt_threshold = \"0.00\"\n", today, )) .unwrap(); let inv = b.entries.iter().find(|x| x.origin == "Invoice").unwrap(); assert_eq!(inv.party.as_deref(), Some("Acme Buyer")); let pay = b.entry(paid).unwrap(); assert_eq!(pay.origin, "Bill payment"); assert_eq!(pay.party.as_deref(), Some("Paper Co")); assert_eq!( b.entries.iter().filter(|x| x.origin == "Payroll").count(), 1 ); let r = b.entry(remit).unwrap(); assert_eq!(r.origin, "Payroll taxes"); assert!(!r.missing_receipt, "a remittance isn't a hand-made entry"); let _ = std::fs::remove_dir_all(&d); }