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