A distribution declared to a payable and then paid is financing when paid, the declaration disclosed (ASC 230-10-45-15(a)); a disposal's depreciation lines count as depreciation in the cash flow and the tax-basis K-1; an as-of asset list says the status of that day. Seam tests tie the members' statement, K-1, party totals, cash flow, 1099s, depreciation and the CPA package together on three dates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
351 lines
13 KiB
Rust
351 lines
13 KiB
Rust
//! P3's seams through the tools: on the seam review's books (a year of a
|
|
//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), built
|
|
//! in-process with the real `Store`, closed, then served by the real
|
|
//! binary over stdio from the same folder and its settings file, every
|
|
//! figure a tool reports is the engine's for the same accounts and dates,
|
|
//! and the tools agree with each other: the cash flow statement, party
|
|
//! totals on both bases, the 1099 worksheet as of a day, the fixed
|
|
//! assets, the members' equity statement and the CPA package.
|
|
|
|
#[path = "../../ledger-package/tests/seams/books.rs"]
|
|
mod books;
|
|
|
|
use books::{books, d, dec};
|
|
use serde_json::{Value, json};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::path::Path;
|
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
|
use std::sync::mpsc::{Receiver, channel};
|
|
use std::time::Duration;
|
|
|
|
const TIMEOUT: Duration = Duration::from_secs(180);
|
|
const START: &str = "2026-01-01";
|
|
|
|
struct Mcp {
|
|
stdin: ChildStdin,
|
|
rx: Receiver<String>,
|
|
child: Child,
|
|
next_id: u64,
|
|
}
|
|
|
|
impl Drop for Mcp {
|
|
fn drop(&mut self) {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
}
|
|
|
|
impl Mcp {
|
|
fn start(dir: &Path) -> Self {
|
|
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(dir.join("books-data"))
|
|
.arg("--books-toml")
|
|
.arg(dir.join("wanderbooks.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 mcp = Mcp {
|
|
stdin,
|
|
rx,
|
|
child,
|
|
next_id: 1,
|
|
};
|
|
let init = mcp.rpc(
|
|
"initialize",
|
|
json!({
|
|
"protocolVersion": "2025-06-18",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "p3-seams", "version": "0.1.0"},
|
|
}),
|
|
);
|
|
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
|
writeln!(
|
|
mcp.stdin,
|
|
"{}",
|
|
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
)
|
|
.unwrap();
|
|
mcp
|
|
}
|
|
|
|
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
|
writeln!(self.stdin, "{msg}").unwrap();
|
|
self.stdin.flush().unwrap();
|
|
loop {
|
|
let line = self
|
|
.rx
|
|
.recv_timeout(TIMEOUT)
|
|
.unwrap_or_else(|_| panic!("no response to {method}"));
|
|
let v: Value = serde_json::from_str(&line).unwrap();
|
|
if v.get("id") == Some(&json!(id)) {
|
|
return v;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A tool's structured result; a refusal fails the test.
|
|
fn ok(&mut self, name: &str, args: Value) -> Value {
|
|
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
|
|
let r = &resp["result"];
|
|
assert!(
|
|
resp.get("error").is_none() && r.get("isError") != Some(&json!(true)),
|
|
"{name} {args}: {resp}"
|
|
);
|
|
r["structuredContent"].clone()
|
|
}
|
|
}
|
|
|
|
fn m(v: &Value) -> rust_decimal::Decimal {
|
|
dec(v
|
|
.as_str()
|
|
.unwrap_or_else(|| panic!("a decimal string: {v}")))
|
|
}
|
|
|
|
/// What the engine says, worked out before the server opens the books.
|
|
struct Expected {
|
|
end: String,
|
|
cash_flow: ledger_core::CashFlowStatement,
|
|
members_equity: ledger_core::MembersEquityStatement,
|
|
register: ledger_core::AssetRegister,
|
|
vendor_1099: ledger_core::Vendor1099Report,
|
|
depreciation: rust_decimal::Decimal,
|
|
iif: String,
|
|
}
|
|
|
|
/// Every figure the tools report on the seam review's books is the
|
|
/// engine's for the same accounts and dates, on every date (B-33's "the
|
|
/// tool's for the same accounts and through"), and party totals for the
|
|
/// year are the engine's on both bases.
|
|
#[test]
|
|
fn the_tools_figures_are_the_engines_on_every_date() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let b = books(dir.path());
|
|
let l = b.store.ledger();
|
|
let settings = ledger_package::Settings {
|
|
company: &b.company,
|
|
cash_accounts: &b.cash,
|
|
financing_accounts: &b.financing,
|
|
investing_accounts: &[],
|
|
};
|
|
let capitals = ledger_package::member_capitals(l, &b.company.members).unwrap();
|
|
let mut expected = Vec::new();
|
|
for end in ["2026-06-30", "2026-09-15", "2026-12-31"] {
|
|
let e = d(end);
|
|
expected.push(Expected {
|
|
end: end.to_string(),
|
|
cash_flow: ledger_package::cash_flow_for(
|
|
l,
|
|
&settings,
|
|
Some(START),
|
|
Some(end),
|
|
&[],
|
|
&[],
|
|
&[],
|
|
e,
|
|
)
|
|
.unwrap(),
|
|
members_equity: ledger_core::members_equity(l, d(START), e, &capitals).unwrap(),
|
|
register: ledger_core::fixed_asset_register(l, e).unwrap(),
|
|
vendor_1099: ledger_package::vendor_1099_report(l, &settings, 2026, None, Some(e))
|
|
.unwrap(),
|
|
depreciation: ledger_core::profit_and_loss_for_period(l, d(START), e, None)
|
|
.unwrap()
|
|
.expenses
|
|
.iter()
|
|
.filter(|x| x.code == "6500")
|
|
.map(|x| x.amount)
|
|
.sum(),
|
|
iif: ledger_package::export_iif(l, &b.cash, e).unwrap().text,
|
|
});
|
|
}
|
|
let money = ledger_core::parties::money_accounts(
|
|
l,
|
|
&[l.chart().find_by_code("1000").unwrap().id],
|
|
&b.financing
|
|
.iter()
|
|
.map(|c| l.chart().find_by_code(c).unwrap().id)
|
|
.collect::<Vec<_>>(),
|
|
);
|
|
let members = b.company.party_members(l);
|
|
let year = (d(START), d("2026-12-31"));
|
|
let parties: Vec<(String, ledger_core::parties::Activity)> = l
|
|
.parties()
|
|
.map(|p| {
|
|
(
|
|
p.name.clone(),
|
|
ledger_core::parties::activity_with(l, p.id, year, &money, &members),
|
|
)
|
|
})
|
|
.collect();
|
|
drop(b);
|
|
|
|
let mut mcp = Mcp::start(dir.path());
|
|
for x in &expected {
|
|
let end = x.end.as_str();
|
|
// The cash flow statement: every line, kind and reconciling item.
|
|
let cf = mcp.ok("books.cash_flow", json!({"start": START, "end": end}));
|
|
for (key, total) in [
|
|
("cash_from_operations", x.cash_flow.cash_from_operations),
|
|
("cash_from_investing", x.cash_flow.cash_from_investing),
|
|
("cash_from_financing", x.cash_flow.cash_from_financing),
|
|
("net_change_in_cash", x.cash_flow.net_change_in_cash),
|
|
] {
|
|
assert_eq!(m(&cf[key]), total, "{end} {key}");
|
|
}
|
|
for (key, section) in [
|
|
("operating", ledger_core::CashFlowSection::Operating),
|
|
("investing", ledger_core::CashFlowSection::Investing),
|
|
("financing", ledger_core::CashFlowSection::Financing),
|
|
] {
|
|
let lines: Vec<(String, rust_decimal::Decimal)> = cf[key]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|l| (l["kind"].as_str().unwrap().to_string(), m(&l["amount"])))
|
|
.collect();
|
|
let engine: Vec<(String, rust_decimal::Decimal)> = x
|
|
.cash_flow
|
|
.lines(section)
|
|
.iter()
|
|
.map(|l| (l.kind.clone(), l.amount))
|
|
.collect();
|
|
assert_eq!(lines, engine, "{end} {key}");
|
|
}
|
|
let recon: Vec<(String, rust_decimal::Decimal)> = cf["operating_reconciliation"]["lines"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|l| (l["label"].as_str().unwrap().to_string(), m(&l["amount"])))
|
|
.collect();
|
|
let engine: Vec<(String, rust_decimal::Decimal)> = x
|
|
.cash_flow
|
|
.operating_reconciliation
|
|
.lines
|
|
.iter()
|
|
.map(|l| (l.label.clone(), l.amount))
|
|
.collect();
|
|
assert_eq!(recon, engine, "{end}");
|
|
let depreciation: rust_decimal::Decimal = recon
|
|
.iter()
|
|
.filter(|(label, _)| label == "Depreciation")
|
|
.map(|(_, a)| *a)
|
|
.sum();
|
|
assert_eq!(
|
|
depreciation, x.depreciation,
|
|
"{end}: the P&L's depreciation"
|
|
);
|
|
// The P&L's depreciation.
|
|
let pnl = mcp.ok("books.profit_and_loss", json!({"start": START, "end": end}));
|
|
let dep: rust_decimal::Decimal = pnl["expense_accounts"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.filter(|a| a["code"] == "6500")
|
|
.map(|a| m(&a["amount"]))
|
|
.sum();
|
|
assert_eq!(dep, x.depreciation, "{end}");
|
|
// The members' equity statement.
|
|
let me = mcp.ok(
|
|
"books.export",
|
|
json!({"report": "members_equity", "format": "json", "start": START, "end": end}),
|
|
);
|
|
let me: Value = serde_json::from_str(me["content"].as_str().unwrap()).unwrap();
|
|
for (col, engine) in me["members"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.zip(&x.members_equity.members)
|
|
{
|
|
for (key, v) in [
|
|
("beginning", engine.beginning),
|
|
("contributions", engine.contributions),
|
|
("net_income", engine.net_income),
|
|
("distributions", engine.distributions),
|
|
("other", engine.other),
|
|
("ending", engine.ending),
|
|
] {
|
|
assert_eq!(m(&col[key]), v, "{end} {} {key}", engine.name);
|
|
}
|
|
}
|
|
// The fixed assets, as of the day: list_assets is the register.
|
|
let assets = mcp.ok("books.list_assets", json!({"as_of": end}));
|
|
for (a, row) in assets["assets"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.zip(&x.register.rows)
|
|
{
|
|
assert_eq!(m(&a["accumulated_posted"]), row.accumulated_posted, "{end}");
|
|
assert_eq!(
|
|
m(&a["accumulated_scheduled_through"]),
|
|
row.accumulated_scheduled,
|
|
"{end}"
|
|
);
|
|
assert_eq!(m(&a["book_value"]), row.book_value, "{end}");
|
|
let disposed = row.disposed.is_some();
|
|
assert_eq!(a["held"], json!(!disposed), "{end}");
|
|
assert_eq!(
|
|
a["status"],
|
|
json!(if disposed { "disposed" } else { "active" }),
|
|
"{end}: a disposal after as_of doesn't show"
|
|
);
|
|
}
|
|
// The 1099 worksheet as of the day.
|
|
let w = mcp.ok("books.vendor_1099", json!({"year": 2026, "through": end}));
|
|
let vendors = w["vendors"].as_array().unwrap();
|
|
assert_eq!(vendors.len(), x.vendor_1099.vendors.len(), "{end}");
|
|
for (v, engine) in vendors.iter().zip(&x.vendor_1099.vendors) {
|
|
assert_eq!(v["vendor_id"], json!(engine.vendor_id));
|
|
for (key, amount) in [
|
|
("paid", engine.paid),
|
|
("excluded_card", engine.excluded_card),
|
|
("undetermined", engine.undetermined),
|
|
("not_reportable", engine.not_reportable),
|
|
] {
|
|
assert_eq!(m(&v[key]), amount, "{end} {} {key}", engine.name);
|
|
}
|
|
}
|
|
// The IIF as of the day.
|
|
let iif = mcp.ok(
|
|
"books.export",
|
|
json!({"report": "general_journal", "format": "iif", "as_of": end}),
|
|
);
|
|
assert_eq!(iif["content"].as_str().unwrap(), x.iif, "{end}");
|
|
}
|
|
// Party totals for the year, on both bases.
|
|
for (name, a) in &parties {
|
|
for (basis, key) in [
|
|
(ledger_core::parties::Basis::Cash, "cash"),
|
|
(ledger_core::parties::Basis::All, "all"),
|
|
] {
|
|
let p = mcp.ok(
|
|
"books.party",
|
|
json!({"party": name, "year": 2026, "basis": key}),
|
|
);
|
|
let (paid, received) = a.totals(basis);
|
|
assert_eq!(m(&p["totals"]["paid"]), paid, "{name} {key}");
|
|
assert_eq!(m(&p["totals"]["received"]), received, "{name} {key}");
|
|
}
|
|
}
|
|
}
|