//! Oracle tests for `ledger-mcp`, driven over stdio against the real binary. //! //! Every number a tool reports is checked against what `ledger-core` (or //! `ledger-store`) computes for the same books, computed here from their //! public APIs, or against first principles of double entry. Formats are //! checked by reading them back: CSV is parsed (RFC 4180) to the same rows, //! JSON deserializes, zips and XLSX unzip, IIF splits into its records. //! //! The books are mostly built in-process with [`Store`] (fast, and the //! oracle sees exactly the books the server will open), then the store is //! closed and the server started on the same data directory. use base64::Engine as _; use ledger_core::{ AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital, Money, NewAsset, Side, depreciation_schedule, reports, }; use ledger_store::Store; use ledger_store::viewer::{ViewerCommand, ViewerTarget}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashMap}; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::str::FromStr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc::{Receiver, channel}; use std::time::Duration; const TIMEOUT: Duration = Duration::from_secs(180); // ---------------------------------------------------------------- harness #[derive(Debug)] enum Out { Ok(Value), ToolErr(String), RpcErr(Value), } struct Mcp { stdin: ChildStdin, rx: Receiver, child: Child, next_id: u64, run: PathBuf, } impl Drop for Mcp { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } static DIR_SEQ: AtomicU64 = AtomicU64::new(0); fn test_dir(name: &str) -> PathBuf { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "ledger-mcp-oracle-{}-{name}-{n}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } /// `wanderbooks.toml` for two 50/50 members on 3000/3001, cash 1000, /// financing 2100, with the given receipt threshold. fn books_toml(dir: &Path, threshold: &str) -> PathBuf { let path = dir.join(format!("wanderbooks-{threshold}.toml")); std::fs::write( &path, format!( "[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"01-01\"\n\ [[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n\ financing_accounts = [\"2100\"]\n" ), ) .unwrap(); path } fn data_dir(dir: &Path) -> PathBuf { dir.join("books-data") } impl Mcp { fn start(dir: &Path, threshold: &str) -> Self { let toml = books_toml(dir, threshold); let run = dir.join("run"); std::fs::create_dir_all(&run).unwrap(); let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) .arg("--data-dir") .arg(data_dir(dir)) .arg("--books-toml") .arg(&toml) .env("XDG_RUNTIME_DIR", &run) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .expect("spawn ledger-mcp"); let stdin = child.stdin.take().unwrap(); let stdout = BufReader::new(child.stdout.take().unwrap()); let (tx, rx) = channel(); std::thread::spawn(move || { for line in stdout.lines() { let Ok(line) = line else { break }; if tx.send(line).is_err() { break; } } }); let mut m = Self { stdin, rx, child, next_id: 1, run, }; let init = m.rpc( "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "oracle-test", "version": "0"}}), ); assert!(init.get("result").is_some(), "initialize failed: {init}"); m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string()); m } fn send(&mut self, line: &str) { writeln!(self.stdin, "{line}").unwrap(); self.stdin.flush().unwrap(); } fn rpc(&mut self, method: &str, params: Value) -> Value { let id = self.next_id; self.next_id += 1; self.send( &json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(), ); loop { let line = self .rx .recv_timeout(TIMEOUT) .unwrap_or_else(|_| panic!("no response to {method}")); let v: Value = serde_json::from_str(&line).expect("server wrote JSON"); if v.get("id").is_none() { continue; } assert_eq!(v["id"], json!(id)); return v; } } fn tool(&mut self, name: &str, args: Value) -> Out { let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); if let Some(e) = resp.get("error") { return Out::RpcErr(e.clone()); } let result = &resp["result"]; if result.get("isError") == Some(&json!(true)) { let text = result["content"] .as_array() .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) .unwrap_or_default() .to_string(); return Out::ToolErr(text); } Out::Ok(result["structuredContent"].clone()) } fn ok(&mut self, name: &str, args: Value) -> Value { let a = args.to_string(); match self.tool(name, args) { Out::Ok(v) => v, other => panic!("{name} {a}: expected success, got {other:?}"), } } fn refuse(&mut self, name: &str, args: Value) -> String { let a = args.to_string(); match self.tool(name, args) { Out::Ok(v) => panic!("{name} {a}: expected a refusal, got {v}"), Out::ToolErr(t) => t, Out::RpcErr(e) => e.to_string(), } } /// `books.export`, returning the content (base64-decoded when the /// server says it is base64). fn export(&mut self, args: Value) -> Vec { let out = self.ok("books.export", args); let content = out["content"].as_str().unwrap(); if out["content_encoding"] == "base64" { b64d(content) } else { content.as_bytes().to_vec() } } fn export_text(&mut self, args: Value) -> String { let out = self.ok("books.export", args.clone()); assert!( out.get("content_encoding").is_none_or(|e| e.is_null()), "text export {args} came back encoded" ); out["content"].as_str().unwrap().to_string() } /// The viewer command the last `books.show` wrote. fn viewer_command(&self) -> ViewerCommand { let dir = self.run.join("wanderbooks"); let files: Vec<_> = std::fs::read_dir(&dir) .unwrap() .filter_map(|e| e.ok()) .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|x| x == "json")) .collect(); assert_eq!(files.len(), 1, "one viewer command file: {files:?}"); serde_json::from_slice(&std::fs::read(&files[0]).unwrap()).unwrap() } } // ---------------------------------------------------------------- helpers fn b64e(bytes: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } fn b64d(s: &str) -> Vec { base64::engine::general_purpose::STANDARD .decode(s) .expect("valid base64") } fn sha256(bytes: &[u8]) -> String { ledger_store::journal::sha256_hex(bytes) } fn d(s: &str) -> chrono::NaiveDate { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() } fn dec(s: &str) -> Decimal { Decimal::from_str(s).unwrap_or_else(|e| panic!("{s:?} is not a decimal: {e}")) } fn usd(s: &str) -> Money { Money::new(dec(s), Currency::USD) } /// Amounts out (server invariant 3): `^-?\d+\.\d{2,}$`, parsed exactly. fn amt(s: &str) -> Decimal { let body = s.strip_prefix('-').unwrap_or(s); let ok = body.split_once('.').is_some_and(|(i, f)| { !i.is_empty() && i.bytes().all(|b| b.is_ascii_digit()) && f.len() >= 2 && f.bytes().all(|b| b.is_ascii_digit()) }); assert!(ok, "amount {s:?} is not a decimal string with 2+ places"); dec(s) } /// A JSON string field as an amount. fn jamt(v: &Value) -> Decimal { amt(v .as_str() .unwrap_or_else(|| panic!("amount {v} is not a string"))) } /// RFC 4180: fields separated by commas, records by CRLF or LF, quoted /// fields may hold commas, quotes (doubled), and line breaks. fn parse_csv(s: &str) -> Vec> { let mut rows = Vec::new(); let mut row = Vec::new(); let mut field = String::new(); let mut quoted = false; let mut chars = s.chars().peekable(); while let Some(c) = chars.next() { if quoted { if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); chars.next(); } else { quoted = false; } } else { field.push(c); } continue; } match c { '"' => quoted = true, ',' => row.push(std::mem::take(&mut field)), '\r' => {} '\n' => { row.push(std::mem::take(&mut field)); rows.push(std::mem::take(&mut row)); } _ => field.push(c), } } assert!(!quoted, "unterminated quoted CSV field"); if !field.is_empty() || !row.is_empty() { row.push(field); rows.push(row); } rows } /// Parse CSV, check the header and that every row has its width. fn csv_rows(s: &str, header: &[&str]) -> Vec> { let mut rows = parse_csv(s); assert!(!rows.is_empty(), "empty CSV"); let head = rows.remove(0); assert_eq!(head, header, "CSV header"); for r in &rows { assert_eq!(r.len(), header.len(), "CSV row width: {r:?}"); } rows } /// A zip's files, by name. fn unzip(bytes: &[u8]) -> BTreeMap> { assert!(bytes.starts_with(b"PK"), "not a zip"); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("a readable zip"); let mut out = BTreeMap::new(); for i in 0..z.len() { let mut f = z.by_index(i).unwrap(); let name = f.name().expect("a zip member name").to_string(); let mut buf = Vec::new(); f.read_to_end(&mut buf).unwrap(); assert!( out.insert(name.clone(), buf).is_none(), "duplicate zip member {name}" ); } out } /// All the text in an XLSX workbook's XML parts. fn xlsx_text(bytes: &[u8]) -> String { let files = unzip(bytes); assert!( files.contains_key("[Content_Types].xml"), "an XLSX has [Content_Types].xml: {:?}", files.keys() ); assert!( files.keys().any(|k| k.starts_with("xl/worksheets/")), "an XLSX has worksheets" ); files .iter() .filter(|(k, _)| k.ends_with(".xml")) .map(|(_, v)| String::from_utf8_lossy(v).into_owned()) .collect::>() .join("\n") } fn id_of(s: &Store, code: &str) -> AccountId { s.ledger().chart().find_by_code(code).unwrap().id } /// Normal-side sign: +1 when the account's normal side is debit. fn normal_signed(ledger: &Ledger, account: AccountId, side: Side, amount: Decimal) -> Decimal { let a = ledger.chart().get(account).unwrap(); if side == a.account_type.normal_side() { amount } else { -amount } } fn side_str(s: Side) -> &'static str { match s { Side::Debit => "debit", Side::Credit => "credit", } } // ---------------------------------------------------------------- fixture const CHART: &[(&str, &str, AccountType)] = &[ ("1000", "Bank", AccountType::Asset), ("1200", "Accounts Receivable", AccountType::Asset), ("1300", "Prepaid Expenses", AccountType::Asset), ("1500", "Equipment", AccountType::Asset), ("1510", "Accumulated Depreciation", AccountType::Asset), ("2000", "Accounts Payable", AccountType::Liability), ("2100", "Due to Shareholders", AccountType::Liability), ("3000", "Capital A", AccountType::Equity), ("3001", "Capital B", AccountType::Equity), ("3999", "Retained Earnings", AccountType::Equity), ("4000", "Revenue", AccountType::Revenue), ("6000", "Rent", AccountType::Expense), ("6010", "Meals, \"client\"", AccountType::Expense), ("6020", "Coffee", AccountType::Expense), ("6500", "Depreciation Expense", AccountType::Expense), ("6999", "General Expenses", AccountType::Expense), ("7100", "Gain or Loss on Disposal", AccountType::Expense), ]; fn new_store(dir: &Path) -> Store { let mut s = Store::create(&data_dir(dir), Currency::USD).unwrap(); for (code, name, ty) in CHART { s.add_account(code, name, *ty, None, false).unwrap(); } s } /// Draft an entry from `(code, side, amount, class)` lines. fn draft(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 { let e = s.create_entry(d(date), desc).unwrap(); for (code, side, a) in lines { let id = id_of(s, code); s.add_line(e, id, *side, usd(a)).unwrap(); } e } fn post(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 { let e = draft(s, date, desc, lines); s.post_entry(e).unwrap(); e } use Side::{Credit as Cr, Debit as Dr}; struct Rich { rent: u64, rondo: u32, coach: u32, contractor: u64, prior: u64, after: u64, never: u64, } /// Books with a bit of everything, mostly in 2025: contributions, classes, /// vendors, an asset and its depreciation, a reversal, a draft, an entry /// the year before and one after mid-year, and awkward names for CSV. fn rich_books(s: &mut Store) -> Rich { let rondo = s.add_class("Rondo").unwrap(); let coach = s.add_class("Coach").unwrap(); let landlord = s.add_vendor("Landlord").unwrap(); let contractor = s.add_vendor("Contractor").unwrap(); s.set_vendor_1099(landlord, Some(Form1099::Misc), true) .unwrap(); s.set_vendor_1099(contractor, Some(Form1099::Nec), false) .unwrap(); s.set_account_tax(id_of(s, "6000"), Some("rent_property"), None) .unwrap(); s.set_account_tax(id_of(s, "6010"), Some("meals"), None) .unwrap(); let prior = post( s, "2024-12-15", "Prior-year sale", &[("1000", Dr, "100.00"), ("4000", Cr, "100.00")], ); post( s, "2025-01-02", "Owner contribution", &[ ("1000", Dr, "10000.00"), ("3000", Cr, "5000.00"), ("3001", Cr, "5000.00"), ], ); let rent = draft( s, "2025-01-15", "Rent January", &[("6000", Dr, "1200"), ("1000", Cr, "1200")], ); s.set_line_class(rent, 0, Some(rondo)).unwrap(); s.set_entry_vendor(rent, Some(landlord)).unwrap(); s.post_entry(rent).unwrap(); let sale = draft( s, "2025-02-01", "Rondo sale", &[("1000", Dr, "2500.00"), ("4000", Cr, "2500.00")], ); s.set_line_class(sale, 1, Some(rondo)).unwrap(); s.post_entry(sale).unwrap(); let coach_sale = draft( s, "2025-02-10", "Coach sale", &[("1000", Dr, "700.25"), ("4000", Cr, "700.25")], ); s.set_line_class(coach_sale, 1, Some(coach)).unwrap(); s.post_entry(coach_sale).unwrap(); post( s, "2025-02-20", "Invoice, on account", &[("1200", Dr, "300.00"), ("4000", Cr, "300.00")], ); post( s, "2025-02-21", "Supplies on account", &[("6999", Dr, "40.00"), ("2000", Cr, "40.00")], ); post( s, "2025-03-05", "Lunch, \"team\"\nquarterly", &[("6010", Dr, "80.50"), ("2100", Cr, "80.50")], ); post( s, "2025-03-20", "Laptop", &[("1500", Dr, "3000.00"), ("1000", Cr, "3000.00")], ); let asset = s .register_asset(NewAsset { description: "Laptop".into(), purchase_date: d("2025-03-20"), cost: usd("3000.00"), salvage_value: usd("0.00"), useful_life_months: 36, cost_account: id_of(s, "1500"), accum_depr_account: id_of(s, "1510"), expense_account: id_of(s, "6500"), }) .unwrap(); for p in ["2025-03", "2025-04", "2025-05"] { s.run_depreciation(asset, p).unwrap(); } post( s, "2025-04-01", "Distribution", &[ ("3000", Dr, "500.00"), ("3001", Dr, "500.00"), ("1000", Cr, "1000.00"), ], ); let oops = post( s, "2025-04-10", "Mistake", &[("6999", Dr, "50.00"), ("1000", Cr, "50.00")], ); s.reverse_entry(oops, d("2025-04-11"), "Reverse mistake") .unwrap(); let pay = draft( s, "2025-05-10", "Contractor work", &[("6999", Dr, "900.00"), ("1000", Cr, "900.00")], ); s.set_entry_vendor(pay, Some(contractor)).unwrap(); s.post_entry(pay).unwrap(); let never = draft( s, "2025-05-01", "Never posted", &[("6999", Dr, "999.00"), ("1000", Cr, "999.00")], ); let after = post( s, "2025-08-01", "After mid-year", &[("6999", Dr, "25.00"), ("1000", Cr, "25.00")], ); Rich { rent, rondo, coach, contractor, prior, after, never, } } const START: &str = "2025-01-01"; const END: &str = "2025-06-30"; // ------------------------------------------------- statements: csv + json /// Trial balance CSV and JSON equal `trial_balance_as_of`, line by line. /// Kills: export_trial_balance stubs, its json/csv switch. #[test] fn trial_balance_exports_equal_the_core_trial_balance() { let dir = test_dir("tb"); let mut s = new_store(&dir); rich_books(&mut s); let tb = reports::trial_balance_as_of(s.ledger(), d(END)); drop(s); assert!(!tb.lines.is_empty()); let mut m = Mcp::start(&dir, "1000000.00"); let csv = m.export_text(json!({"format": "csv", "report": "trial_balance", "as_of": END})); let rows = csv_rows(&csv, &["code", "name", "debit", "credit"]); assert_eq!(rows.len(), tb.lines.len() + 1, "a row per line plus TOTAL"); for (r, l) in rows.iter().zip(&tb.lines) { assert_eq!(r[0], l.code); assert_eq!(r[1], l.name); assert_eq!(amt(&r[2]), l.debit, "{} debit", l.code); assert_eq!(amt(&r[3]), l.credit, "{} credit", l.code); } let total = rows.last().unwrap(); assert_eq!(total[0], "TOTAL"); assert_eq!(amt(&total[2]), tb.total_debits); assert_eq!(amt(&total[3]), tb.total_credits); let j: Value = serde_json::from_str( &m.export_text(json!({"format": "json", "report": "trial_balance", "as_of": END})), ) .expect("JSON export deserializes"); assert_eq!(j["as_of"], END); assert_eq!(j["balanced"], json!(tb.balanced())); assert_eq!(jamt(&j["total_debits"]), tb.total_debits); assert_eq!(jamt(&j["total_credits"]), tb.total_credits); let lines = j["lines"].as_array().unwrap(); assert_eq!(lines.len(), tb.lines.len()); for (o, l) in lines.iter().zip(&tb.lines) { assert_eq!(o["code"], l.code.as_str()); assert_eq!(o["name"], l.name.as_str()); assert_eq!(jamt(&o["debit"]), l.debit); assert_eq!(jamt(&o["credit"]), l.credit); } } fn check_statement_lines(got: &Value, want: &[ledger_core::StatementLine], what: &str) { let got = got.as_array().unwrap_or_else(|| panic!("{what} lines")); assert_eq!(got.len(), want.len(), "{what}: line count"); for (g, w) in got.iter().zip(want) { assert_eq!(g["code"], w.code.as_str(), "{what}"); assert_eq!(g["name"], w.name.as_str(), "{what}"); assert_eq!(jamt(&g["amount"]), w.amount, "{what} {}", w.code); } } /// P&L CSV and JSON equal `profit_and_loss_for_period`; balance sheet CSV /// and JSON equal `balance_sheet_as_of`. Kills: export_pnl and /// export_balance_sheet stubs and json/csv switches, and the export /// dispatch's balance_sheet arm. #[test] fn pnl_and_balance_sheet_exports_equal_the_core_statements() { let dir = test_dir("pnl-bs"); let mut s = new_store(&dir); rich_books(&mut s); let pl = ledger_core::profit_and_loss_for_period(s.ledger(), d(START), d(END), None).unwrap(); let bs = ledger_core::balance_sheet_as_of(s.ledger(), d(END)).unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); // P&L, CSV. let csv = m.export_text( json!({"format": "csv", "report": "profit_and_loss", "start": START, "end": END}), ); let rows = csv_rows(&csv, &["section", "code", "name", "amount"]); let mut want: Vec<(String, String, String, Decimal)> = Vec::new(); for (section, lines) in [("revenue", &pl.revenue), ("expenses", &pl.expenses)] { for l in lines { want.push((section.into(), l.code.clone(), l.name.clone(), l.amount)); } } for (label, v) in [ ("revenue", pl.total_revenue), ("expenses", pl.total_expenses), ("net_income", pl.net_income()), ] { want.push(("total".into(), String::new(), label.into(), v)); } let got: Vec<_> = rows .iter() .map(|r| (r[0].clone(), r[1].clone(), r[2].clone(), amt(&r[3]))) .collect(); assert_eq!(got, want, "P&L CSV"); // P&L, JSON. let j: Value = serde_json::from_str(&m.export_text( json!({"format": "json", "report": "profit_and_loss", "start": START, "end": END}), )) .unwrap(); assert_eq!(j["start"], START); assert_eq!(j["end"], END); check_statement_lines(&j["revenue_accounts"], &pl.revenue, "revenue"); check_statement_lines(&j["expense_accounts"], &pl.expenses, "expenses"); assert_eq!(jamt(&j["revenue"]), pl.total_revenue); assert_eq!(jamt(&j["expenses"]), pl.total_expenses); assert_eq!(jamt(&j["net_income"]), pl.net_income()); // Balance sheet, CSV. let csv = m.export_text(json!({"format": "csv", "report": "balance_sheet", "as_of": END})); let rows = csv_rows(&csv, &["section", "code", "name", "amount"]); let mut want: Vec<(String, String, String, Decimal)> = Vec::new(); for (section, lines) in [ ("assets", &bs.assets), ("liabilities", &bs.liabilities), ("equity", &bs.equity), ] { for l in lines { want.push((section.into(), l.code.clone(), l.name.clone(), l.amount)); } } want.push(( "equity".into(), String::new(), "current_earnings".into(), bs.current_earnings, )); for (label, v) in [ ("assets", bs.total_assets), ("liabilities", bs.total_liabilities), ("equity", bs.total_equity), ] { want.push(("total".into(), String::new(), label.into(), v)); } let got: Vec<_> = rows .iter() .map(|r| (r[0].clone(), r[1].clone(), r[2].clone(), amt(&r[3]))) .collect(); assert_eq!(got, want, "balance sheet CSV"); // Balance sheet, JSON. let j: Value = serde_json::from_str( &m.export_text(json!({"format": "json", "report": "balance_sheet", "as_of": END})), ) .unwrap(); assert_eq!(j["as_of"], END); check_statement_lines(&j["asset_accounts"], &bs.assets, "assets"); check_statement_lines(&j["liability_accounts"], &bs.liabilities, "liabilities"); check_statement_lines(&j["equity_accounts"], &bs.equity, "equity"); assert_eq!(jamt(&j["assets"]), bs.total_assets); assert_eq!(jamt(&j["liabilities"]), bs.total_liabilities); assert_eq!(jamt(&j["equity"]), bs.total_equity); assert_eq!(jamt(&j["current_earnings"]), bs.current_earnings); assert_eq!(j["balanced"], json!(bs.balanced())); } /// Cash flow CSV and JSON equal `ledger_core::cash_flow` for the same /// account roles. Kills: export_cash_flow stubs and its json/csv switch. #[test] fn cash_flow_exports_equal_the_core_statement() { let dir = test_dir("cf"); let mut s = new_store(&dir); rich_books(&mut s); let cf = ledger_core::cash_flow( s.ledger(), &CashFlowParams { start: d(START), end: d(END), cash_accounts: vec![id_of(&s, "1000")], financing_accounts: vec![id_of(&s, "2100")], investing_accounts: vec![], }, ) .unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let args = |format: &str| { json!({"format": format, "report": "cash_flow", "start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}) }; let csv = m.export_text(args("csv")); let rows = csv_rows(&csv, &["section", "label", "amount"]); let mut want: Vec<(String, String, Decimal)> = vec![ ("operating".into(), "Net income".into(), cf.net_income), ("operating".into(), "Depreciation".into(), cf.depreciation), ]; for l in &cf.working_capital { want.push(("operating".into(), l.label.clone(), l.amount)); } want.push(( "operating".into(), "Cash from operations".into(), cf.cash_from_operations, )); for l in &cf.investing { want.push(("investing".into(), l.label.clone(), l.amount)); } want.push(( "investing".into(), "Cash from investing".into(), cf.cash_from_investing, )); for l in &cf.financing { want.push(("financing".into(), l.label.clone(), l.amount)); } want.push(( "financing".into(), "Cash from financing".into(), cf.cash_from_financing, )); want.push(( "reconciliation".into(), "Net change in cash".into(), cf.net_change_in_cash, )); let got: Vec<_> = rows .iter() .map(|r| (r[0].clone(), r[1].clone(), amt(&r[2]))) .collect(); assert_eq!(got, want, "cash flow CSV"); let j: Value = serde_json::from_str(&m.export_text(args("json"))).unwrap(); assert_eq!(j["start"], START); assert_eq!(j["end"], END); for (k, v) in [ ("net_income", cf.net_income), ("depreciation", cf.depreciation), ("cash_from_operations", cf.cash_from_operations), ("cash_from_investing", cf.cash_from_investing), ("cash_from_financing", cf.cash_from_financing), ("net_change_in_cash", cf.net_change_in_cash), ("cash_beginning", cf.cash_beginning), ("cash_ending", cf.cash_ending), ] { assert_eq!(jamt(&j[k]), v, "cash flow {k}"); } for (k, lines) in [ ("working_capital", &cf.working_capital), ("investing", &cf.investing), ("financing", &cf.financing), ] { let got = j[k].as_array().unwrap(); assert_eq!(got.len(), lines.len(), "{k}"); for (g, l) in got.iter().zip(lines) { assert_eq!(g["label"], l.label.as_str()); assert_eq!(jamt(&g["amount"]), l.amount); } } } /// The journal export, CSV and JSON, is every posted entry line by line /// (no drafts), as the ledger holds them. Kills: export_journal's /// json/csv switch. #[test] fn journal_exports_hold_every_posted_line() { let dir = test_dir("journal"); let mut s = new_store(&dir); rich_books(&mut s); let ledger = s.ledger().clone(); drop(s); let posted: Vec<_> = ledger.posted_entries().collect(); let mut m = Mcp::start(&dir, "1000000.00"); for report in ["journal", "general_journal"] { let csv = m.export_text(json!({"format": "csv", "report": report})); let rows = csv_rows( &csv, &[ "entry_id", "date", "description", "reversal_of", "account_code", "account_name", "side", "amount", "currency", ], ); let want: Vec> = posted .iter() .flat_map(|e| { e.lines().iter().map(|l| { let a = ledger.chart().get(l.account).unwrap(); vec![ e.id().to_string(), e.date().to_string(), e.description().to_string(), e.reversal_of().map(|r| r.to_string()).unwrap_or_default(), a.code.clone(), a.name.clone(), side_str(l.side).to_string(), l.amount.amount.to_string(), l.amount.currency.code().to_string(), ] }) }) .collect(); assert_eq!(rows.len(), want.len(), "{report}: a row per posted line"); for (g, w) in rows.iter().zip(&want) { assert_eq!(g[..7], w[..7], "{report}"); assert_eq!(amt(&g[7]), dec(&w[7]), "{report}"); assert_eq!(g[8], w[8]); } } let j: Value = serde_json::from_str(&m.export_text(json!({"format": "json", "report": "journal"}))) .unwrap(); let got = j.as_array().unwrap(); assert_eq!(got.len(), posted.len(), "a JSON entry per posted entry"); for (g, e) in got.iter().zip(&posted) { assert_eq!(g["entry_id"], json!(e.id())); assert_eq!(g["date"], e.date().to_string()); assert_eq!(g["description"], e.description()); assert_eq!(g["reversal_of"], json!(e.reversal_of())); let lines = g["lines"].as_array().unwrap(); assert_eq!(lines.len(), e.lines().len()); for (gl, l) in lines.iter().zip(e.lines()) { let a = ledger.chart().get(l.account).unwrap(); assert_eq!(gl["account_code"], a.code.as_str()); assert_eq!(gl["account_name"], a.name.as_str()); assert_eq!(gl["side"], side_str(l.side)); assert_eq!(jamt(&gl["amount"]), l.amount.amount); assert_eq!(gl["currency"], l.amount.currency.code()); } } } /// General ledger, tax summary, and members' equity exports equal the core /// reports for the same range. #[test] fn ledger_tax_and_equity_exports_equal_the_core_reports() { let dir = test_dir("gl-tax-me"); let mut s = new_store(&dir); rich_books(&mut s); let ledger = s.ledger().clone(); let gl = ledger_core::general_ledger(&ledger, d(START), d(END)).unwrap(); let ts = ledger_core::tax_summary(&ledger, d(START), d(END), None).unwrap(); let me = ledger_core::members_equity( &ledger, d(START), d(END), &[ MemberCapital { name: "A".into(), capital_account: id_of(&s, "3000"), distribution_account: None, share: dec("0.5"), }, MemberCapital { name: "B".into(), capital_account: id_of(&s, "3001"), distribution_account: None, share: dec("0.5"), }, ], ) .unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let range = |format: &str, report: &str| json!({"format": format, "report": report, "start": START, "end": END}); // General ledger, CSV. let csv = m.export_text(range("csv", "general_ledger")); let rows = csv_rows( &csv, &[ "account", "date", "entry", "description", "split", "debit", "credit", "balance", ], ); let mut it = rows.iter(); for a in &gl.accounts { let label = format!("{} {}", a.code, a.name); let beg = it.next().expect("beginning row"); assert_eq!(beg[0], label); assert_eq!(beg[3], "Beginning balance"); assert_eq!(amt(&beg[7]), a.beginning_balance, "{label} beginning"); for p in &a.postings { let r = it.next().expect("posting row"); assert_eq!(r[0], label); assert_eq!(r[1], p.date.to_string()); assert_eq!(r[2], p.entry_id.to_string()); assert_eq!(r[3], p.description); assert_eq!(r[4], p.split); assert_eq!(amt(&r[5]), p.debit); assert_eq!(amt(&r[6]), p.credit); assert_eq!(amt(&r[7]), p.balance); } let t = it.next().expect("total row"); assert_eq!(t[0], label); assert_eq!(t[3], "Total"); assert_eq!(amt(&t[5]), a.total_debits); assert_eq!(amt(&t[6]), a.total_credits); assert_eq!(amt(&t[7]), a.ending_balance); } assert!(it.next().is_none(), "no rows past the last account"); // General ledger, JSON. let j: Value = serde_json::from_str(&m.export_text(range("json", "general_ledger"))).unwrap(); assert_eq!(j["start"], START); assert_eq!(j["end"], END); let accts = j["accounts"].as_array().unwrap(); assert_eq!(accts.len(), gl.accounts.len()); for (g, a) in accts.iter().zip(&gl.accounts) { assert_eq!(g["code"], a.code.as_str()); assert_eq!(jamt(&g["beginning_balance"]), a.beginning_balance); assert_eq!(jamt(&g["ending_balance"]), a.ending_balance); assert_eq!(jamt(&g["total_debits"]), a.total_debits); assert_eq!(jamt(&g["total_credits"]), a.total_credits); let ps = g["postings"].as_array().unwrap(); assert_eq!(ps.len(), a.postings.len()); for (gp, p) in ps.iter().zip(&a.postings) { assert_eq!(gp["entry_id"], json!(p.entry_id)); assert_eq!(gp["split"], p.split.as_str()); assert_eq!(jamt(&gp["balance"]), p.balance); } } // Tax summary, CSV. let csv = m.export_text(range("csv", "tax_summary")); let rows = csv_rows( &csv, &["section", "category", "label", "accounts", "book", "tax"], ); let mut want: Vec> = Vec::new(); for (section, lines) in [ ("income", &ts.income), ("cost_of_goods", &ts.cost_of_goods), ("deductions", &ts.deductions), ("separately_stated", &ts.separately_stated), ] { for l in lines { want.push(vec![ section.into(), l.category.clone(), l.label.clone(), l.accounts.join("; "), l.book.to_string(), l.tax.to_string(), ]); } } for u in &ts.uncategorized { want.push(vec![ "uncategorized".into(), String::new(), String::new(), u.account.clone(), u.book.to_string(), String::new(), ]); } assert!(!ts.deductions.is_empty() && !ts.uncategorized.is_empty()); assert_eq!(rows.len(), want.len(), "tax summary rows"); for (g, w) in rows.iter().zip(&want) { assert_eq!(g[..4], w[..4]); assert_eq!(amt(&g[4]), dec(&w[4])); if w[5].is_empty() { assert_eq!(g[5], ""); } else { assert_eq!(amt(&g[5]), dec(&w[5])); } } // Tax summary, JSON. let j: Value = serde_json::from_str(&m.export_text(range("json", "tax_summary"))).unwrap(); assert_eq!(jamt(&j["book_net"]), ts.book_net()); assert_eq!(jamt(&j["tax_net"]), ts.tax_net()); assert_eq!( j["deductions"].as_array().unwrap().len(), ts.deductions.len() ); for (g, l) in j["deductions"] .as_array() .unwrap() .iter() .zip(&ts.deductions) { assert_eq!(g["category"], l.category.as_str()); assert_eq!(jamt(&g["book"]), l.book); assert_eq!(jamt(&g["tax"]), l.tax); } assert_eq!( j["uncategorized"].as_array().unwrap().len(), ts.uncategorized.len() ); // Members' equity, CSV and JSON. let csv = m.export_text(range("csv", "members_equity")); let rows = csv_rows( &csv, &[ "member", "beginning", "contributions", "net_income", "distributions", "other", "ending", ], ); let cols: Vec<_> = me .members .iter() .chain(std::iter::once(&me.total)) .collect(); assert_eq!(rows.len(), cols.len()); for (r, c) in rows.iter().zip(&cols) { assert_eq!(r[0], c.name); let got: Vec = r[1..].iter().map(|x| amt(x)).collect(); assert_eq!( got, vec![ c.beginning, c.contributions, c.net_income, c.distributions, c.other, c.ending ], "members' equity {}", c.name ); } let j: Value = serde_json::from_str(&m.export_text(range("json", "members_equity"))).unwrap(); let members = j["members"].as_array().unwrap(); assert_eq!(members.len(), me.members.len()); for (g, c) in members .iter() .chain(std::iter::once(&j["total"])) .zip(&cols) { assert_eq!(g["name"], c.name.as_str()); assert_eq!(jamt(&g["contributions"]), c.contributions); assert_eq!(jamt(&g["net_income"]), c.net_income); assert_eq!(jamt(&g["distributions"]), c.distributions); assert_eq!(jamt(&g["ending"]), c.ending); } } // ------------------------------------------------- binary formats and IIF /// XLSX for every report is a workbook holding the report's content; PDF /// for every statement is a PDF. Kills: export_xlsx's general_ledger and /// journal arms, layout_statement's tax_summary arm. #[test] fn xlsx_and_pdf_exports_are_real_files_with_the_books_in_them() { let dir = test_dir("xlsx-pdf"); let mut s = new_store(&dir); rich_books(&mut s); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let args = |format: &str, report: &str| json!({"format": format, "report": report, "start": START, "end": END, "as_of": END}); for report in [ "profit_and_loss", "balance_sheet", "cash_flow", "trial_balance", "members_equity", "tax_summary", ] { let pdf = m.export(args("pdf", report)); assert!(pdf.starts_with(b"%PDF-"), "{report} PDF"); let text = xlsx_text(&m.export(args("xlsx", report))); assert!( text.contains("Oracle Co"), "{report} XLSX names the company" ); } // The ledgers: by account and entry by entry, for the range only. let gl = xlsx_text(&m.export(args("xlsx", "general_ledger"))); for name in ["Bank", "Rent", "Depreciation Expense"] { assert!(gl.contains(name), "general ledger XLSX has {name}"); } for report in ["journal", "general_journal"] { let gj = xlsx_text(&m.export(args("xlsx", report))); assert!(gj.contains("Rent January"), "{report} XLSX has the rent"); assert!(gj.contains("Contractor work")); assert!( !gj.contains("Prior-year sale") && !gj.contains("After mid-year"), "{report} XLSX keeps to the range" ); assert!(!gj.contains("Never posted"), "{report} XLSX has no drafts"); } } /// IIF: accounts declared with QuickBooks types per the documented mapping /// (cash accounts BANK, 1200 AR, 2000 AP, a registered asset's accounts /// FIXASSET, the rest by type), then one balanced TRNS/SPL/ENDTRNS block /// per posted entry. Kills: export_iif's AR and AP comparisons. #[test] fn iif_declares_accounts_by_role_and_balances_every_transaction() { let dir = test_dir("iif"); let mut s = new_store(&dir); rich_books(&mut s); let posted = s.ledger().posted_entries().count(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let iif = String::from_utf8(m.export(json!({"format": "iif", "report": "journal"}))).unwrap(); let mut types: HashMap = HashMap::new(); let mut header: Vec<&str> = Vec::new(); let mut blocks = 0; let mut sum = Decimal::ZERO; let mut in_trns = false; for line in iif.lines() { let f: Vec<&str> = line.split('\t').collect(); match f[0] { "!ACCNT" => header = f.clone(), "ACCNT" => { let col = |name: &str| f[header.iter().position(|h| *h == name).unwrap()]; types.insert(col("ACCNUM").to_string(), col("ACCNTTYPE").to_string()); } "TRNS" | "SPL" => { if f[0] == "TRNS" { assert!(!in_trns, "TRNS inside a transaction"); in_trns = true; sum = Decimal::ZERO; } sum += dec(f[6]); } "ENDTRNS" => { assert!(in_trns); assert_eq!(sum, Decimal::ZERO, "an IIF transaction sums to zero"); in_trns = false; blocks += 1; } _ => {} } } assert_eq!(blocks, posted, "a transaction per posted entry"); for (code, ty) in [ ("1000", "BANK"), ("1200", "AR"), ("1300", "OCASSET"), ("1500", "FIXASSET"), ("1510", "FIXASSET"), ("2000", "AP"), ("2100", "OCLIAB"), ("3000", "EQUITY"), ("4000", "INC"), ("6000", "EXP"), ] { assert_eq!( types.get(code).map(String::as_str), Some(ty), "account {code}'s IIF type" ); } } // ------------------------------------------------- P&L by class /// Per-class revenue, expenses, and net equal `profit_and_loss_for_period` /// for the class; the unclassified row is the whole period's P&L less the /// classes (they partition it). Boundaries are inclusive. Kills: the date /// filter, the end-before-start check, the class filter, and the /// drop-empty-rows filter. #[test] fn pnl_by_class_equals_the_core_pnl_per_class() { let dir = test_dir("by-class"); let mut s = new_store(&dir); let r = rich_books(&mut s); let ledger = s.ledger().clone(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let check = |m: &mut Mcp, start: &str, end: &str| { let out = m.ok( "books.profit_and_loss_by_class", json!({"start": start, "end": end}), ); let total = ledger_core::profit_and_loss_for_period(&ledger, d(start), d(end), None).unwrap(); let mut want: Vec<(Option, Decimal, Decimal)> = Vec::new(); let (mut rev, mut exp) = (Decimal::ZERO, Decimal::ZERO); let mut classes: Vec<_> = [r.rondo, r.coach] .iter() .map(|c| ledger.get_class(*c).unwrap().clone()) .collect(); classes.sort_by(|a, b| a.name.cmp(&b.name)); for c in classes { let p = ledger_core::profit_and_loss_for_period(&ledger, d(start), d(end), Some(c.id)) .unwrap(); rev += p.total_revenue; exp += p.total_expenses; if !(p.total_revenue.is_zero() && p.total_expenses.is_zero()) { want.push((Some(c.name.clone()), p.total_revenue, p.total_expenses)); } } let (urev, uexp) = (total.total_revenue - rev, total.total_expenses - exp); if !(urev.is_zero() && uexp.is_zero()) { want.push((None, urev, uexp)); } let got: Vec<_> = out["classes"] .as_array() .unwrap() .iter() .map(|c| { let (rv, ex) = (jamt(&c["revenue"]), jamt(&c["expenses"])); assert_eq!(jamt(&c["net_income"]), rv - ex, "net = revenue - expenses"); (c["class"].as_str().map(String::from), rv, ex) }) .collect(); assert_eq!(got, want, "P&L by class {start}..{end}"); got }; // Whole year: Coach has revenue only, Rondo both, plus unclassified. let year = check(&mut m, "2025-01-01", "2025-12-31"); assert_eq!(year.len(), 3); // Boundaries on entry dates: the rent (Jan 15) and the Rondo sale // (Feb 1) are in; the prior-year sale and the Coach sale are out. let edge = check(&mut m, "2025-01-15", "2025-02-01"); assert_eq!(edge.len(), 1, "only Rondo moves in Jan 15..Feb 1"); // One day. let day = check(&mut m, "2025-02-10", "2025-02-10"); assert_eq!(day.len(), 1, "only Coach on Feb 10"); m.refuse( "books.profit_and_loss_by_class", json!({"start": "2025-02-02", "end": "2025-02-01"}), ); // One class: only its row. let only = m.ok( "books.profit_and_loss_by_class", json!({"start": "2025-01-01", "end": "2025-12-31", "class": "Coach"}), ); let rows = only["classes"].as_array().unwrap(); assert_eq!(rows.len(), 1, "class filter keeps one row: {only}"); assert_eq!(rows[0]["class"], "Coach"); let coach = ledger_core::profit_and_loss_for_period(&ledger, d(START), d("2025-12-31"), Some(r.coach)) .unwrap(); assert_eq!(jamt(&rows[0]["revenue"]), coach.total_revenue); } /// A period's P&L by class still shows what each class earned after the /// period is closed: closing entries are excluded, as in /// `profit_and_loss_for_period` (docs: "Closing entries are excluded, so a /// closed period still shows what it earned"). #[test] fn pnl_by_class_excludes_closing_entries() { let dir = test_dir("by-class-closed"); let mut s = new_store(&dir); let r = rich_books(&mut s); let p = s .create_period("2025-01", d("2025-01-01"), d("2025-01-31")) .unwrap(); s.close_period(p).unwrap(); let ledger = s.ledger().clone(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok( "books.profit_and_loss_by_class", json!({"start": "2025-01-01", "end": "2025-01-31"}), ); let rondo = ledger_core::profit_and_loss_for_period( &ledger, d("2025-01-01"), d("2025-01-31"), Some(r.rondo), ) .unwrap(); let all = ledger_core::profit_and_loss_for_period(&ledger, d("2025-01-01"), d("2025-01-31"), None) .unwrap(); // January: only the Rondo rent moves P&L; nothing is unclassified. assert_eq!(all.net_income(), rondo.net_income()); let rows = out["classes"].as_array().unwrap(); assert_eq!(rows.len(), 1, "only Rondo, no unclassified row: {out}"); assert_eq!(jamt(&rows[0]["expenses"]), rondo.total_expenses); } // ------------------------------------------------- 1099 /// The worksheet equals `vendor_1099_report`; `missing_w9` is the /// reportable vendors without a W-9; the untagged-entries note counts the /// year's posted entries with no vendor; the indexing note appears only /// after 2026. Kills: vendor_1099's year/tag filter and both note guards. #[test] fn vendor_1099_equals_the_core_report_and_counts_untagged_entries() { let dir = test_dir("1099"); let mut s = new_store(&dir); let contractor = rich_books(&mut s).contractor; // A 2027 payment to the contractor, tagged, so 2027 has no untagged. let e = draft( &mut s, "2027-03-01", "Contractor 2027", &[("6999", Dr, "2500.00"), ("1000", Cr, "2500.00")], ); s.set_entry_vendor(e, Some(contractor)).unwrap(); s.post_entry(e).unwrap(); let ledger = s.ledger().clone(); drop(s); let untagged = |year: i32| { ledger .posted_entries() .filter(|e| chrono::Datelike::year(&e.date()) == year) .filter(|e| ledger.entry_vendor(e.id()).is_none()) .count() }; assert!(untagged(2025) > 0 && untagged(2025) != untagged(2024)); let mut m = Mcp::start(&dir, "1000000.00"); for (year, threshold) in [(2025, None), (2026, None), (2027, Some("2000.00"))] { let mut args = json!({"year": year}); if let Some(t) = threshold { args["threshold"] = json!(t); } let out = m.ok("books.vendor_1099", args); let want = ledger_core::vendor_1099_report(&ledger, year, threshold.map(dec)).unwrap(); assert_eq!(out["year"], json!(want.year)); assert_eq!(jamt(&out["threshold"]), want.threshold); let vendors = out["vendors"].as_array().unwrap(); assert_eq!(vendors.len(), want.vendors.len(), "{year} vendors"); for (g, w) in vendors.iter().zip(&want.vendors) { assert_eq!(g["vendor_id"], json!(w.vendor_id)); assert_eq!(g["name"], w.name.as_str()); assert_eq!(jamt(&g["paid"]), w.paid, "{year} {}", w.name); assert_eq!(g["reportable"], json!(w.reportable)); assert_eq!(g["tin_on_file"], json!(w.tin_on_file)); } let missing: Vec = want .vendors .iter() .filter(|v| v.reportable && !v.tin_on_file) .map(|v| v.name.clone()) .collect(); assert_eq!(out["missing_w9"], json!(missing), "{year} missing W-9s"); let notes: Vec<&str> = out["notes"] .as_array() .unwrap() .iter() .map(|n| n.as_str().unwrap()) .collect(); let tag_note = notes.iter().find(|n| n.contains("no vendor tag")); match untagged(year) { 0 => assert!(tag_note.is_none(), "{year}: no untagged note: {notes:?}"), n => { let note = tag_note.unwrap_or_else(|| panic!("{year}: untagged note: {notes:?}")); let count: usize = note.split_whitespace().next().unwrap().parse().unwrap(); assert_eq!(count, n, "{year}: untagged count"); } } let indexed = notes.iter().any(|n| n.contains("indexed for inflation")); assert_eq!(indexed, year > 2026, "{year}: indexing note: {notes:?}"); } assert_eq!(untagged(2026), 0); assert_eq!(untagged(2027), 0); } // ------------------------------------------------- fixed assets /// Dispose previews: accumulated, book value, gain/loss, and final /// depreciation equal the store's disposal preview (`disposal_amounts` /// plus the final month); `receipt_required` is exactly "the disposal /// entry's debits exceed the threshold", the debits being accumulated + /// proceeds + any loss. Probed at thresholds one cent under and exactly at /// the debits, for a loss and a gain. Then a confirmed disposal posts /// the previewed lines. Kills: dispose_asset's debits arithmetic. #[test] fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() { let dir = test_dir("dispose"); let mut s = new_store(&dir); let reg = |s: &mut Store, desc: &str| { s.register_asset(NewAsset { description: desc.into(), purchase_date: d("2025-01-01"), cost: usd("1200.00"), salvage_value: usd("0.00"), useful_life_months: 12, cost_account: id_of(s, "1500"), accum_depr_account: id_of(s, "1510"), expense_account: id_of(s, "6500"), }) .unwrap() }; post( &mut s, "2025-01-01", "Two machines", &[("1500", Dr, "2400.00"), ("1000", Cr, "2400.00")], ); let loss = reg(&mut s, "Loss machine"); let gain = reg(&mut s, "Gain machine"); for p in ["2025-01", "2025-02", "2025-03"] { s.run_depreciation(loss, p).unwrap(); s.run_depreciation(gain, p).unwrap(); } let cases = [(loss, "300.00"), (gain, "1000.00")]; let mut want = Vec::new(); for (asset, proceeds) in cases { let p = s .dispose_preview(asset, d("2025-04-30"), usd(proceeds)) .unwrap(); let a = s.ledger().get_asset(asset).unwrap(); let core = ledger_core::disposal_amounts(a, d("2025-04-30"), usd(proceeds)).unwrap(); assert_eq!(p.book_value, core.book_value); assert_eq!(p.gain_loss, core.gain_loss); let debits = p.accumulated + dec(proceeds) + (-p.gain_loss).max(Decimal::ZERO); want.push((asset, proceeds, p, debits)); } assert!(want[0].2.gain_loss < Decimal::ZERO && want[1].2.gain_loss > Decimal::ZERO); let bank_before = s.ledger().account_balance(id_of(&s, "1000")).unwrap(); drop(s); let preview = |m: &mut Mcp, asset: u64, proceeds: &str| { m.ok( "books.dispose_asset", json!({"asset_id": asset, "date": "2025-04-30", "proceeds": proceeds, "proceeds_account": "1000", "gain_loss_account": "7100"}), ) }; let mut thresholds = Vec::new(); for (_, _, _, debits) in &want { thresholds.push(*debits - dec("0.01")); thresholds.push(*debits); } for t in thresholds { let mut m = Mcp::start(&dir, &t.to_string()); for (asset, proceeds, p, debits) in &want { let out = preview(&mut m, *asset, proceeds); assert_eq!(out["status"], "preview"); assert_eq!(jamt(&out["accumulated"]), p.accumulated); assert_eq!(jamt(&out["book_value"]), p.book_value); assert_eq!(jamt(&out["gain_loss"]), p.gain_loss); assert_eq!(jamt(&out["final_depreciation"]), p.final_depreciation); assert_eq!(jamt(&out["proceeds"]), dec(proceeds)); let line_debits: Decimal = out["lines"] .as_array() .unwrap() .iter() .filter(|l| l["side"] == "debit") .map(|l| jamt(&l["amount"])) .sum(); assert_eq!(line_debits, *debits, "the preview's debit lines"); assert_eq!( out["receipt_required"], json!(*debits > t), "asset {asset}, debits {debits}, threshold {t}" ); } } // Confirmed: posts the previewed amounts. let mut m = Mcp::start(&dir, "1000000.00"); let (asset, proceeds, p, _) = &want[0]; let out = m.ok( "books.dispose_asset", json!({"asset_id": asset, "date": "2025-04-30", "proceeds": proceeds, "proceeds_account": "1000", "gain_loss_account": "7100", "confirmed": true}), ); assert_eq!(out["status"], "posted"); let entry = out["entry_id"].as_u64().unwrap(); drop(m); let s = Store::open(&data_dir(&dir)).unwrap(); let l = s.ledger(); assert_eq!( l.get_entry(entry).unwrap().status(), EntryStatus::Posted, "the disposal is posted" ); assert_eq!( l.account_balance(id_of(&s, "7100")).unwrap(), -p.gain_loss, "a loss lands as expense" ); assert_eq!( l.account_balance(id_of(&s, "1000")).unwrap(), bank_before + dec(proceeds), "proceeds land in the bank" ); } /// Run-all depreciation skips an asset whose life doesn't touch the period /// and posts the others by their schedule. Kills: run_depreciation's /// period match. #[test] fn run_depreciation_skips_assets_outside_their_life() { let dir = test_dir("depr"); let mut s = new_store(&dir); let reg = |s: &mut Store, desc: &str, date: &str, months: u32| { s.register_asset(NewAsset { description: desc.into(), purchase_date: d(date), cost: usd("600.00"), salvage_value: usd("0.00"), useful_life_months: months, cost_account: id_of(s, "1500"), accum_depr_account: id_of(s, "1510"), expense_account: id_of(s, "6500"), }) .unwrap() }; let old = reg(&mut s, "Short-lived", "2025-01-01", 2); let new = reg(&mut s, "Long-lived", "2025-05-01", 12); let sched = depreciation_schedule(s.ledger().get_asset(new).unwrap(), d("2025-06-30")); let june = sched.iter().find(|l| l.period == "2025-06").unwrap().amount; assert!( !depreciation_schedule(s.ledger().get_asset(old).unwrap(), d("2025-06-30")) .iter() .any(|l| l.period == "2025-06") ); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok("books.run_depreciation", json!({"period": "2025-06"})); let runs = out["runs"].as_array().unwrap(); assert_eq!(runs.len(), 1, "{out}"); assert_eq!(runs[0]["asset_id"], json!(new)); assert_eq!(jamt(&runs[0]["amount"]), june); let skipped = out["skipped"].as_array().unwrap(); assert_eq!(skipped.len(), 1); assert_eq!(skipped[0]["asset_id"], json!(old)); } // ------------------------------------------------- payroll /// Remitting clears every withholding payable, and the reported total is /// what payroll put in them: gross − net + employer taxes. Kills: /// remit_payroll_taxes's total accumulation. #[test] fn remit_payroll_total_is_what_payroll_withheld_and_owed() { let dir = test_dir("payroll"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let lines = [ ( "1000.00", "100.00", "50.00", "62.00", "14.50", "62.00", "14.50", "5.00", ), ( "2000.00", "250.00", "80.00", "124.00", "29.00", "124.00", "29.00", "0.00", ), ]; let mut owed = Decimal::ZERO; let payroll_lines: Vec = lines .iter() .enumerate() .map(|(i, (g, fed, st, ss, med, sser, meder, other))| { owed += [fed, st, ss, med, sser, meder, other] .iter() .map(|x| dec(x)) .sum::(); json!({"employee_name": format!("E{i}"), "gross_pay": g, "federal_withholding": fed, "state_withholding": st, "ss_employee": ss, "medicare_employee": med, "ss_employer": sser, "medicare_employer": meder, "other_deductions": other}) }) .collect(); post_contribution(&mut m); m.ok( "books.run_payroll", json!({"date": "2025-02-15", "cash_account": "1000", "lines": payroll_lines}), ); let out = m.ok( "books.remit_payroll_taxes", json!({"date": "2025-02-20", "cash_account": "1000"}), ); assert_eq!(jamt(&out["total"]), owed); let entry = out["entry_id"].as_u64().unwrap(); drop(m); let s = Store::open(&data_dir(&dir)).unwrap(); let e = s.ledger().get_entry(entry).unwrap(); let cash = id_of(&s, "1000"); let paid: Decimal = e .lines() .iter() .filter(|l| l.account == cash && l.side == Cr) .map(|l| l.amount.amount) .sum(); assert_eq!(paid, owed, "the remittance pays out the total"); for code in ["2110", "2120", "2130", "2140", "2150"] { if let Some(a) = s.ledger().chart().find_by_code(code) { assert!( s.ledger().account_balance(a.id).unwrap().is_zero(), "{code}" ); } } } /// Owner contribution of 10000.00 to 1000 through the MCP flow. fn post_contribution(m: &mut Mcp) -> u64 { let e = m.ok( "books.draft_entry", json!({"date": "2025-01-02", "description": "Contribution"}), )["entry_id"] .as_u64() .unwrap(); for (a, side) in [("1000", "debit"), ("3000", "credit")] { m.ok( "books.add_line", json!({"entry_id": e, "account": a, "side": side, "amount": "10000.00"}), ); } m.ok( "books.attach_receipt", json!({"entry_id": e, "file_name": "c.txt", "mime_type": "text/plain", "content_base64": b64e(b"contribution")}), ); m.ok( "books.post_entry", json!({"entry_id": e, "confirmed": true}), ); e } // ------------------------------------------------- digest /// The digest lists exactly the posted entries dated on or after `since` /// (newest first), with their debits and receipt coverage per the policy /// (strictly above the threshold); per-account movement equals the trial /// balance difference across the period, on each account's normal side. /// Kills: digest's date filter and nonzero filter, movement_in. #[test] fn digest_equals_the_journal_since_a_date() { let dir = test_dir("digest"); let mut s = new_store(&dir); post( &mut s, "2025-02-28", "Before", &[("1000", Dr, "5000.00"), ("3000", Cr, "5000.00")], ); let on = post( &mut s, "2025-03-01", "On the day", &[("1000", Dr, "300.00"), ("4000", Cr, "300.00")], ); let after = post( &mut s, "2025-03-15", "After", &[("6000", Dr, "80"), ("2100", Cr, "80")], ); let big = post( &mut s, "2025-03-20", "Big", &[("6999", Dr, "150.00"), ("1000", Cr, "150.00")], ); s.attach_receipt(Some(on), "on.pdf", "application/pdf", b"on", None) .unwrap(); draft( &mut s, "2025-03-25", "Draft", &[("6999", Dr, "1.00"), ("1000", Cr, "1.00")], ); let ledger = s.ledger().clone(); let receipts: HashMap = ledger .posted_entries() .map(|e| (e.id(), s.list_attachments(Some(e.id())).unwrap().len())) .collect(); drop(s); let since = "2025-03-01"; let today = chrono::Local::now().date_naive(); let mut m = Mcp::start(&dir, "100.00"); let out = m.ok("books.digest", json!({"since": since})); assert_eq!(out["since"], since); let mut want: Vec<_> = ledger .posted_entries() .filter(|e| e.date() >= d(since)) .collect(); want.sort_by(|a, b| b.date().cmp(&a.date()).then(b.id().cmp(&a.id()))); let got = out["entries"].as_array().unwrap(); assert_eq!( got.iter() .map(|e| e["entry_id"].as_u64().unwrap()) .collect::>(), want.iter().map(|e| e.id()).collect::>(), "entries on or after since, newest first" ); assert_eq!(want.len(), 3); for (g, e) in got.iter().zip(&want) { let debits: Decimal = e .lines() .iter() .filter(|l| l.side == Dr) .map(|l| l.amount.amount) .sum(); assert_eq!(jamt(&g["total"]), debits); assert_eq!(g["date"], e.date().to_string()); assert_eq!(g["description"], e.description()); let count = receipts[&e.id()]; let required = debits > dec("100.00"); assert_eq!(g["receipt_count"], json!(count)); assert_eq!(g["receipt_required"], json!(required), "entry {}", e.id()); assert_eq!(g["receipt_missing"], json!(required && count == 0)); } assert!( got.iter() .any(|g| g["entry_id"] == json!(big) && g["receipt_missing"] == true) ); assert!(got.iter().any(|g| g["entry_id"] == json!(after))); // Movement: trial balance through today minus through the day before. let tb_net = |as_of: chrono::NaiveDate| -> HashMap { reports::trial_balance_as_of(&ledger, as_of) .lines .into_iter() .map(|l| (l.code, l.debit - l.credit)) .collect() }; let (now, before) = (tb_net(today), tb_net(d(since).pred_opt().unwrap())); let mut want: BTreeMap = BTreeMap::new(); for a in ledger.chart().all() { let net = now.get(&a.code).copied().unwrap_or_default() - before.get(&a.code).copied().unwrap_or_default(); let m = normal_signed(&ledger, a.id, Dr, net); if !m.is_zero() { want.insert(a.code.clone(), m); } } let got: BTreeMap = out["totals_by_account"] .as_array() .unwrap() .iter() .map(|t| { ( t["code"].as_str().unwrap().to_string(), jamt(&t["movement"]), ) }) .collect(); assert_eq!(got, want, "movement per account"); assert!(want.values().any(|v| *v > Decimal::ZERO) && want.contains_key("2100")); } /// A torn journal tail set aside on open shows in the digest for any /// `since` on or before the repair, and not after. Kills: digest's /// journal_repairs filter. #[test] fn digest_reports_journal_repairs_since_the_date() { let dir = test_dir("repair"); let mut s = new_store(&dir); post( &mut s, "2025-01-02", "Seed", &[("1000", Dr, "1.00"), ("4000", Cr, "1.00")], ); drop(s); let journal = data_dir(&dir).join(ledger_store::JOURNAL_FILE); let mut f = std::fs::OpenOptions::new() .append(true) .open(&journal) .unwrap(); f.write_all(b"{\"seq\": 999, \"torn").unwrap(); drop(f); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok("books.digest", json!({"since": "2025-01-01"})); let repairs = out["journal_repairs"].as_array().unwrap(); drop(m); let s = Store::open(&data_dir(&dir)).unwrap(); let want = s.tail_repairs().unwrap(); drop(s); assert_eq!(want.len(), 1, "the store set the torn tail aside"); assert_eq!(repairs.len(), 1, "the digest reports it: {out}"); assert_eq!(repairs[0]["at"], want[0].0.as_str()); assert_eq!(repairs[0]["bytes"], json!(want[0].1)); let mut m = Mcp::start(&dir, "1000000.00"); let later = m.ok("books.digest", json!({"since": "2999-01-01"})); assert_eq!(later["journal_repairs"], json!([]), "none since 2999"); } // ------------------------------------------------- the viewer /// `books.show` writes the viewer command for exactly the target asked /// for; bad report columns, comparisons, and pages are refused, good ones /// accepted. Kills: show's validations, the attachment lookup, and the /// columns description. #[test] fn show_writes_the_viewer_command_for_each_target() { let dir = test_dir("show"); let mut s = new_store(&dir); let e = post( &mut s, "2025-01-02", "Seed", &[("1000", Dr, "1.00"), ("4000", Cr, "1.00")], ); let (a1, _, _) = s .attach_receipt( Some(e), "alpha-receipt.pdf", "application/pdf", b"one", None, ) .unwrap(); let (a2, _, _) = s .attach_receipt(None, "omega-receipt.pdf", "application/pdf", b"two", None) .unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); m.ok("books.show", json!({"entry_id": e})); assert_eq!( m.viewer_command().target, ViewerTarget::Entry { entry_id: e } ); m.refuse("books.show", json!({"entry_id": 999})); m.ok( "books.show", json!({"account": "1000", "start": "2025-01-01", "end": "2025-01-31"}), ); assert_eq!( m.viewer_command().target, ViewerTarget::Account { code: "1000".into(), start: Some("2025-01-01".into()), end: Some("2025-01-31".into()) } ); for (columns, compare) in [ ("month", "prior_year"), ("quarter", "prior_period"), ("year", "prior_year"), ("total", "prior_period"), ] { let out = m.ok( "books.show", json!({"report": "profit_and_loss", "start": "2025-01-01", "end": "2025-12-31", "columns": columns, "compare": compare}), ); assert_eq!( m.viewer_command().target, ViewerTarget::Report { report: "profit_and_loss".into(), start: Some("2025-01-01".into()), end: Some("2025-12-31".into()), columns: Some(columns.into()), compare: Some(compare.into()), } ); if columns != "total" { assert!( out["shown"].as_str().unwrap().contains(columns), "shown names the {columns} columns: {out}" ); } } m.refuse( "books.show", json!({"report": "profit_and_loss", "columns": "weekly"}), ); m.refuse( "books.show", json!({"report": "profit_and_loss", "compare": "prior_decade"}), ); m.refuse("books.show", json!({"report": "horoscope"})); for (id, name, other) in [ (a1, "alpha-receipt.pdf", "omega-receipt.pdf"), (a2, "omega-receipt.pdf", "alpha-receipt.pdf"), ] { let out = m.ok("books.show", json!({"attachment_id": id})); assert_eq!( m.viewer_command().target, ViewerTarget::Document { attachment_id: id } ); let shown = out["shown"].as_str().unwrap(); assert!( shown.contains(name) && !shown.contains(other), "shown names attachment {id}: {shown}" ); } m.ok("books.show", json!({"search": "coffee"})); assert_eq!( m.viewer_command().target, ViewerTarget::Search { query: "coffee".into() } ); for page in [ "overview", "cash", "profit", "transactions", "reports", "accounts", "documents", "company", "journal", ] { m.ok("books.show", json!({"page": page})); assert_eq!( m.viewer_command().target, ViewerTarget::Page { page: page.into() } ); } m.refuse("books.show", json!({"page": "settings"})); m.refuse("books.show", json!({})); } // ------------------------------------------------- resources /// Each advertised resource reads back as the matching tool's result, and /// receipts resolve by id; an unknown URI is "not found". Kills: /// resource_text's balance-sheet, profit-and-loss, digest, and receipts /// arms. #[test] fn resources_read_back_as_the_matching_tools() { let dir = test_dir("resources"); let mut s = new_store(&dir); rich_books(&mut s); let (r1, _, _) = s .attach_receipt(None, "first.txt", "text/plain", b"first receipt", None) .unwrap(); let (r2, _, _) = s .attach_receipt(None, "second.txt", "text/plain", b"second receipt", None) .unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let list = m.rpc("resources/list", json!({})); let uris: Vec<&str> = list["result"]["resources"] .as_array() .unwrap() .iter() .map(|r| r["uri"].as_str().unwrap()) .collect(); for u in [ "books://chart", "books://reports/trial-balance", "books://reports/balance-sheet", "books://reports/profit-and-loss", "books://digest", ] { assert!(uris.contains(&u), "resources/list has {u}"); } let read = |m: &mut Mcp, uri: &str| -> Value { let r = m.rpc("resources/read", json!({"uri": uri})); let text = r["result"]["contents"][0]["text"] .as_str() .unwrap_or_else(|| panic!("{uri}: {r}")); serde_json::from_str(text).unwrap() }; for (uri, tool, args) in [ ("books://chart", "books.chart", json!({})), ( "books://reports/trial-balance", "books.trial_balance", json!({}), ), ( "books://reports/balance-sheet", "books.balance_sheet", json!({}), ), ( "books://reports/profit-and-loss", "books.profit_and_loss", json!({}), ), ( "books://digest?since=2025-03-01", "books.digest", json!({"since": "2025-03-01"}), ), ] { let got = read(&mut m, uri); let want = m.ok(tool, args); assert_eq!(got, want, "{uri} reads as {tool}"); } for (id, bytes) in [(r1, &b"first receipt"[..]), (r2, &b"second receipt"[..])] { let got = read(&mut m, &format!("books://receipts/{id}")); assert_eq!(got["attachment_id"], json!(id)); assert_eq!(b64d(got["content_base64"].as_str().unwrap()), bytes); assert_eq!(got["sha256"], sha256(bytes)); } let r = m.rpc("resources/read", json!({"uri": "books://nope"})); let err = r.get("error").unwrap_or_else(|| panic!("unknown URI: {r}")); assert!( err.to_string().contains("unknown resource"), "an unknown URI is reported as unknown, not as a bad receipt: {err}" ); } // ------------------------------------------------- files in and out /// Receipts and documents round-trip: the bytes come back exactly (SHA-256 /// of what was sent), with the name, MIME type, category, entry, and /// description given. Kills: get_receipt's lookup, import_local_file's /// document arm. #[test] fn receipts_and_documents_round_trip() { let dir = test_dir("files"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let e = post_contribution(&mut m); let first: &[u8] = b"%PDF-1.4 first"; let second: &[u8] = b"\x89PNG second \x00\xff"; let a = m.ok( "books.attach_receipt", json!({"entry_id": e, "file_name": "first.pdf", "mime_type": "application/pdf", "content_base64": b64e(first), "description": "Receipt ID 1007; Paid from: Bluevine"}), ); let b = m.ok( "books.file_document", json!({"category": "legal", "file_name": "oa.png", "mime_type": "image/png", "content_base64": b64e(second), "description": "Operating agreement"}), ); let local_doc = dir.join("ein-letter.pdf"); std::fs::write(&local_doc, b"EIN letter bytes").unwrap(); let c = m.ok( "books.import_local_file", json!({"path": local_doc.to_str().unwrap(), "category": "document", "document_category": "tax", "description": "IRS CP 575"}), ); assert_eq!(c["file_name"], "ein-letter.pdf"); let local_receipt = dir.join("scan.png"); std::fs::write(&local_receipt, b"a scanned receipt").unwrap(); let r = m.ok( "books.import_local_file", json!({"path": local_receipt.to_str().unwrap(), "category": "receipt", "entry_id": e, "file_name": "renamed.png", "description": "scan"}), ); assert_eq!(r["file_name"], "renamed.png"); m.refuse( "books.import_local_file", json!({"path": local_receipt.to_str().unwrap(), "category": "photo"}), ); // (result, bytes, file name, MIME type, entry, description) type Case<'a> = (&'a Value, &'a [u8], &'a str, &'a str, Option, &'a str); let cases: [Case<'_>; 4] = [ ( &a, first, "first.pdf", "application/pdf", Some(e), "Receipt ID 1007; Paid from: Bluevine", ), ( &b, second, "oa.png", "image/png", None, "Operating agreement", ), ( &c, b"EIN letter bytes", "ein-letter.pdf", "application/pdf", None, "IRS CP 575", ), ( &r, b"a scanned receipt", "renamed.png", "image/png", Some(e), "scan", ), ]; let listed = m.ok("books.list_receipts", json!({}))["receipts"].clone(); for (out, bytes, name, mime, entry, desc) in cases { let id = out["attachment_id"].as_u64().unwrap(); assert_eq!(out["sha256"], sha256(bytes), "{name}: sha256 of the bytes"); let got = m.ok("books.get_receipt", json!({"attachment_id": id})); assert_eq!(got["attachment_id"], json!(id)); assert_eq!( b64d(got["content_base64"].as_str().unwrap()), bytes, "{name}" ); assert_eq!(got["sha256"], sha256(bytes)); assert_eq!(got["file_name"], name); assert_eq!(got["mime_type"], mime); assert_eq!(got["entry_id"], json!(entry)); assert_eq!(got["size_bytes"], json!(bytes.len())); let row = listed .as_array() .unwrap() .iter() .find(|x| x["attachment_id"] == json!(id)) .unwrap(); assert_eq!(row["description"], desc, "{name}: description as given"); } let mut cat = |out: &Value| { m.ok( "books.get_receipt", json!({"attachment_id": out["attachment_id"]}), )["category"] .clone() }; assert_eq!(cat(&b), "legal"); assert_eq!(cat(&c), "tax"); m.refuse("books.get_receipt", json!({"attachment_id": 999})); } /// 25 MB (MiB) is the most a receipt or document may be: exactly the /// limit is accepted through every way in, one byte more refused (checked /// on the local-file path, where it costs no upload). Kills: the limit /// comparisons in attach_receipt, file_document, and import_local_file, /// and import_local_file's limit arithmetic (2 MiB must pass). #[test] fn uploads_up_to_25_mib_are_accepted_and_no_more() { const LIMIT: usize = 25 * 1024 * 1024; let dir = test_dir("limits"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let at = vec![b'a'; LIMIT]; let out = m.ok( "books.attach_receipt", json!({"file_name": "big.bin", "mime_type": "application/octet-stream", "content_base64": b64e(&at)}), ); let at_id = out["attachment_id"].as_u64().unwrap(); let doc = vec![b'b'; LIMIT]; let out = m.ok( "books.file_document", json!({"category": "other", "file_name": "big.bin", "mime_type": "application/octet-stream", "content_base64": b64e(&doc)}), ); let doc_id = out["attachment_id"].as_u64().unwrap(); let listed = m.ok("books.list_receipts", json!({})); for id in [at_id, doc_id] { let row = listed["receipts"] .as_array() .unwrap() .iter() .find(|r| r["attachment_id"] == json!(id)) .unwrap() .clone(); assert_eq!(row["size_bytes"], json!(LIMIT), "stored whole"); } for (n, accepted) in [(2 * 1024 * 1024, true), (LIMIT, true), (LIMIT + 1, false)] { let path = dir.join(format!("local-{n}.bin")); std::fs::write(&path, vec![b'c' ^ (n % 7) as u8; n]).unwrap(); let args = json!({"path": path.to_str().unwrap(), "category": "receipt"}); if accepted { m.ok("books.import_local_file", args); } else { m.refuse("books.import_local_file", args); } std::fs::remove_file(&path).unwrap(); } } /// Bank CSV import: money in debits the bank, money out credits it; the /// longest matching rule picks the offset, else the default; day-first /// dates read as given; amounts come back signed. Kills: import_bank_csv's /// side choice. #[test] fn bank_import_puts_money_in_and_out_on_the_right_sides() { let dir = test_dir("bank"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok( "books.import_bank_csv", json!({ "csv_text": "Date,Description,Amount\n\ 03/04/2025,Corner Coffee Shop,-4.50\n\ 13/04/2025,Client payment,250.00\n\ 20/04/2025,STARBUCKS COFFEE #12,-6.00\n", "bank_account_code": "1000", "default_offset_code": "4000", "rules": {"coffee": "6010", "starbucks coffee": "6020"}, "date_order": "day_first", }), ); let want = [ ("2025-04-03", "Corner Coffee Shop", "-4.50", "6010"), ("2025-04-13", "Client payment", "250.00", "4000"), ("2025-04-20", "STARBUCKS COFFEE #12", "-6.00", "6020"), ]; let imported = out["imported"].as_array().unwrap(); assert_eq!(out["count"], json!(want.len())); assert_eq!(imported.len(), want.len()); for (g, (date, desc, amount, offset)) in imported.iter().zip(want) { assert_eq!(g["date"], date); assert_eq!(g["description"], desc); assert_eq!(jamt(&g["amount"]), dec(amount)); assert_eq!(g["offset_account"], offset); let e = m.ok("books.get_entry", json!({"entry_id": g["entry_id"]})); assert_eq!(e["status"], "draft"); assert_eq!(e["date"], date); let money_in = dec(amount) > Decimal::ZERO; let side_of = |code: &str| { let l = e["lines"] .as_array() .unwrap() .iter() .find(|l| l["code"] == code) .unwrap_or_else(|| panic!("line on {code}: {e}")); assert_eq!(jamt(&l["amount"]), dec(amount).abs()); l["side"].as_str().unwrap().to_string() }; assert_eq!( side_of("1000"), if money_in { "debit" } else { "credit" }, "{desc}: bank side" ); assert_eq!( side_of(offset), if money_in { "credit" } else { "debit" }, "{desc}: offset side" ); } } // ------------------------------------------------- references and lines /// References (INVARIANT 6): a number that is one account's code and the /// same account's id resolves; a vendor's or class's id that is another's /// name is refused; a vendor or class named by its own id resolves; an /// unknown vendor is reported as unknown. Kills: resolve_account's, resolve_vendor's, and /// resolve_class's ambiguity guards, resolve_vendor's unknown arm. #[test] fn references_resolve_unless_truly_ambiguous() { let dir = test_dir("refs"); let s = Store::create(&data_dir(&dir), Currency::USD).unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); // An account whose code is its own id. let first = m.ok( "books.add_account", json!({"code": "1000", "name": "Bank", "account_type": "asset"}), )["account_id"] .as_u64() .unwrap(); m.ok( "books.renumber_account", json!({"account": "1000", "code": first.to_string()}), ); m.ok( "books.add_account", json!({"code": "4000", "name": "Revenue", "account_type": "revenue"}), ); let e = m.ok( "books.draft_entry", json!({"date": "2025-01-02", "description": "Sale"}), )["entry_id"] .as_u64() .unwrap(); m.ok( "books.add_line", json!({"entry_id": e, "account": first.to_string(), "side": "debit", "amount": "5.00"}), ); // A class named by its own id. let c = m.ok("books.add_class", json!({"name": "placeholder-name"}))["id"] .as_u64() .unwrap(); let c2 = m.ok("books.add_class", json!({"name": c.to_string()}))["id"] .as_u64() .unwrap(); // "c" is class c's id and class c2's name: ambiguous. m.refuse( "books.add_line", json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00", "class": c.to_string()}), ); // c2 named by its own id: only one class is meant. let c3 = m.ok("books.add_class", json!({"name": (c2 + 1).to_string()}))["id"] .as_u64() .unwrap(); assert_eq!(c3, c2 + 1, "classes number in order"); m.ok( "books.add_line", json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00", "class": c3.to_string()}), ); // Vendors: id 1 is "Alpha"; "1" is another vendor's name. let v1 = m.ok("books.add_vendor", json!({"name": "Alpha"}))["id"] .as_u64() .unwrap(); let v2 = m.ok("books.add_vendor", json!({"name": v1.to_string()}))["id"] .as_u64() .unwrap(); assert_ne!(v1, v2); m.refuse( "books.set_entry_vendor", json!({"entry_id": e, "vendor": v1.to_string()}), ); m.ok( "books.set_entry_vendor", json!({"entry_id": e, "vendor": v2.to_string()}), ); let got = m.ok("books.get_entry", json!({"entry_id": e})); assert!(!got["vendor"].is_null(), "tagged: {got}"); // A vendor named by its own id: only one vendor is meant. let v3 = m.ok("books.add_vendor", json!({"name": (v2 + 1).to_string()}))["id"] .as_u64() .unwrap(); assert_eq!(v3, v2 + 1, "vendors number in order"); m.ok( "books.set_entry_vendor", json!({"entry_id": e, "vendor": v3.to_string()}), ); let err = m.refuse( "books.set_entry_vendor", json!({"entry_id": e, "vendor": "Nobody Inc"}), ); assert!(err.contains("unknown vendor"), "{err}"); } /// A foreign-currency line needs all three of currency, amount, and rate: /// any one of them alone is refused rather than read as a base line. Kills: /// add_line's fx detection. #[test] fn a_partial_foreign_currency_line_is_refused() { let dir = test_dir("fx"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let e = m.ok( "books.draft_entry", json!({"date": "2025-01-02", "description": "EUR"}), )["entry_id"] .as_u64() .unwrap(); for partial in [ json!({"foreign_currency": "EUR"}), json!({"foreign_amount": "10.00"}), json!({"fx_rate": "1.10"}), json!({"foreign_amount": "10.00", "fx_rate": "1.10"}), ] { let mut args = json!({"entry_id": e, "account": "6999", "side": "debit", "amount": "11.00"}); for (k, v) in partial.as_object().unwrap() { args[k] = v.clone(); } m.refuse("books.add_line", args); } let got = m.ok("books.get_entry", json!({"entry_id": e})); assert_eq!(got["lines"], json!([]), "nothing was added"); m.ok( "books.add_line", json!({"entry_id": e, "account": "6999", "side": "debit", "amount": "11.00", "foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}), ); let got = m.ok("books.get_entry", json!({"entry_id": e})); assert_eq!(jamt(&got["lines"][0]["amount"]), dec("11.00")); } /// Post and reverse previews show each line's balance before and after on /// the account's normal side: a credit to an asset lowers it. Kills: /// preview_lines_for's sign. #[test] fn previews_move_balances_by_normal_side() { let dir = test_dir("preview"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let c = post_contribution(&mut m); let e = m.ok( "books.draft_entry", json!({"date": "2025-01-05", "description": "Rent"}), )["entry_id"] .as_u64() .unwrap(); for (a, side) in [("6000", "debit"), ("1000", "credit")] { m.ok( "books.add_line", json!({"entry_id": e, "account": a, "side": side, "amount": "1200"}), ); } let p = m.ok("books.post_entry", json!({"entry_id": e})); assert_eq!(p["status"], "preview"); let line = |p: &Value, code: &str| -> (Decimal, Decimal, Decimal) { let l = p["lines"] .as_array() .unwrap() .iter() .find(|l| l["code"] == code) .unwrap() .clone(); ( jamt(&l["amount"]), jamt(&l["balance_before"]), jamt(&l["balance_after"]), ) }; assert_eq!( line(&p, "1000"), (dec("1200"), dec("10000"), dec("8800")), "credit to an asset" ); assert_eq!(line(&p, "6000"), (dec("1200"), dec("0"), dec("1200"))); // Reversing the contribution: debit equity, credit the bank. let r = m.ok( "books.reverse_entry", json!({"entry_id": c, "date": "2025-01-06", "description": "Undo"}), ); assert_eq!( line(&r, "1000"), (dec("10000"), dec("10000"), dec("0")), "reversal credits the bank" ); assert_eq!(line(&r, "3000"), (dec("10000"), dec("10000"), dec("0"))); } // ------------------------------------------------- CPA package /// The CPA package holds the documented files for the fiscal year to date; /// the receipts behind the year's posted entries (and only those), indexed /// with each entry's date, description, and debits; the company's /// documents but not loose receipts or withdrawn files; a README counting /// the receipts; and a 1099 worksheet equal to `vendor_1099_report`. With /// include_receipts false, no evidence. Kills: export_cpa_package's /// receipt and document guards, the receipt count, the file-name cleaner. #[test] fn cpa_package_holds_the_year_and_its_evidence() { let dir = test_dir("cpa"); let mut s = new_store(&dir); let r = rich_books(&mut s); // Two receipts on the rent, same-length names. let rent_a: &[u8] = b"rent receipt A"; let rent_b: &[u8] = b"rent receipt B"; let (ra, _, _) = s .attach_receipt( Some(r.rent), "receipt-a.pdf", "application/pdf", rent_a, None, ) .unwrap(); let (rb, _, _) = s .attach_receipt( Some(r.rent), "receipt-b.pdf", "application/pdf", rent_b, None, ) .unwrap(); for (e, name) in [ (r.prior, "old.pdf"), (r.after, "later.pdf"), (r.never, "draft.pdf"), ] { s.attach_receipt(Some(e), name, "application/pdf", name.as_bytes(), None) .unwrap(); } s.attach_receipt(None, "loose.pdf", "application/pdf", b"loose", None) .unwrap(); s.file_document( "legal", "agreement.pdf", "application/pdf", b"agreement", None, ) .unwrap(); let (gone, _, _) = s .file_document( "tax", "withdrawn.pdf", "application/pdf", b"withdrawn", None, ) .unwrap(); s.withdraw_attachment(gone, "filed by mistake").unwrap(); let ledger = s.ledger().clone(); let shas: HashMap = s .list_attachments(None) .unwrap() .into_iter() .map(|a| (a.id, a.blob_sha)) .collect(); drop(s); let rent = ledger.get_entry(r.rent).unwrap(); let rent_debits: Decimal = rent .lines() .iter() .filter(|l| l.side == Dr) .map(|l| l.amount.amount) .sum(); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok( "books.export_cpa_package", json!({"as_of": END, "cash_accounts": ["1000"]}), ); assert_eq!(out["file_name"], format!("cpa-package-{END}.zip")); let files = unzip(&b64d(out["content_base64"].as_str().unwrap())); let names: Vec<&str> = files.keys().map(String::as_str).collect(); let mut want = vec![ "README.txt".to_string(), "general-ledger.xlsx".into(), "general-journal.xlsx".into(), "general-journal.iif".into(), "1099-worksheet-2025.csv".into(), "receipts/index.csv".into(), ]; for st in [ "profit-and-loss", "balance-sheet", "cash-flow", "members-equity", "tax-summary", "trial-balance", ] { want.push(format!("{st}.pdf")); want.push(format!("{st}.xlsx")); } for w in &want { assert!(files.contains_key(w), "package has {w}: {names:?}"); } for st in ["profit-and-loss", "balance-sheet", "trial-balance"] { assert!(files[&format!("{st}.pdf")].starts_with(b"%PDF-")); xlsx_text(&files[&format!("{st}.xlsx")]); } let gj = xlsx_text(&files["general-journal.xlsx"]); assert!(gj.contains("Rent January") && !gj.contains("Prior-year sale")); assert!(!gj.contains("After mid-year"), "fiscal year to as_of only"); // Receipts: exactly the two behind the rent. let receipt_files: Vec<&str> = names .iter() .copied() .filter(|n| n.starts_with("receipts/") && *n != "receipts/index.csv") .collect(); assert_eq!(receipt_files.len(), 2, "{receipt_files:?}"); for (id, name, bytes) in [(ra, "receipt-a.pdf", rent_a), (rb, "receipt-b.pdf", rent_b)] { let f = receipt_files .iter() .find(|n| n.ends_with(name)) .unwrap_or_else(|| panic!("{name} is in receipts/: {receipt_files:?}")); assert_eq!(files[*f], bytes); assert_eq!(sha256(&files[*f]), shas[&id]); } let index = csv_rows( std::str::from_utf8(&files["receipts/index.csv"]).unwrap(), &[ "entry_id", "date", "description", "amount", "file", "sha256", ], ); assert_eq!(index.len(), 2); for row in &index { assert_eq!(row[0], r.rent.to_string()); assert_eq!(row[1], rent.date().to_string()); assert_eq!(row[2], rent.description()); assert_eq!(amt(&row[3]), rent_debits); assert!(files.contains_key(&row[4]), "index names a file in the zip"); assert_eq!(sha256(&files[&row[4]]), row[5]); } // Documents: the agreement, not the loose receipt or the withdrawn. let docs: Vec<&str> = names .iter() .copied() .filter(|n| n.starts_with("documents/")) .collect(); assert_eq!(docs.len(), 1, "{docs:?}"); assert!(docs[0].starts_with("documents/legal/") && docs[0].ends_with("agreement.pdf")); assert_eq!(files[docs[0]], b"agreement"); assert!( !names .iter() .any(|n| n.contains("loose") || n.contains("withdrawn")) ); let readme = String::from_utf8(files["README.txt"].clone()).unwrap(); assert!( readme.contains("the 2 receipts"), "README counts the receipts: {readme}" ); // 1099 worksheet. let w = csv_rows( std::str::from_utf8(&files["1099-worksheet-2025.csv"]).unwrap(), &["vendor", "form", "w9_on_file", "paid", "reportable"], ); let want = ledger_core::vendor_1099_report(&ledger, 2025, None).unwrap(); assert_eq!(w.len(), want.vendors.len()); for (row, v) in w.iter().zip(&want.vendors) { assert_eq!(row[0], v.name); assert_eq!(row[1], v.form.map(|f| f.label()).unwrap_or("")); assert_eq!(row[2], v.tin_on_file.to_string()); assert_eq!(amt(&row[3]), v.paid); assert_eq!(row[4], v.reportable.to_string()); } // Without receipts: no evidence at all. let out = m.ok( "books.export_cpa_package", json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false}), ); let files = unzip(&b64d(out["content_base64"].as_str().unwrap())); assert!( !files .keys() .any(|n| n.starts_with("receipts/") || n.starts_with("documents/")), "{:?}", files.keys() ); assert!(files.contains_key("1099-worksheet-2025.csv")); }