P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
Direct method with the operating reconciliation and noncash schedules; it foots by construction (Lean and Kani). Every non-cash account belongs to one section; reversals net; a member or lender paying for the company is cash borrowed and spent (D-42, constructive receipt and disbursement); property a member contributes stays noncash. The bookspec seed: one loader checks every example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
64 files changed
+12128
-1567
No files matched your search
@@ -2379,8 +2379,11 @@ fn a_restarted_server_waits_for_the_old_one_to_let_go() {
|
||||
}
|
||||
|
||||
/// `books.cash_flow` with no accounts passed reads the roles from
|
||||
/// `[books]` in wanderbooks.toml (rules.md B-14): the owners' advance is
|
||||
/// financing, gross; a loan to a member is investing.
|
||||
/// `[books]` in wanderbooks.toml (rules.md B-14): the expense the owners
|
||||
/// paid through 2100 is borrowed from them and spent on its date, each
|
||||
/// line with `on_behalf` (D-42, rule 11); repaying the advance is
|
||||
/// financing; a loan to a member is investing; the statement is the direct
|
||||
/// method's, and it foots.
|
||||
#[test]
|
||||
fn cash_flow_reads_its_account_roles_from_the_books_settings() {
|
||||
let dir = test_dir("cash-roles");
|
||||
@@ -2448,10 +2451,89 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
|
||||
.map(|l| l["amount"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
};
|
||||
// Financing, gross and sorted by code: 2100 in and out, then capital.
|
||||
assert_eq!(amounts("financing"), ["100.00", "-100.00", "1000.00"]);
|
||||
// Financing by kind: the contribution, what the owners paid for the
|
||||
// company (borrowed through 2100), then the repayment of 2100.
|
||||
assert_eq!(amounts("financing"), ["1000.00", "100.00", "-100.00"]);
|
||||
assert_eq!(cf["financing"][1]["entry"], 2);
|
||||
assert_eq!(cf["financing"][1]["on_behalf"], "100.00");
|
||||
assert!(cf["financing"][0].get("on_behalf").is_none());
|
||||
let kinds: Vec<&str> = cf["financing_by_kind"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|k| k["kind"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
"Contributions from owners — 3000 Capital",
|
||||
"Proceeds from borrowing — 2100 Due to owners",
|
||||
"Repayments of borrowing — 2100 Due to owners"
|
||||
]
|
||||
);
|
||||
assert_eq!(amounts("investing"), ["-500.00"]);
|
||||
assert_eq!(amounts("operating"), ["-100.00"]);
|
||||
assert_eq!(cf["operating"][0]["on_behalf"], "-100.00");
|
||||
assert_eq!(cf["operating"][0]["class"], "to_suppliers_and_employees");
|
||||
assert_eq!(cf["method"], "direct");
|
||||
assert_eq!(cf["net_change_in_cash"], "400.00");
|
||||
assert_eq!(cf["reconciles"]["sections_sum"], "400.00");
|
||||
assert_eq!(cf["reconciles"]["difference"], "0.00");
|
||||
// Nothing noncash: the software is net income's only item.
|
||||
assert_eq!(cf["noncash_disclosures"], json!([]));
|
||||
let r = &cf["operating_reconciliation"];
|
||||
assert_eq!(r["net_income"], "-100.00");
|
||||
assert_eq!(r["lines"], json!([]));
|
||||
assert_eq!(r["net_cash_from_operating"], "-100.00");
|
||||
for gone in ["net_income", "depreciation", "working_capital"] {
|
||||
assert!(cf.get(gone).is_none(), "{gone} is gone");
|
||||
}
|
||||
}
|
||||
|
||||
/// rules.md B-14 rule 2: roles that contradict the chart are refused,
|
||||
/// naming the setting and the fix, never a statement on a wrong partition.
|
||||
#[test]
|
||||
fn cash_flow_refuses_contradictory_roles() {
|
||||
let dir = test_dir("cash-refuse");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
for (code, name, typ) in [
|
||||
("1000", "Bank", "asset"),
|
||||
("1500", "Equipment", "asset"),
|
||||
("2100", "Due to owners", "liability"),
|
||||
] {
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": typ}),
|
||||
);
|
||||
}
|
||||
let err = mcp.call_err(
|
||||
"books.cash_flow",
|
||||
json!({"cash_accounts": ["1000"], "financing_accounts": ["1500"]}),
|
||||
);
|
||||
assert!(
|
||||
err.contains("financing_accounts (from the call or books.financing_accounts) lists 1500"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("financing accounts are liabilities"), "{err}");
|
||||
let err = mcp.call_err(
|
||||
"books.cash_flow",
|
||||
json!({"cash_accounts": ["1000", "2100"]}),
|
||||
);
|
||||
assert!(err.contains("cash accounts are asset accounts"), "{err}");
|
||||
let err = mcp.call_err(
|
||||
"books.cash_flow",
|
||||
json!({"cash_accounts": ["1000"], "investing_accounts": ["1000"]}),
|
||||
);
|
||||
assert!(err.contains("is in both cash_accounts"), "{err}");
|
||||
}
|
||||
|
||||
/// A chart CSV with a bad row anywhere is refused whole: nothing is
|
||||
|
||||
@@ -782,12 +782,28 @@ fn pnl_and_balance_sheet_exports_equal_the_core_statements() {
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// account roles and members, field for field (rules.md B-14; the JSON is
|
||||
/// the tool's own answer). Kills: export_cash_flow stubs and its json/csv
|
||||
/// switch, and any field the mapping drops.
|
||||
#[test]
|
||||
fn cash_flow_exports_equal_the_core_statement() {
|
||||
let dir = test_dir("cf");
|
||||
let mut s = new_store(&dir);
|
||||
rich_books(&mut s);
|
||||
// Equipment on an operating payable: a warning, in the CSV too.
|
||||
post(
|
||||
&mut s,
|
||||
"2025-04-02",
|
||||
"Monitor on account",
|
||||
&[("1500", Dr, "250.00"), ("2000", Cr, "250.00")],
|
||||
);
|
||||
let member = |name: &str, code: &str| ledger_core::MemberCapital {
|
||||
name: name.into(),
|
||||
capital_account: id_of(&s, code),
|
||||
distribution_account: None,
|
||||
share: Decimal::new(5, 1),
|
||||
};
|
||||
let members = vec![member("A", "3000"), member("B", "3001")];
|
||||
let cf = ledger_core::cash_flow(
|
||||
s.ledger(),
|
||||
&CashFlowParams {
|
||||
@@ -796,6 +812,7 @@ fn cash_flow_exports_equal_the_core_statement() {
|
||||
cash_accounts: vec![id_of(&s, "1000")],
|
||||
financing_accounts: vec![id_of(&s, "2100")],
|
||||
investing_accounts: vec![],
|
||||
members,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -805,54 +822,173 @@ fn cash_flow_exports_equal_the_core_statement() {
|
||||
json!({"format": format, "report": "cash_flow", "start": START, "end": END,
|
||||
"cash_accounts": ["1000"], "financing_accounts": ["2100"]})
|
||||
};
|
||||
use ledger_core::CashFlowSection as S;
|
||||
|
||||
let csv = m.export_text(args("csv"));
|
||||
let rows = csv_rows(&csv, &["section", "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));
|
||||
let rows = csv_rows(
|
||||
&csv,
|
||||
&[
|
||||
"section",
|
||||
"kind",
|
||||
"class",
|
||||
"entry",
|
||||
"date",
|
||||
"label",
|
||||
"amount",
|
||||
"on_behalf",
|
||||
],
|
||||
);
|
||||
let row = |sec: &str, kind: &str, class: &str, entry: String, date: String, label: &str, v| {
|
||||
(
|
||||
sec.to_string(),
|
||||
kind.to_string(),
|
||||
class.to_string(),
|
||||
entry,
|
||||
date,
|
||||
label.to_string(),
|
||||
v,
|
||||
None,
|
||||
)
|
||||
};
|
||||
let mut want = Vec::new();
|
||||
for (sec, total) in [
|
||||
(S::Operating, "Net cash from operating activities"),
|
||||
(S::Investing, "Net cash from investing activities"),
|
||||
(S::Financing, "Net cash from financing activities"),
|
||||
] {
|
||||
for l in cf.lines(sec) {
|
||||
let mut r = row(
|
||||
sec.key(),
|
||||
&l.kind,
|
||||
l.class.map_or("", |c| c.key()),
|
||||
l.entry.to_string(),
|
||||
l.date.to_string(),
|
||||
&l.label,
|
||||
l.amount,
|
||||
);
|
||||
r.7 = Some(l.on_behalf);
|
||||
want.push(r);
|
||||
}
|
||||
want.push(row(
|
||||
sec.key(),
|
||||
"",
|
||||
"",
|
||||
String::new(),
|
||||
String::new(),
|
||||
total,
|
||||
cf.total(sec),
|
||||
));
|
||||
}
|
||||
want.push((
|
||||
"operating".into(),
|
||||
"Cash from operations".into(),
|
||||
cf.cash_from_operations,
|
||||
let r = &cf.operating_reconciliation;
|
||||
want.push(row(
|
||||
"reconciliation",
|
||||
"",
|
||||
"",
|
||||
String::new(),
|
||||
String::new(),
|
||||
"Net income (loss)",
|
||||
r.net_income,
|
||||
));
|
||||
for l in &cf.investing {
|
||||
want.push(("investing".into(), l.label.clone(), l.amount));
|
||||
for l in &r.lines {
|
||||
want.push(row(
|
||||
"reconciliation",
|
||||
l.kind.key(),
|
||||
"",
|
||||
String::new(),
|
||||
String::new(),
|
||||
&l.label,
|
||||
l.amount,
|
||||
));
|
||||
}
|
||||
want.push((
|
||||
"investing".into(),
|
||||
"Cash from investing".into(),
|
||||
cf.cash_from_investing,
|
||||
want.push(row(
|
||||
"reconciliation",
|
||||
"",
|
||||
"",
|
||||
String::new(),
|
||||
String::new(),
|
||||
"Net cash from operating activities",
|
||||
r.net_cash_from_operating,
|
||||
));
|
||||
for l in &cf.financing {
|
||||
want.push(("financing".into(), l.label.clone(), l.amount));
|
||||
for n in &cf.noncash_disclosures {
|
||||
want.push(row(
|
||||
"noncash",
|
||||
"",
|
||||
"",
|
||||
n.entry.to_string(),
|
||||
n.date.to_string(),
|
||||
&n.label,
|
||||
n.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,
|
||||
));
|
||||
for (label, v) in [
|
||||
("Net increase (decrease) in cash", cf.net_change_in_cash),
|
||||
("Cash at beginning of period", cf.cash_beginning),
|
||||
("Cash at end of period", cf.cash_ending),
|
||||
("Sections sum", cf.reconciles.sections_sum),
|
||||
("Difference", cf.reconciles.difference),
|
||||
] {
|
||||
want.push(row(
|
||||
"footing",
|
||||
"",
|
||||
"",
|
||||
String::new(),
|
||||
String::new(),
|
||||
label,
|
||||
v,
|
||||
));
|
||||
}
|
||||
// The warnings close the CSV: kind, entry and message, no amount.
|
||||
let (warned, rows): (Vec<_>, Vec<_>) = rows.into_iter().partition(|r| r[0] == "warning");
|
||||
let want_warned: Vec<Vec<String>> = cf
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|w| {
|
||||
vec![
|
||||
"warning".to_string(),
|
||||
w.kind.key().to_string(),
|
||||
String::new(),
|
||||
w.entry.to_string(),
|
||||
String::new(),
|
||||
w.message.clone(),
|
||||
String::new(),
|
||||
String::new(),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(warned, want_warned, "cash flow CSV warnings");
|
||||
assert_eq!(cf.warnings.len(), 1, "the monitor bought on account");
|
||||
let got: Vec<_> = rows
|
||||
.iter()
|
||||
.map(|r| (r[0].clone(), r[1].clone(), amt(&r[2])))
|
||||
.map(|r| {
|
||||
(
|
||||
r[0].clone(),
|
||||
r[1].clone(),
|
||||
r[2].clone(),
|
||||
r[3].clone(),
|
||||
r[4].clone(),
|
||||
r[5].clone(),
|
||||
amt(&r[6]),
|
||||
(!r[7].is_empty()).then(|| amt(&r[7])),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(got, want, "cash flow CSV");
|
||||
assert!(
|
||||
cf.operating.len() + cf.financing.len() > 1,
|
||||
"the books move cash"
|
||||
);
|
||||
// The lunch a member paid through 2100 is borrowed and spent (D-42):
|
||||
// its lines carry what was paid on the company's behalf.
|
||||
assert!(
|
||||
cf.financing.iter().any(|l| !l.on_behalf.is_zero()),
|
||||
"a payment made for the company"
|
||||
);
|
||||
|
||||
let j: Value = serde_json::from_str(&m.export_text(args("json"))).unwrap();
|
||||
assert_eq!(j["method"], "direct");
|
||||
assert_eq!(j["start"], START);
|
||||
assert_eq!(j["end"], END);
|
||||
for (k, v) in [
|
||||
("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),
|
||||
@@ -862,18 +998,98 @@ fn cash_flow_exports_equal_the_core_statement() {
|
||||
] {
|
||||
assert_eq!(jamt(&j[k]), v, "cash flow {k}");
|
||||
}
|
||||
for (k, lines) in [
|
||||
("working_capital", &cf.working_capital),
|
||||
("investing", &cf.investing),
|
||||
("financing", &cf.financing),
|
||||
for (k, sec) in [
|
||||
("operating", S::Operating),
|
||||
("investing", S::Investing),
|
||||
("financing", S::Financing),
|
||||
] {
|
||||
let lines = cf.lines(sec);
|
||||
let got = j[k].as_array().unwrap();
|
||||
assert_eq!(got.len(), lines.len(), "{k}");
|
||||
for (g, l) in got.iter().zip(lines) {
|
||||
assert_eq!(g["entry"], json!(l.entry));
|
||||
assert_eq!(g["date"], l.date.to_string());
|
||||
assert_eq!(g["label"], l.label.as_str());
|
||||
assert_eq!(g["kind"], l.kind.as_str());
|
||||
assert_eq!(jamt(&g["amount"]), l.amount);
|
||||
if l.on_behalf.is_zero() {
|
||||
assert!(g.get("on_behalf").is_none(), "{g}");
|
||||
} else {
|
||||
assert_eq!(jamt(&g["on_behalf"]), l.on_behalf);
|
||||
}
|
||||
assert_eq!(g["accounts"], json!(l.accounts));
|
||||
assert_eq!(g["reverses"], json!(l.reverses));
|
||||
assert_eq!(g["class"], json!(l.class.map(|c| c.key())));
|
||||
}
|
||||
let by = if sec == S::Operating {
|
||||
"operating_by_class".to_string()
|
||||
} else {
|
||||
format!("{k}_by_kind")
|
||||
};
|
||||
let kinds = cf.kinds(sec);
|
||||
let got = j[&by].as_array().unwrap();
|
||||
assert_eq!(got.len(), kinds.len(), "{by}");
|
||||
for (g, kd) in got.iter().zip(&kinds) {
|
||||
assert_eq!(g["kind"], kd.kind.as_str());
|
||||
assert_eq!(jamt(&g["amount"]), kd.amount);
|
||||
assert_eq!(g["entries"], json!(kd.entries));
|
||||
assert_eq!(g["class"], json!(kd.class.map(|c| c.key())));
|
||||
}
|
||||
}
|
||||
let got = j["noncash_disclosures"].as_array().unwrap();
|
||||
assert_eq!(got.len(), cf.noncash_disclosures.len());
|
||||
for (g, n) in got.iter().zip(&cf.noncash_disclosures) {
|
||||
assert_eq!(g["entry"], json!(n.entry));
|
||||
assert_eq!(g["date"], n.date.to_string());
|
||||
assert_eq!(g["label"], n.label.as_str());
|
||||
for (k, v) in [
|
||||
("amount", n.amount),
|
||||
("operating", n.operating),
|
||||
("investing", n.investing),
|
||||
("financing", n.financing),
|
||||
("exchanged", n.exchanged),
|
||||
] {
|
||||
assert_eq!(jamt(&g[k]), v, "disclosure {k}");
|
||||
}
|
||||
assert_eq!(g["reverses"], json!(n.reverses));
|
||||
}
|
||||
let rj = &j["operating_reconciliation"];
|
||||
assert_eq!(jamt(&rj["net_income"]), r.net_income);
|
||||
assert_eq!(
|
||||
jamt(&rj["net_cash_from_operating"]),
|
||||
r.net_cash_from_operating
|
||||
);
|
||||
let got = rj["lines"].as_array().unwrap();
|
||||
assert_eq!(got.len(), r.lines.len());
|
||||
for (g, l) in got.iter().zip(&r.lines) {
|
||||
assert_eq!(g["kind"], l.kind.key());
|
||||
assert_eq!(g["label"], l.label.as_str());
|
||||
assert_eq!(jamt(&g["amount"]), l.amount);
|
||||
assert_eq!(g["account"], json!(l.account));
|
||||
assert_eq!(g["entries"], json!(l.entries));
|
||||
}
|
||||
assert_eq!(
|
||||
jamt(&j["reconciles"]["sections_sum"]),
|
||||
cf.reconciles.sections_sum
|
||||
);
|
||||
assert_eq!(
|
||||
jamt(&j["reconciles"]["net_change"]),
|
||||
cf.reconciles.net_change
|
||||
);
|
||||
assert_eq!(j["reconciles"]["difference"], "0.00");
|
||||
let got = j["warnings"].as_array().unwrap();
|
||||
assert_eq!(got.len(), cf.warnings.len());
|
||||
for (g, w) in got.iter().zip(&cf.warnings) {
|
||||
assert_eq!(g["entry"], json!(w.entry));
|
||||
assert_eq!(g["kind"], w.kind.key());
|
||||
assert_eq!(g["message"], w.message.as_str());
|
||||
}
|
||||
// The tool's answer is the export's JSON.
|
||||
let tool = m.ok(
|
||||
"books.cash_flow",
|
||||
json!({"start": START, "end": END, "cash_accounts": ["1000"], "financing_accounts": ["2100"]}),
|
||||
);
|
||||
assert_eq!(tool, j);
|
||||
}
|
||||
|
||||
/// The journal export, CSV and JSON, is every posted entry line by line
|
||||
|
||||
@@ -2510,7 +2510,11 @@ fn settings_that_disagree_with_the_chart_are_problems() {
|
||||
.map(|p| p.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(problems[0].contains("[books] financing_accounts names 6000"));
|
||||
assert!(
|
||||
problems[0].contains("[books] financing_accounts lists 6000"),
|
||||
"{problems:?}"
|
||||
);
|
||||
assert!(problems[0].ends_with("remove it from [books] financing_accounts in the settings"));
|
||||
let text = std::fs::read_to_string(&toml)
|
||||
.unwrap()
|
||||
.replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]")
|
||||
@@ -2527,7 +2531,7 @@ fn settings_that_disagree_with_the_chart_are_problems() {
|
||||
"{problems}"
|
||||
);
|
||||
assert!(
|
||||
problems.contains("[books] financing_accounts names 6000"),
|
||||
problems.contains("[books] financing_accounts lists 6000"),
|
||||
"{problems}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user