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