diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 25dab96..891b4bc 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -3666,8 +3666,12 @@ impl BooksServer { expenses: Decimal, } let mut by_class: HashMap, Acc> = HashMap::new(); + // A closing entry isn't income or spending (rules.md B-11): left + // out, as profit_and_loss_for_period leaves it out (found by + // pnl_by_class_excludes_closing_entries, 2026-10-14). + let closings = ledger.closing_entry_ids(); for entry in ledger.posted_entries() { - if entry.date() < start || entry.date() > end { + if entry.date() < start || entry.date() > end || closings.contains(&entry.id()) { continue; } for line in entry.lines() { diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs new file mode 100644 index 0000000..db6a4bb --- /dev/null +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -0,0 +1,2721 @@ +//! 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")); +}