//! Oracle tests for the books server's tools, driven through a real //! `wanderbooks server` as an agent reaches it (common.rs). //! //! 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), in the books' //! folder of a data root of the test's own; then the store is closed and //! the server started on that root. use common::{Place, Wire}; use ledger_core::{ AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital, Money, NewAsset, Side, depreciation_schedule, reports, }; use ledger_store::Store; use ledger_store::ops::{ViewerCommand, ViewerTarget}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashMap}; use std::io::{Read, Write}; use std::path::PathBuf; use std::str::FromStr; mod common; // ---------------------------------------------------------------- harness #[derive(Debug)] enum Out { Ok(Value), ToolErr(String), RpcErr(Value), } struct Mcp { wire: Wire, next_id: u64, /// The books' folder in the data root. data: PathBuf, } /// A test's place (common.rs): its home, its server, its books. type TestDir = Place; fn test_dir(name: &str) -> TestDir { Place::new(&format!("oracle-{name}")) } /// `wanderbooks.toml` for two 50/50 members on 3000/3001, cash 1000, /// financing 2100, with the given receipt threshold. fn books_toml(threshold: &str) -> String { 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" ) } /// The books' folder in `dir`'s data root, where the oracle writes the /// books the server then opens. fn data_dir(dir: &TestDir) -> PathBuf { dir.books_data() } impl Mcp { /// Start (or restart) `dir`'s server, and a session whose books keep /// [`books_toml`]'s settings with this receipt threshold (set as an /// agent sets them: a value already set changes nothing). fn start(dir: &TestDir, threshold: &str) -> Self { let wire = dir.start(None); let mut m = Self { wire, next_id: 1, data: dir.books_data(), }; 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.ok( "books.set_setting", json!({"changes": common::settings_changes(&books_toml(threshold), None, "USD"), "decided_by": "Test Owner"}), ); m } fn send(&mut self, line: &str) { self.wire.send_line(line); } 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(), ); let v = self .wire .recv(common::TIMEOUT) .unwrap_or_else(|| panic!("no response to {method}: {:?}", self.wire.refused)); assert_eq!(v["id"], json!(id)); v } fn tool(&mut self, name: &str, args: Value) -> Out { let resp = common::tools_call(|m, p| self.rpc(m, p), name, args.clone()); 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`'s file: handed over (D-46), fetched by its /// download, never its bytes in the answer. fn export(&mut self, args: Value) -> Vec { let out = self.ok("books.export", args.clone()); assert!(out.get("content").is_none(), "{args} came back inline"); self.handed(&out["file"]) } fn export_text(&mut self, args: Value) -> String { String::from_utf8(self.export(args)).expect("a text export") } /// Upload `bytes` as an agent does (D-46): the upload_id. fn upload(&mut self, bytes: &[u8], file_name: &str, mime_type: &str, purpose: &str) -> String { self.wire.upload(bytes, file_name, mime_type, purpose) } /// The viewer command the last `books.show` wrote, in the books' own /// `ops/`. fn viewer_command(&self) -> ViewerCommand { ledger_store::ops::read(&self.data).expect("a show request") } /// The bytes of a file a tool handed over (a `HandedFile`): fetched by /// its download, matching the size and SHA-256 it states. fn handed(&mut self, file: &Value) -> Vec { assert!(file["expires_at"].is_string(), "{file}"); self.wire.fetch(file) } } // ---------------------------------------------------------------- helpers 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: &TestDir) -> 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, &decided(Form1099::Misc), Some(true)) .unwrap(); s.set_vendor_1099(contractor, &decided(Form1099::Nec), Some(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(), in_service_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"; /// A 1099 decision from the beginning, decided by the owner. fn decided(form: Form1099) -> ledger_core::Dated1099 { ledger_core::Dated1099::from_beginning( ledger_core::Decision1099::Files(form), Some(ledger_core::settings::Source { decided_by: "Oracle Owner".into(), decided_on: d("2025-01-01"), citation: None, note: None, }), ) } /// The 1099 report the server makes for these books: payments out of /// cash 1000 and financing 2100 (books_toml), no member parties (the /// rich books name none, so no shares resolve against members). fn core_1099( ledger: &Ledger, year: i32, threshold: Option, ) -> ledger_core::Vendor1099Report { core_1099_through(ledger, year, threshold, None) } /// [`core_1099`], payments dated after `through` left out. fn core_1099_through( ledger: &Ledger, year: i32, threshold: Option, through: Option, ) -> ledger_core::Vendor1099Report { let id = |code: &str| ledger.chart().find_by_code(code).unwrap().id; ledger_core::vendor_1099_report( ledger, year, threshold, &ledger_core::PaymentAccounts { cash: vec![id("1000")], financing: vec![id("2100")], }, &[], through, ) .unwrap() } // ------------------------------------------------- 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 and members, field for field (rules.md B-14; the JSON is /// the tool's own answer). Kills: export_cash_flow stubs and its json/csv /// switch, and any field the mapping drops. #[test] fn cash_flow_exports_equal_the_core_statement() { let dir = test_dir("cf"); let mut s = new_store(&dir); rich_books(&mut s); // Equipment on an operating payable: a warning, in the CSV too. post( &mut s, "2025-04-02", "Monitor on account", &[("1500", Dr, "250.00"), ("2000", Cr, "250.00")], ); let member = |name: &str, code: &str| ledger_core::MemberCapital { name: name.into(), capital_account: id_of(&s, code), distribution_account: None, share: Decimal::new(5, 1), }; let members = vec![member("A", "3000"), member("B", "3001")]; 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![], members, }, ) .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"]}) }; use ledger_core::CashFlowSection as S; let csv = m.export_text(args("csv")); let rows = csv_rows( &csv, &[ "section", "kind", "class", "entry", "date", "label", "amount", "on_behalf", ], ); let row = |sec: &str, kind: &str, class: &str, entry: String, date: String, label: &str, v| { ( sec.to_string(), kind.to_string(), class.to_string(), entry, date, label.to_string(), v, None, ) }; let mut want = Vec::new(); for (sec, total) in [ (S::Operating, "Net cash from operating activities"), (S::Investing, "Net cash from investing activities"), (S::Financing, "Net cash from financing activities"), ] { for l in cf.lines(sec) { let mut r = row( sec.key(), &l.kind, l.class.map_or("", |c| c.key()), l.entry.to_string(), l.date.to_string(), &l.label, l.amount, ); r.7 = Some(l.on_behalf); want.push(r); } want.push(row( sec.key(), "", "", String::new(), String::new(), total, cf.total(sec), )); } let r = &cf.operating_reconciliation; want.push(row( "reconciliation", "", "", String::new(), String::new(), "Net income (loss)", r.net_income, )); for l in &r.lines { want.push(row( "reconciliation", l.kind.key(), "", String::new(), String::new(), &l.label, l.amount, )); } want.push(row( "reconciliation", "", "", String::new(), String::new(), "Net cash from operating activities", r.net_cash_from_operating, )); for n in &cf.noncash_disclosures { want.push(row( "noncash", "", "", n.entry.to_string(), n.date.to_string(), &n.label, n.amount, )); } for (label, v) in [ ("Net increase (decrease) in cash", cf.net_change_in_cash), ("Cash at beginning of period", cf.cash_beginning), ("Cash at end of period", cf.cash_ending), ("Sections sum", cf.reconciles.sections_sum), ("Difference", cf.reconciles.difference), ] { want.push(row( "footing", "", "", String::new(), String::new(), label, v, )); } // The warnings close the CSV: kind, entry and message, no amount. let (warned, rows): (Vec<_>, Vec<_>) = rows.into_iter().partition(|r| r[0] == "warning"); let want_warned: Vec> = cf .warnings .iter() .map(|w| { vec![ "warning".to_string(), w.kind.key().to_string(), String::new(), w.entry.to_string(), String::new(), w.message.clone(), String::new(), String::new(), ] }) .collect(); assert_eq!(warned, want_warned, "cash flow CSV warnings"); assert_eq!(cf.warnings.len(), 1, "the monitor bought on account"); let got: Vec<_> = rows .iter() .map(|r| { ( r[0].clone(), r[1].clone(), r[2].clone(), r[3].clone(), r[4].clone(), r[5].clone(), amt(&r[6]), (!r[7].is_empty()).then(|| amt(&r[7])), ) }) .collect(); assert_eq!(got, want, "cash flow CSV"); assert!( cf.operating.len() + cf.financing.len() > 1, "the books move cash" ); // The lunch a member paid through 2100 is borrowed and spent (D-42): // its lines carry what was paid on the company's behalf. assert!( cf.financing.iter().any(|l| !l.on_behalf.is_zero()), "a payment made for the company" ); let j: Value = serde_json::from_str(&m.export_text(args("json"))).unwrap(); assert_eq!(j["method"], "direct"); assert_eq!(j["start"], START); assert_eq!(j["end"], END); for (k, v) in [ ("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, sec) in [ ("operating", S::Operating), ("investing", S::Investing), ("financing", S::Financing), ] { let lines = cf.lines(sec); 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["entry"], json!(l.entry)); assert_eq!(g["date"], l.date.to_string()); assert_eq!(g["label"], l.label.as_str()); assert_eq!(g["kind"], l.kind.as_str()); assert_eq!(jamt(&g["amount"]), l.amount); if l.on_behalf.is_zero() { assert!(g.get("on_behalf").is_none(), "{g}"); } else { assert_eq!(jamt(&g["on_behalf"]), l.on_behalf); } assert_eq!(g["accounts"], json!(l.accounts)); assert_eq!(g["reverses"], json!(l.reverses)); assert_eq!(g["class"], json!(l.class.map(|c| c.key()))); } let by = if sec == S::Operating { "operating_by_class".to_string() } else { format!("{k}_by_kind") }; let kinds = cf.kinds(sec); let got = j[&by].as_array().unwrap(); assert_eq!(got.len(), kinds.len(), "{by}"); for (g, kd) in got.iter().zip(&kinds) { assert_eq!(g["kind"], kd.kind.as_str()); assert_eq!(jamt(&g["amount"]), kd.amount); assert_eq!(g["entries"], json!(kd.entries)); assert_eq!(g["class"], json!(kd.class.map(|c| c.key()))); } } let got = j["noncash_disclosures"].as_array().unwrap(); assert_eq!(got.len(), cf.noncash_disclosures.len()); for (g, n) in got.iter().zip(&cf.noncash_disclosures) { assert_eq!(g["entry"], json!(n.entry)); assert_eq!(g["date"], n.date.to_string()); assert_eq!(g["label"], n.label.as_str()); for (k, v) in [ ("amount", n.amount), ("operating", n.operating), ("investing", n.investing), ("financing", n.financing), ("exchanged", n.exchanged), ] { assert_eq!(jamt(&g[k]), v, "disclosure {k}"); } assert_eq!(g["reverses"], json!(n.reverses)); } let rj = &j["operating_reconciliation"]; assert_eq!(jamt(&rj["net_income"]), r.net_income); assert_eq!( jamt(&rj["net_cash_from_operating"]), r.net_cash_from_operating ); let got = rj["lines"].as_array().unwrap(); assert_eq!(got.len(), r.lines.len()); for (g, l) in got.iter().zip(&r.lines) { assert_eq!(g["kind"], l.kind.key()); assert_eq!(g["label"], l.label.as_str()); assert_eq!(jamt(&g["amount"]), l.amount); assert_eq!(g["account"], json!(l.account)); assert_eq!(g["entries"], json!(l.entries)); } assert_eq!( jamt(&j["reconciles"]["sections_sum"]), cf.reconciles.sections_sum ); assert_eq!( jamt(&j["reconciles"]["net_change"]), cf.reconciles.net_change ); assert_eq!(j["reconciles"]["difference"], "0.00"); let got = j["warnings"].as_array().unwrap(); assert_eq!(got.len(), cf.warnings.len()); for (g, w) in got.iter().zip(&cf.warnings) { assert_eq!(g["entry"], json!(w.entry)); assert_eq!(g["kind"], w.kind.key()); assert_eq!(g["message"], w.message.as_str()); } // The tool's answer is the export's JSON. let tool = m.ok( "books.cash_flow", json!({"start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}), ); // (The tool's answer also says what is open as of the end; the // export's file is the statement alone.) let mut tool = tool; assert!( tool.as_object_mut() .unwrap() .remove("as_of_status") .is_some() ); assert_eq!(tool, j); } /// The journal export, CSV and JSON, over every date, 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"] { // Every date: the journal covers a period (rules.md B-30). let csv = m.export_text( json!({"format": "csv", "report": report, "start": "0001-01-01", "end": "9999-12-31"}), ); 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", "start": "0001-01-01", "end": "9999-12-31"}), )) .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(), ]); } } // Book amounts only: Form 4797 figures the tax gain. for l in &ts.form_4797 { want.push(vec![ "form_4797".into(), l.category.clone(), l.label.clone(), l.accounts.join("; "), l.book.to_string(), String::new(), ]); } 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 dated on or before as_of, numbered (DOCNUM) with its /// entry id (rules.md B-33, B-34); plain ASCII text with CR LF, what it /// writes differently (a line break and double quotes, the engine's own em /// dashes) in warnings, grouped. 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 ledger = s.ledger().clone(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let out = m.ok( "books.export", json!({"format": "iif", "report": "journal", "as_of": END}), ); assert!(out.get("content").is_none(), "handed over: {out}"); assert_eq!(out["file"]["mime_type"], "text/plain"); let lunch = ledger .posted_entries() .find(|e| e.description().starts_with("Lunch")) .unwrap() .id(); // The chart's `Meals, "client"`, its double quotes written single; // then the memos, grouped by what is written differently: the lunch's // double quotes and line break, and the em dashes of every // depreciation memo in one line. let months: Vec = ledger .posted_entries() .filter(|e| e.description().starts_with("Depreciation")) .map(|e| e.id().to_string()) .collect(); assert_eq!(months.len(), 3); let warnings = vec![ "account 6010's name \"Meals, \\\"client\\\"\" is written \"Meals, 'client'\"".to_string(), format!("\"\\\"\" is written \"'\" in the memo of entry {lunch}"), format!("\"\\\"\\n\" is written \"' \" in the memo of entry {lunch}"), format!( "\"—\" is written \"--\" in the memos of entries {}", months.join(", ") ), ]; assert_eq!(out["warnings"], json!(warnings)); let iif = &String::from_utf8(m.handed(&out["file"])).unwrap(); assert!(iif.ends_with("\r\n") && !iif.replace("\r\n", "").contains(['\r', '\n'])); let mut types: HashMap = HashMap::new(); let mut header: Vec<&str> = Vec::new(); let mut docnums: Vec = Vec::new(); let mut sum = Decimal::ZERO; let mut in_trns = false; for line in iif.split("\r\n") { 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; docnums.push(f[7].parse().unwrap()); } assert_eq!( f[7], docnums.last().unwrap().to_string(), "DOCNUM on every line" ); sum += dec(f[6]); } "ENDTRNS" => { assert!(in_trns); assert_eq!(sum, Decimal::ZERO, "an IIF transaction sums to zero"); in_trns = false; } _ => {} } } let through: Vec = ledger .posted_entries() .filter(|e| e.date() <= d(END)) .map(|e| e.id()) .collect(); assert_eq!(docnums, through, "every posted entry through as_of, by id"); 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" ); } // general_journal is the report's name; journal its alias (both work). let alias = m.ok( "books.export", json!({"format": "iif", "report": "general_journal", "as_of": END}), ); assert_eq!(alias["content"], out["content"]); // The file holds everything from the first entry: a range is refused, // and so is another report. let why = m.refuse( "books.export", json!({"format": "iif", "report": "journal", "start": START, "end": END}), ); assert!(why.contains("iif takes as_of, not start or end"), "{why}"); let why = m.refuse( "books.export", json!({"format": "iif", "report": "trial_balance"}), ); assert!( why.contains("only valid for the general_journal report"), "{why}" ); } /// P3 review R5: books.export iif without as_of is as of today, like the /// trial balance: an entry dated later is left out, and the two exports /// made with no dates tie, account by account. #[test] fn iif_without_as_of_is_as_of_today_and_ties_to_the_trial_balance() { let dir = test_dir("iif-today"); let mut s = new_store(&dir); rich_books(&mut s); post( &mut s, "2099-01-04", "Far future rent", &[("6000", Dr, "1234.00"), ("1000", Cr, "1234.00")], ); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let iif = m.export_text(json!({"format": "iif", "report": "journal"})); assert!(!iif.contains("Far future rent")); let mut number: HashMap = HashMap::new(); let mut iif_bal: BTreeMap = BTreeMap::new(); for line in iif.split("\r\n") { let f: Vec<&str> = line.split('\t').collect(); match f[0] { "ACCNT" => { number.insert(f[1].to_string(), f[3].to_string()); } "TRNS" | "SPL" => *iif_bal.entry(number[f[4]].clone()).or_default() += dec(f[6]), _ => {} } } iif_bal.retain(|_, v| !v.is_zero()); let tb = m.export_text(json!({"format": "csv", "report": "trial_balance"})); let mut tb_bal: BTreeMap = BTreeMap::new(); for row in csv_rows(&tb, &["code", "name", "debit", "credit"]) { if row[0] != "TOTAL" { tb_bal.insert(row[0].clone(), dec(&row[2]) - dec(&row[3])); } } tb_bal.retain(|_, v| !v.is_zero()); assert_eq!(iif_bal, tb_bal); } // ------------------------------------------------- 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` (for the server's cash and /// financing accounts); `missing_w9` is the vendors a 1099 is or may be /// due for without a W-9; the untagged-entries note counts the year's /// untagged entries that paid money out for something (the report's /// untagged_payments, listed as untagged_entries); 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| { core_1099(&ledger, year, Some(Decimal::ZERO)) .untagged_payments .len() }; 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 = core_1099(&ledger, year, threshold.map(dec)); assert_eq!(out["year"], json!(want.year)); assert_eq!(out["untagged_entries"], json!(want.untagged_payments)); 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!(jamt(&g["excluded_card"]), w.excluded_card); assert_eq!(jamt(&g["undetermined"]), w.undetermined); assert_eq!(jamt(&g["not_reportable"]), w.not_reportable); assert_eq!(g["status"], w.status.key()); assert_eq!(g["decision_1099"]["value"], w.decision.key()); 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.tin_on_file) .filter_map(|v| { if v.reportable { Some(v.name.clone()) } else if v.status == ledger_core::Status1099::Undetermined && v.decisions.iter().any(|d| d.decision.form().is_some()) { Some(format!("{} (may be reportable)", v.name)) } else { None } }) .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(), in_service_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); dir.stop(); 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 posts each asset what its schedule says is due /// through the month's end (plan/served.md P3, settled in design: a month /// skipped is caught up in the month the run is made), and skips an asset /// not yet in service, or one with nothing due. Hand check: "Short-lived" /// is 600.00 over 2 months from 2025-01-01 (life Jan-Feb 2025), never run; /// "Long-lived" 600.00 over 12 months from 2025-05-01, 50.00 a month, May /// never run. A June run posts the first's whole 600.00 (all caught up) /// and the second's May and June, 100.00 (50.00 caught up). An April run /// then skips both: the second isn't in service yet, and the first's life /// is over with June's run carrying all of it. Kills: run_depreciation's /// catch-up and its skips. #[test] fn run_depreciation_catches_up_and_skips_what_has_nothing_due() { 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(), in_service_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")).unwrap(); let june = sched.iter().find(|l| l.period == "2025-06").unwrap().amount; assert_eq!(june, dec("50.00")); 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(), 2, "{out}"); let run = |id: u64| { runs.iter() .find(|r| r["asset_id"] == json!(id)) .unwrap_or_else(|| panic!("{id} in {out}")) .clone() }; assert_eq!(jamt(&run(old)["amount"]), dec("600.00")); assert_eq!(jamt(&run(old)["caught_up"]), dec("600.00")); assert_eq!(jamt(&run(new)["amount"]), dec("100.00")); assert_eq!(jamt(&run(new)["caught_up"]), dec("50.00")); assert_eq!(out["skipped"], json!([])); let out = m.ok("books.run_depreciation", json!({"period": "2025-04"})); assert_eq!(out["runs"], json!([]), "{out}"); let skipped = out["skipped"].as_array().unwrap(); assert_eq!(skipped.len(), 2, "{out}"); let why = |id: u64| { skipped.iter().find(|r| r["asset_id"] == json!(id)).unwrap()["reason"] .as_str() .unwrap() .to_string() }; assert!(why(new).contains("placed in service 2025-05-01"), "{out}"); assert!(why(old).contains("life is over"), "{out}"); } // ------------------------------------------------- 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); dir.stop(); 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"}), ); } let up = m.upload(b"contribution", "c.txt", "text/plain", "receipt"); m.ok( "books.attach_receipt", json!({"entry_id": e, "upload_id": up}), ); 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); dir.stop(); 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)); // Only the stamp the bytes were set aside under: never the journal's // file name (plan I-12). let stamp = want[0].2.rsplit_once(".torn.").unwrap().1; assert_eq!(repairs[0]["set_aside"], stamp); assert!( !out.to_string().contains(ledger_store::JOURNAL_FILE), "{out}" ); 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()) } ); // A split, or one total column compared (the viewer compares only a // report in one total column: defect 15, options a target ignores are // refused). for (columns, compare) in [ (Some("month"), None), (Some("quarter"), None), (Some("year"), None), (Some("total"), Some("prior_period")), (None, Some("prior_year")), ] { 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: columns.map(Into::into), compare: compare.map(Into::into), } ); let shown = out["shown"].as_str().unwrap(); assert!(shown.contains("2025-01-01 to 2025-12-31"), "{out}"); if let Some(c) = columns.filter(|c| *c != "total") { assert!(shown.contains(&format!("by {c}")), "{out}"); } if let Some(c) = compare { assert!(shown.contains(&c.replace('_', " ")), "{out}"); } } for (columns, compare) in [("month", "prior_year"), ("quarter", "prior_period")] { let e = m.refuse( "books.show", json!({"report": "profit_and_loss", "columns": columns, "compare": compare}), ); assert!(e.contains("one total column"), "{e}"); } 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!({})); } /// The command `books.show` last wrote, or None before any. fn last_show(m: &Mcp) -> Option { ledger_store::ops::read(&m.data) } /// Defect 15, repros 1.1.1 and 1.1.2: a start after the end, given, is /// refused for a report and for an account, and nothing reaches the /// viewer (the last command it was sent stays the last). A start equal to /// the end is a one-day period. #[test] fn show_refuses_start_after_end_defect_15() { let dir = test_dir("show-inverted"); drop(new_store(&dir)); let mut m = Mcp::start(&dir, "1000000.00"); m.ok("books.show", json!({"page": "overview"})); let before = last_show(&m).unwrap(); for args in [ json!({"report": "profit_and_loss", "start": "2026-12-31", "end": "2026-01-01"}), json!({"account": "1000", "start": "2026-12-31", "end": "2026-01-01"}), json!({"report": "cash_flow", "start": "2026-01-02", "end": "2026-01-01"}), json!({"report": "balance_sheet", "columns": "month", "start": "2026-03-01", "end": "2026-02-28"}), ] { let e = m.refuse("books.show", args.clone()); assert!(e.contains("is before start"), "{args}: {e}"); assert_eq!(last_show(&m), Some(before.clone()), "{args}"); } let out = m.ok( "books.show", json!({"report": "profit_and_loss", "start": "2026-03-01", "end": "2026-03-01"}), ); assert!( out["shown"] .as_str() .unwrap() .contains("2026-03-01 to 2026-03-01"), "{out}" ); } /// Defect 15, repro 1.1.3: a start after today with no end (the end an /// omitted end means) is refused, saying the end was defaulted; nothing /// reaches the viewer. #[test] fn show_refuses_a_derived_inverted_range_defect_15() { let dir = test_dir("show-derived"); drop(new_store(&dir)); let mut m = Mcp::start(&dir, "1000000.00"); m.ok("books.show", json!({"page": "journal"})); let before = last_show(&m).unwrap(); for args in [ json!({"account": "1000", "start": "9999-12-31"}), json!({"report": "general_ledger", "start": "9999-01-01"}), ] { let e = m.refuse("books.show", args.clone()); assert!( e.contains("defaults to today") && e.contains("pass end"), "{args}: {e}" ); assert_eq!(last_show(&m), Some(before.clone()), "{args}"); } } /// Defect 16, repro 1.1.4: books.show sends the dates it names, resolved /// by the one default (rules.md B-30): only an end covers that end's /// fiscal year to it (2025-01-01 to 2025-06-30, never today's year's /// start); no dates cover today's fiscal year to date (checked against /// the clock read before and after, R14); a report as of a day says "as /// of", vendor_1099 "for" its year; and the viewer gets the same pair. #[test] fn show_sends_the_dates_it_names_defect_16() { let dir = test_dir("show-dates"); drop(new_store(&dir)); let mut m = Mcp::start(&dir, "1000000.00"); let target = |m: &Mcp| match last_show(m).unwrap().target { ViewerTarget::Report { start, end, .. } | ViewerTarget::Account { start, end, .. } => { (start.unwrap(), end.unwrap()) } other => panic!("not dated: {other:?}"), }; for (args, shown, dates) in [ ( json!({"report": "profit_and_loss", "end": "2025-06-30"}), "the profit and loss report, 2025-01-01 to 2025-06-30", ("2025-01-01", "2025-06-30"), ), ( json!({"account": "1000", "end": "2025-06-30"}), "the register of 1000 Bank, 2025-01-01 to 2025-06-30", ("2025-01-01", "2025-06-30"), ), ( json!({"report": "profit_and_loss", "start": "2025-03-01", "end": "2025-03-31"}), "the profit and loss report, 2025-03-01 to 2025-03-31", ("2025-03-01", "2025-03-31"), ), ( json!({"report": "trial_balance", "end": "2025-06-30"}), "the trial balance report, as of 2025-06-30", ("2025-01-01", "2025-06-30"), ), ( json!({"report": "balance_sheet", "end": "2025-06-30"}), "the balance sheet report, as of 2025-06-30", ("2025-01-01", "2025-06-30"), ), ( json!({"report": "balance_sheet", "end": "2025-06-30", "columns": "quarter"}), "the balance sheet report, 2025-01-01 to 2025-06-30, by quarter", ("2025-01-01", "2025-06-30"), ), ( json!({"report": "fixed_asset_register", "end": "2024-12-31"}), "the fixed asset register report, as of 2024-12-31", ("2024-01-01", "2024-12-31"), ), ( json!({"report": "ar_aging", "end": "2025-02-15"}), "the ar aging report, as of 2025-02-15", ("2025-01-01", "2025-02-15"), ), ( json!({"report": "vendor_1099", "end": "2025-12-31"}), "the vendor 1099 report, for 2025", ("2025-01-01", "2025-12-31"), ), ] { let out = m.ok("books.show", args.clone()); assert_eq!(out["shown"], shown, "{args}"); assert_eq!( target(&m), (dates.0.to_string(), dates.1.to_string()), "{args}" ); } // No dates: today's fiscal year to date, whichever day the call saw. let before = ledger_core::settings::today_in(None); let out = m.ok("books.show", json!({"report": "profit_and_loss"})); let after = ledger_core::settings::today_in(None); let (s, e) = target(&m); let ok = [before, after].iter().any(|t| { let (fy, _) = ledger_core::settings::Company::default().fiscal_year_containing(*t); (s.clone(), e.clone()) == (fy.to_string(), t.to_string()) && out["shown"] == format!("the profit and loss report, {fy} to {t}") }); assert!(ok, "{s} to {e}: {out}"); } /// Defect 15 (settled: books.show refuses options its target ignores): /// dates, columns or a comparison for an entry, a page or any other /// non-dated target; columns or a comparison for an account; columns for /// a report that doesn't split; a comparison for one that doesn't compare; /// a start for a report as of a day, or for the 1099 worksheet. Each names /// what takes the option, and nothing reaches the viewer. #[test] fn show_refuses_options_its_target_ignores_defect_15() { let dir = test_dir("show-options"); let mut s = new_store(&dir); let e = post( &mut s, "2025-01-02", "Seed", &[("1000", Dr, "1.00"), ("4000", Cr, "1.00")], ); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); m.ok("books.show", json!({"page": "overview"})); let before = last_show(&m).unwrap(); for (args, says) in [ ( json!({"entry_id": e, "start": "2025-01-01"}), "for an account or a report", ), ( json!({"page": "reports", "end": "2025-01-31"}), "for an account or a report", ), ( json!({"search": "x", "columns": "month"}), "for an account or a report", ), ( json!({"entry_id": e, "start": "2025-01-01", "compare": "prior_year"}), "start and compare are", ), ( json!({"account": "1000", "columns": "month"}), "takes only start and end", ), ( json!({"account": "1000", "compare": "prior_year"}), "takes only start and end", ), ( json!({"report": "trial_balance", "columns": "total"}), "columns is for profit_and_loss, balance_sheet and cash_flow", ), ( json!({"report": "tax_summary", "columns": "month"}), "columns is for", ), ( json!({"report": "general_ledger", "compare": "prior_year"}), "compare is for profit_and_loss, balance_sheet, cash_flow and tax_summary", ), ( json!({"report": "trial_balance", "start": "2025-01-01", "end": "2025-03-31"}), "is as of a day: pass end", ), ( json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31"}), "in one total column is as of a day", ), ( json!({"report": "vendor_1099", "start": "2025-01-01"}), "is for a calendar year", ), // A split past the viewer's 240 columns, which it shows as the // total: 312 months. ( json!({"report": "profit_and_loss", "start": "2000-01-01", "end": "2025-12-31", "columns": "month"}), "is 312 columns, and the viewer splits a report into at most 240", ), ( json!({"report": "balance_sheet", "start": "0001-01-01", "end": "2025-12-31", "columns": "year"}), "at most 240", ), ] { let err = m.refuse("books.show", args.clone()); assert!(err.contains(says), "{args}: {err}"); assert_eq!(last_show(&m), Some(before.clone()), "{args}"); } // What a balance sheet takes a start for: a split, or the prior period; // and 240 months is a split the viewer shows. for args in [ json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "columns": "month"}), json!({"report": "balance_sheet", "start": "2025-01-01", "end": "2025-03-31", "compare": "prior_period"}), json!({"report": "profit_and_loss", "start": "2006-01-01", "end": "2025-12-31", "columns": "month"}), json!({"report": "profit_and_loss", "start": "0001-01-01", "end": "2025-12-31", "columns": "total"}), ] { m.ok("books.show", args); } } // ------------------------------------------------- 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}"); } // A receipt reads as books.get_receipt's meta view: what it is, never // its bytes (those come with view bytes). 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!(got.get("content_base64").is_none(), "no bytes: {got}"); assert_eq!(got["sha256"], sha256(bytes)); assert_eq!(got["size_bytes"], json!(bytes.len())); assert_eq!( got, m.ok("books.get_receipt", json!({"attachment_id": id})), "books://receipts/{id} reads as books.get_receipt" ); let full = m.ok( "books.get_receipt", json!({"attachment_id": id, "view": "bytes"}), ); assert_eq!(m.handed(&full["file"]), 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; only view bytes carries the bytes. Kills: /// get_receipt's lookup and its views. #[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 up = m.upload(first, "first.pdf", "application/pdf", "receipt"); let a = m.ok( "books.attach_receipt", json!({"entry_id": e, "upload_id": up, "description": "Receipt ID 1007; Paid from: Quillmere"}), ); let up = m.upload(second, "oa.png", "image/png", "document"); let b = m.ok( "books.file_document", json!({"category": "legal", "upload_id": up, "description": "Operating agreement"}), ); let up = m.upload( b"EIN letter bytes", "ein-letter.pdf", "application/pdf", "document", ); let c = m.ok( "books.file_document", json!({"category": "tax", "upload_id": up, "description": "IRS CP 575"}), ); // The name and type a call gives win over the upload's. let up = m.upload( b"a scanned receipt", "scan.bin", "application/octet-stream", "receipt", ); let r = m.ok( "books.attach_receipt", json!({"entry_id": e, "upload_id": up, "file_name": "renamed.png", "mime_type": "image/png", "description": "scan"}), ); // (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: Quillmere", ), ( &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 meta = m.ok("books.get_receipt", json!({"attachment_id": id})); assert!(meta.get("file").is_none(), "{name}: meta, no file"); let got = m.ok( "books.get_receipt", json!({"attachment_id": id, "view": "bytes"}), ); assert_eq!(got["attachment_id"], json!(id)); // Handed over (D-46): its download is the bytes, under its name. assert!(got.get("content_base64").is_none(), "{name}: {got}"); assert_eq!(m.handed(&got["file"]), bytes, "{name}"); assert_eq!(got["file"]["file_name"], name); assert_eq!(got["file"]["mime_type"], mime); let mut without = got.clone(); without.as_object_mut().unwrap().remove("file"); assert_eq!(without, meta, "{name}: view bytes is meta and the file"); 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})); for view in ["", "BYTES", "content", "meta "] { let why = m.refuse( "books.get_receipt", json!({"attachment_id": a["attachment_id"], "view": view}), ); assert!( why.contains("meta (the default), text, page_image or bytes"), "{view:?}: {why}" ); } } /// No file is given inline any more (D-46): one past the old inline limit /// (256 KiB) is filed by upload through books.attach_receipt and /// books.file_document and stored whole, and comes back whole by its /// download; the inline arguments are refused by both as unknown, /// pointing at books.begin_upload, and nothing is stored. #[test] fn files_past_the_old_inline_limit_go_by_upload_and_come_back_by_download() { const OLD_LIMIT: usize = 256 * 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'; OLD_LIMIT + 1]; let up = m.upload(&at, "big.bin", "application/octet-stream", "receipt"); let out = m.ok("books.attach_receipt", json!({"upload_id": up})); let at_id = out["attachment_id"].as_u64().unwrap(); let doc = vec![b'b'; OLD_LIMIT + 1]; let up = m.upload(&doc, "big.bin", "application/octet-stream", "document"); let out = m.ok( "books.file_document", json!({"category": "other", "upload_id": up}), ); let doc_id = out["attachment_id"].as_u64().unwrap(); let listed = m.ok("books.list_receipts", json!({})); for (id, bytes) in [(at_id, &at), (doc_id, &doc)] { let row = listed["receipts"] .as_array() .unwrap() .iter() .find(|r| r["attachment_id"] == json!(id)) .unwrap() .clone(); assert_eq!(row["size_bytes"], json!(OLD_LIMIT + 1), "stored whole"); // And it comes back by its download, whole. let got = m.ok( "books.get_receipt", json!({"attachment_id": id, "view": "bytes"}), ); assert_eq!(&m.handed(&got["file"]), bytes); } let journal = std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(); for (tool, args) in [ ( "books.attach_receipt", json!({"file_name": "s.bin", "mime_type": "application/octet-stream", "content_base64": "YQ==", "sha256": sha256(b"a")}), ), ( "books.file_document", json!({"category": "other", "file_name": "s.bin", "mime_type": "application/octet-stream", "content_base64": "YQ==", "sha256": sha256(b"a")}), ), ] { let why = m.refuse(tool, args); assert!( why.contains("books.begin_upload") && why.contains("unknown argument"), "{tool}: {why}" ); } assert_eq!( std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(), journal, "a refusal stored something" ); } /// A filing tool takes its file only as an upload (D-46): with none it is /// refused, naming upload_id; an argument that once carried the bytes /// inline (content_base64, sha256, csv_text), alone or beside an upload, /// is refused as unknown, pointing at books.begin_upload; an upload_id /// that names no upload is refused. books.begin_upload names the /// attachment for bytes stored already (its sha256 in any case), and /// begins an upload for new ones, journaling nothing. Every refusal /// stores nothing. #[test] fn a_filing_tool_takes_its_file_only_as_an_upload() { let dir = test_dir("inline"); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); let bytes: &[u8] = b"%PDF-1.4 a receipt"; let other: &[u8] = b"%PDF-1.4 a receipt!"; let journal = || std::fs::read(data_dir(&dir).join(ledger_store::JOURNAL_FILE)).unwrap(); let before = journal(); let up = m.upload(bytes, "r.pdf", "application/pdf", "receipt"); for (tool, base) in [ ("books.attach_receipt", json!({})), ("books.file_document", json!({"category": "other"})), ] { let with = |extra: Value| { let mut args = base.clone(); for (k, v) in extra.as_object().unwrap() { args[k] = v.clone(); } args }; for (args, said) in [ (with(json!({})), "upload_id"), ( with(json!({"content_base64": "JVBERi0xLjQ=", "sha256": sha256(bytes)})), "books.begin_upload", ), ( with(json!({"content_base64": "JVBERi0xLjQ="})), "unknown argument", ), (with(json!({"sha256": sha256(bytes)})), "unknown argument"), ( with(json!({"upload_id": up, "content_base64": "JVBERi0xLjQ="})), "unknown argument", ), ( with(json!({"upload_id": up, "sha256": sha256(bytes)})), "unknown argument", ), // An upload there is none of. ( with(json!({"upload_id": "u1"})), "there is no upload \"u1\"", ), ] { let why = m.refuse(tool, args.clone()); assert!(why.contains(said), "{tool} {args}: {why}"); } } assert_eq!(journal(), before, "a refusal stored something"); // Every refusal left the upload: filed now. let out = m.ok("books.attach_receipt", json!({"upload_id": up})); assert_eq!(out["sha256"], sha256(bytes)); let id = out["attachment_id"].as_u64().unwrap(); assert_ne!(journal(), before); // begin_upload: the bytes stored already name their attachment; new // ones begin an upload. let up = |sha: String| { json!({"sha256": sha, "size": bytes.len(), "mime_type": "application/pdf", "file_name": "r.pdf", "purpose": "receipt"}) }; let got = m.ok("books.begin_upload", up(sha256(bytes))); assert_eq!(got["existing_attachment_id"], json!(id), "{got}"); assert!(got.get("upload_id").is_none(), "{got}"); assert!(got.get("command").is_none(), "{got}"); let got = m.ok("books.begin_upload", up(sha256(bytes).to_ascii_uppercase())); assert_eq!(got["existing_attachment_id"], json!(id), "{got}"); let stored = journal(); let begun = m.ok("books.begin_upload", up(sha256(other))); assert!(begun["upload_id"].is_string(), "{begun}"); assert_eq!(journal(), stored, "beginning an upload journals nothing"); // The CSV imports take only an upload; the text inline is refused as // unknown, alone or beside one; an upload there is none of is refused. for (tool, base) in [ ( "books.import_bank_csv", json!({"bank_account_code": "1000", "default_offset_code": "4000"}), ), ("books.import_chart_csv", json!({})), ] { for (extra, said) in [ (json!({}), "upload_id"), (json!({"csv_text": "a,b\n"}), "books.begin_upload"), ( json!({"csv_text": "a,b\n", "upload_id": "u1"}), "unknown argument", ), (json!({"upload_id": "u1"}), "there is no upload \"u1\""), ] { let mut args = base.clone(); for (k, v) in extra.as_object().unwrap() { args[k] = v.clone(); } let why = m.refuse(tool, args.clone()); assert!(why.contains(said), "{tool} {args}: {why}"); } } assert_eq!(journal(), stored, "a refusal stored something"); } /// books.get_receipt with view "bytes" hands every file over, whatever its /// size (D-46): its download, made for the caller's token, is those very /// bytes, as is the stored file's own route (`GET /b/{book}/a/{id}`, for /// books:read); the answer never carries the bytes. #[test] fn get_receipt_hands_every_file_over_by_its_download() { const OLD_LIMIT: usize = 256 * 1024; let dir = test_dir("get-bytes"); let mut s = new_store(&dir); let at = vec![b'x'; OLD_LIMIT]; let over = vec![b'y'; OLD_LIMIT + 1]; let (at_id, _, _) = s .attach_receipt(None, "at.bin", "application/octet-stream", &at, None) .unwrap(); let (over_id, _, _) = s .attach_receipt(None, "over.bin", "application/octet-stream", &over, None) .unwrap(); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); for (id, bytes, name) in [(at_id, &at, "at.bin"), (over_id, &over, "over.bin")] { let got = m.ok( "books.get_receipt", json!({"attachment_id": id, "view": "bytes"}), ); assert!(got.get("content_base64").is_none(), "{got}"); assert!(got.get("download").is_none(), "{got}"); let file = &got["file"]; assert_eq!(file["file_name"], name); assert_eq!(file["mime_type"], "application/octet-stream"); assert_eq!(file["size_bytes"], json!(bytes.len())); assert_eq!(file["sha256"], sha256(bytes)); let download = file["download"].as_str().unwrap_or_else(|| panic!("{got}")); assert!(download.contains(" ex_"), "{download}"); assert_eq!(&m.handed(file), bytes); let (status, stored) = m.wire.get(&format!("a/{id}")); assert_eq!(status, 200); assert_eq!(&stored, bytes); let meta = m.ok("books.get_receipt", json!({"attachment_id": id})); assert_eq!(meta["size_bytes"], json!(bytes.len())); assert_eq!(meta["sha256"], sha256(bytes)); assert!(meta.get("file").is_none(), "{meta}"); } } /// 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 csv = "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"; let up = m.upload(csv.as_bytes(), "bank.csv", "text/csv", "import"); let out = m.ok( "books.import_bank_csv", json!({ "upload_id": up, "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); // Classes as an earlier version could write them (a name of digits // only can't be added now): 1 "placeholder-name", 2 named "1", 3 // named "3". let mut w = ledger_store::JournalWriter::open(&data_dir(&dir).join(ledger_store::JOURNAL_FILE)) .unwrap(); for (i, n) in ["placeholder-name", "1", "3"].iter().enumerate() { w.append("class.added", json!({"id": i + 1, "name": n})) .unwrap(); } drop(w); 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"}), ); // Class 2 is named "1", class 1's id. let c = 1u64; // "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()}), ); // c3 named by its own id: only one class is meant. let c3 = 3u64; 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; a 1099 worksheet equal to `vendor_1099_report` through /// as_of (a payment after it to the same contractor left out); and the /// journal as IIF from the first entry through as_of. 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); // The payment after as_of went to the contractor too. s.set_entry_vendor(r.after, Some(r.contractor)).unwrap(); // 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"], "include_tins": false, "allow_open_drafts": {"reason": "the test books keep drafts open"}}), ); assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip")); assert_eq!(out["file"]["mime_type"], "application/zip"); let files = unzip(&m.handed(&out["file"])); 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"); // The IIF: from the first entry (the prior year's sale) through as_of. let iif = std::str::from_utf8(&files["general-journal.iif"]).unwrap(); assert!(iif.contains("\tPrior-year sale\r\n") && iif.contains("\tRent January\r\n")); assert!(!iif.contains("After mid-year"), "through as_of only"); assert!(iif.contains("\tLunch, 'team' quarterly\r\n")); // The meals account's double quotes, the lunch's and its line break, // and the em dashes of the depreciation memos: written differently, so // said, the memos grouped (four lines for one name and four memos), in // warnings and the README alike. // (The audit notice P8 adds while no counted audit stands behind the // package is a warning too; it is not about the IIF.) let warnings: Vec = out["warnings"] .as_array() .unwrap() .iter() .map(|w| w.as_str().unwrap().to_string()) .filter(|w| !w.starts_with("No counted audit")) .collect(); assert_eq!(warnings.len(), 4, "{out}"); let readme = std::str::from_utf8(&files["README.txt"]).unwrap(); for w in &warnings { assert!(readme.contains(&format!("\n- {w}\n")), "{w} in {readme}"); } assert!(out["note"].as_str().unwrap().ends_with( " 5 account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human." )); // 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_id", "name", "decision_1099", "form_1099", "reason_kind", "reason", "decided_by", "decided_on", "effective", "decisions_in_year", "paid", "excluded_card", "undetermined", "not_reportable", "status", "reportable", "w9_on_file", "tin_last4", ], ); // Through as_of (rules.md B-33), out of the package's cash account // and the settings' financing account (one list each per package). let want = core_1099_through(&ledger, 2025, None, Some(d(END))); assert_eq!(w.len(), want.vendors.len()); let contractor = w.iter().find(|row| row[1] == "Contractor").unwrap(); assert_eq!( amt(&contractor[10]), dec("900.00"), "the payment after as_of is left out" ); for (row, v) in w.iter().zip(&want.vendors) { assert_eq!(row[0], v.vendor_id.to_string()); assert_eq!(row[1], v.name); assert_eq!(row[2], v.decision.key()); assert_eq!(row[3], v.form().map(|f| f.label()).unwrap_or("")); assert_eq!( row[6], v.decided.as_ref().map_or("", |s| s.decided_by.as_str()) ); assert_eq!(amt(&row[10]), v.paid); assert_eq!(amt(&row[11]), v.excluded_card); assert_eq!(amt(&row[12]), v.undetermined); assert_eq!(amt(&row[13]), v.not_reportable); assert_eq!(row[14], v.status.key()); assert_eq!(row[15], v.reportable.to_string()); assert_eq!(row[16], v.tin_on_file.to_string()); } // The package's 1099 worksheet is books.vendor_1099's CSV for the // package's accounts and its as_of, byte for byte (ledger_package // INVARIANT 7, docs/mcp-interface.md): the tool given the same cash // account (the financing account the settings', as the package's) // and through as_of hands over the same file; without through, the // payment after as_of counts, and the files differ. let mut tool_csv = |args: Value| { let out = m.ok("books.vendor_1099", args); m.handed(&out["csv"]) }; let same = tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"], "through": END})); assert_eq!(same, files["1099-worksheet-2025.csv"]); let named = tool_csv(json!({ "year": 2025, "csv": true, "cash_accounts": ["1000"], "financing_accounts": ["2100"], "through": END })); assert_eq!( named, files["1099-worksheet-2025.csv"], "the settings' financing, named" ); let whole_year = tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"]})); assert_ne!(whole_year, files["1099-worksheet-2025.csv"]); // A through outside the year, or an account the chart lacks: refused. let why = m.refuse( "books.vendor_1099", json!({"year": 2025, "through": "2026-01-01"}), ); assert!(why.contains("is a day of 2025"), "{why}"); let why = m.refuse( "books.vendor_1099", json!({"year": 2025, "cash_accounts": ["1999"]}), ); assert!(why.contains("no account 1999"), "{why}"); // Without receipts: no evidence at all. let out = m.ok( "books.export_cpa_package", json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false, "allow_open_drafts": {"reason": "the test books keep drafts open"}}), ); let files = unzip(&m.handed(&out["file"])); assert!( !files .keys() .any(|n| n.starts_with("receipts/") || n.starts_with("documents/")), "{:?}", files.keys() ); assert!(files.contains_key("1099-worksheet-2025.csv")); }