P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
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:
swaitsandClaude Opus 5.5 committed 2026-10-21 09:35:32 -06:00
1 parent 2c29d48ab9
commit eb4329bb2a
64 files changed
+12128 -1567

No files matched your search

+1
View File
@@ -4,3 +4,4 @@
- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart; add a missing one with books.add_account first, never guess one.
- When unsure of the category, draft and ask. A draft is cheap; a wrong posting is a reversal.
- Name the entry's party when you draft it (books.draft_entry party), so the preview can show the split (the parties-and-tins section).
- An expense or purchase a member pays personally goes to the expense or asset against the members' financing account (Due to Members), the vendor named as the party; the cash flow statement shows it as borrowed from the member and spent. What the company owes a member for their own work or property (rent, a guaranteed payment) goes to a payable instead.
@@ -1,6 +1,7 @@
# Tracing figures
- Reports are pure functions of the posted entries. When the human questions a figure, trace it to its entries with the tools: the report (books.trial_balance, books.profit_and_loss, books.balance_sheet, books.cash_flow), then books.search_entries and books.get_entry for the entries behind it, and books.show to put them on the human's screen.
- A cash flow line names its entry (books.cash_flow: each line's entry, kind and accounts). An expense a member paid personally (or equipment a lender paid for) is cash borrowed and spent on its date: a financing line and an operating (or investing) line, each with on_behalf, the part no bank account moved; the reimbursement is a repayment. A noncash disclosure is an entry that moved no cash at all (property contributed, debt converted to capital). A warning names an entry whose later payment or collection the books can't classify, with the fix. A role refusal names the setting to change.
- Never guess, and never look for the books' files: the tools are the evidence.
- books.verify checks the books end to end when a number looks wrong or a human asks whether the books are intact.
- A member's odd cent: closes, the statement of members' equity, the K-1 worksheet and the viewer split earnings among the members one running way. Each close gives a member their share of everything closed to the same members and shares so far, to the cent (odd cents by largest remainder, ties to the member listed first when that count began), less what earlier closes gave them, so no member is ever a cent from their exact share. Earnings not yet closed are split the way their close will split them, so a member's capital on a day never changes when its month is closed. books.close_period's members shows each member's part, running total and exact share; the first close that posts may carry a one-time correction of the rounding left by closes made under an earlier rule (a cent or so per member; each member's this_close shows it) (books.list_periods' close_rule names each close's rule). A change of members or shares starts a new count.
+262 -122
View File
@@ -1245,6 +1245,46 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
} else {
" in the settings"
};
// The cash flow roles: codes the chart lacks here; the rest is the
// engine's own check, the one the statement refuses on (rules.md B-14
// rule 2), so the two never disagree.
let lists = [
(
ledger_core::RoleList::Cash,
label("books.cash_accounts", "[books] cash_accounts"),
&cfg.roles.cash,
),
(
ledger_core::RoleList::Financing,
label("books.financing_accounts", "[books] financing_accounts"),
&cfg.roles.financing,
),
(
ledger_core::RoleList::Investing,
label("books.investing_accounts", "[books] investing_accounts"),
&cfg.roles.investing,
),
];
let mut ids: [Vec<ledger_core::AccountId>; 3] = Default::default();
for ((_, setting, codes), ids) in lists.iter().zip(&mut ids) {
for code in codes.iter() {
match ledger.chart().find_by_code(code) {
Some(a) => ids.push(a.id),
None => out.push(format!(
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
)),
}
}
}
let named = |l: ledger_core::RoleList| {
lists
.iter()
.find(|(list, _, _)| *list == l)
.map_or(String::new(), |(_, setting, _)| setting.to_string())
};
for p in ledger_core::role_problems(ledger, &ids[0], &ids[1], &ids[2]) {
out.push(format!("{}{how}", p.message(named)));
}
let mut check = |setting: &str, code: &str, want: &[AccountType]| {
match ledger.chart().find_by_code(code) {
None => out.push(format!(
@@ -1260,27 +1300,7 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
}
};
use AccountType::*;
for code in &cfg.roles.cash {
check(
label("books.cash_accounts", "[books] cash_accounts"),
code,
&[Asset],
);
}
for code in &cfg.roles.financing {
check(
label("books.financing_accounts", "[books] financing_accounts"),
code,
&[Liability, Equity],
);
}
for code in &cfg.roles.investing {
check(
label("books.investing_accounts", "[books] investing_accounts"),
code,
&[Asset],
);
}
let c = &cfg.company;
for m in &c.members {
check(
@@ -2798,7 +2818,7 @@ impl BooksServer {
#[tool(
name = "books.cash_flow",
title = "Cash flow statement",
description = "Cash flow statement (indirect method) for a period: net income, depreciation add-back, working-capital changes, investing (asset purchases, disposal proceeds), financing. The three sections reconcile to the net change in the given cash accounts.",
description = "Statement of cash flows for a period, direct method (ASC 230): each entry that moved cash, by section and kind (operating by ASC 230-10-45-25 class; investing and financing by kind, such as each member's contributions), gross; an entry and its reversal in the same period drop out together. What a member or lender paid for the company is borrowed and spent on its date (on_behalf). Also noncash investing and financing activities (property contributed, debt converted), net income reconciled to operating cash, warnings, and reconciles: the sections sum to the change in the cash accounts. Account roles default to the books' settings.",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn cash_flow(
@@ -2816,25 +2836,7 @@ impl BooksServer {
&p.financing_accounts,
&p.investing_accounts,
)?;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut {
label: l.label.clone(),
amount: money(l.amount),
};
Ok(Json(CashFlowOut {
start: stmt.start.to_string(),
end: stmt.end.to_string(),
net_income: money(stmt.net_income),
depreciation: money(stmt.depreciation),
working_capital: stmt.working_capital.iter().map(line).collect(),
cash_from_operations: money(stmt.cash_from_operations),
investing: stmt.investing.iter().map(line).collect(),
cash_from_investing: money(stmt.cash_from_investing),
financing: stmt.financing.iter().map(line).collect(),
cash_from_financing: money(stmt.cash_from_financing),
net_change_in_cash: money(stmt.net_change_in_cash),
cash_beginning: money(stmt.cash_beginning),
cash_ending: money(stmt.cash_ending),
}))
Ok(Json(cash_flow_out(&stmt)))
}
#[tool(
@@ -5330,7 +5332,7 @@ impl BooksServer {
#[tool(
name = "books.export",
title = "Export a report",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of, period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, fixed_asset_register, and iif (default today).",
description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), general_ledger (by account: running balances, splits), general_journal (every entry in date order; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books). Formats: pdf and xlsx (base64) for the five statements and fixed_asset_register; xlsx, csv, json for every report; iif for general_journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of, period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (start defaults to the first entry, end to today), as_of for balance_sheet, trial_balance, fixed_asset_register, and iif (default today).",
annotations(read_only_hint = true, open_world_hint = false)
)]
async fn export(&self, params: Parameters<ExportParams>) -> Result<Json<ExportOut>, String> {
@@ -5431,17 +5433,14 @@ impl BooksServer {
let earnings = self.year_earnings(ledger, bs.as_of)?;
report::balance_sheet(&bs, earnings, &members)
}
"cash_flow" => report::cash_flow(
&self.cash_flow_for(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
&p.investing_accounts,
)?,
&members,
),
"cash_flow" => report::cash_flow(&self.cash_flow_for(
ledger,
p.start.as_deref(),
p.end.as_deref(),
&p.cash_accounts,
&p.financing_accounts,
&p.investing_accounts,
)?),
"trial_balance" => {
let as_of = match &p.as_of {
Some(s) => parse_date(s)?,
@@ -8098,7 +8097,7 @@ impl BooksServer {
#[tool(
name = "books.export_cpa_package",
title = "CPA handoff package",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow, statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was bought by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -8602,78 +8601,219 @@ impl BooksServer {
&p.investing_accounts,
)?;
if format == "json" {
return serde_json::to_string_pretty(&serde_json::json!({
"start": stmt.start.to_string(), "end": stmt.end.to_string(),
"net_income": money(stmt.net_income),
"depreciation": money(stmt.depreciation),
"working_capital": stmt.working_capital.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_operations": money(stmt.cash_from_operations),
"investing": stmt.investing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_investing": money(stmt.cash_from_investing),
"financing": stmt.financing.iter().map(|l| serde_json::json!({
"label": l.label, "amount": money(l.amount),
})).collect::<Vec<_>>(),
"cash_from_financing": money(stmt.cash_from_financing),
"net_change_in_cash": money(stmt.net_change_in_cash),
"cash_beginning": money(stmt.cash_beginning),
"cash_ending": money(stmt.cash_ending),
}))
.map_err(|e| e.to_string());
// The tool's own answer, from the one function (they can't
// drift apart).
return serde_json::to_string_pretty(&cash_flow_out(&stmt)).map_err(|e| e.to_string());
}
let mut csv = String::from("section,label,amount\n");
csv.push_str(&format!(
"operating,Net income,{}\n",
money(stmt.net_income)
));
csv.push_str(&format!(
"operating,Depreciation,{}\n",
money(stmt.depreciation)
));
for l in &stmt.working_capital {
csv.push_str(&format!(
"operating,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"operating,Cash from operations,{}\n",
money(stmt.cash_from_operations)
));
for l in &stmt.investing {
csv.push_str(&format!(
"investing,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"investing,Cash from investing,{}\n",
money(stmt.cash_from_investing)
));
for l in &stmt.financing {
csv.push_str(&format!(
"financing,{},{}\n",
csv_escape(&l.label),
money(l.amount)
));
}
csv.push_str(&format!(
"financing,Cash from financing,{}\n",
money(stmt.cash_from_financing)
));
csv.push_str(&format!(
"reconciliation,Net change in cash,{}\n",
money(stmt.net_change_in_cash)
));
Ok(csv)
Ok(cash_flow_csv(&stmt))
}
}
/// `books.cash_flow`'s answer, and `books.export`'s cash flow JSON.
fn cash_flow_out(stmt: &ledger_core::CashFlowStatement) -> CashFlowOut {
use ledger_core::CashFlowSection as S;
let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut {
entry: l.entry,
date: l.date.to_string(),
label: l.label.clone(),
kind: l.kind.clone(),
amount: money(l.amount),
on_behalf: (!l.on_behalf.is_zero()).then(|| money(l.on_behalf)),
accounts: l.accounts.clone(),
reverses: l.reverses,
class: l.class.map(|c| c.key().to_string()),
};
let kinds = |sec: S| {
stmt.kinds(sec)
.into_iter()
.map(|k| CashFlowKindOut {
kind: k.kind,
class: k.class.map(|c| c.key().to_string()),
amount: money(k.amount),
entries: k.entries,
})
.collect()
};
let r = &stmt.operating_reconciliation;
CashFlowOut {
method: "direct".to_string(),
start: stmt.start.to_string(),
end: stmt.end.to_string(),
operating_by_class: kinds(S::Operating),
operating: stmt.operating.iter().map(line).collect(),
cash_from_operations: money(stmt.cash_from_operations),
investing_by_kind: kinds(S::Investing),
investing: stmt.investing.iter().map(line).collect(),
cash_from_investing: money(stmt.cash_from_investing),
financing_by_kind: kinds(S::Financing),
financing: stmt.financing.iter().map(line).collect(),
cash_from_financing: money(stmt.cash_from_financing),
net_change_in_cash: money(stmt.net_change_in_cash),
cash_beginning: money(stmt.cash_beginning),
cash_ending: money(stmt.cash_ending),
noncash_disclosures: stmt
.noncash_disclosures
.iter()
.map(|n| NoncashDisclosureOut {
entry: n.entry,
date: n.date.to_string(),
label: n.label.clone(),
amount: money(n.amount),
operating: money(n.operating),
investing: money(n.investing),
financing: money(n.financing),
exchanged: money(n.exchanged),
reverses: n.reverses,
})
.collect(),
operating_reconciliation: OperatingReconciliationOut {
net_income: money(r.net_income),
lines: r
.lines
.iter()
.map(|l| ReconcilingLineOut {
kind: l.kind.key().to_string(),
label: l.label.clone(),
amount: money(l.amount),
account: l.account.clone(),
entries: l.entries.clone(),
})
.collect(),
net_cash_from_operating: money(r.net_cash_from_operating),
},
reconciles: CashFlowFootingOut {
sections_sum: money(stmt.reconciles.sections_sum),
net_change: money(stmt.reconciles.net_change),
difference: money(stmt.reconciles.difference),
},
warnings: stmt
.warnings
.iter()
.map(|w| CashFlowWarningOut {
entry: w.entry,
kind: w.kind.key().to_string(),
message: w.message.clone(),
})
.collect(),
}
}
/// `books.export`'s cash flow CSV:
/// `section,kind,class,entry,date,label,amount,on_behalf`, every line of
/// every section (`on_behalf`: of its amount, what a member or lender
/// paid or received for the company, D-42), the reconciliation, the
/// noncash disclosures, the footing and the warnings (`warning`, its
/// kind, its entry and its message; no amount). Only a section's lines
/// have an `on_behalf`.
fn cash_flow_csv(stmt: &ledger_core::CashFlowStatement) -> String {
use ledger_core::CashFlowSection as S;
let mut csv = String::from("section,kind,class,entry,date,label,amount,on_behalf\n");
let mut row = |section: &str,
kind: &str,
class: &str,
entry: Option<u64>,
date: Option<chrono::NaiveDate>,
label: &str,
amount: rust_decimal::Decimal,
on_behalf: Option<rust_decimal::Decimal>| {
csv.push_str(&format!(
"{section},{},{class},{},{},{},{},{}\n",
csv_escape(kind),
entry.map_or(String::new(), |e| e.to_string()),
date.map_or(String::new(), |d| d.to_string()),
csv_escape(label),
money(amount),
on_behalf.map_or(String::new(), money)
));
};
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 stmt.lines(sec) {
let class = l.class.map_or("", |c| c.key());
row(
sec.key(),
&l.kind,
class,
Some(l.entry),
Some(l.date),
&l.label,
l.amount,
Some(l.on_behalf),
);
}
row(sec.key(), "", "", None, None, total, stmt.total(sec), None);
}
let r = &stmt.operating_reconciliation;
row(
"reconciliation",
"",
"",
None,
None,
"Net income (loss)",
r.net_income,
None,
);
for l in &r.lines {
row(
"reconciliation",
l.kind.key(),
"",
None,
None,
&l.label,
l.amount,
None,
);
}
row(
"reconciliation",
"",
"",
None,
None,
"Net cash from operating activities",
r.net_cash_from_operating,
None,
);
for n in &stmt.noncash_disclosures {
row(
"noncash",
"",
"",
Some(n.entry),
Some(n.date),
&n.label,
n.amount,
None,
);
}
let f = &stmt.reconciles;
for (label, amount) in [
("Net increase (decrease) in cash", stmt.net_change_in_cash),
("Cash at beginning of period", stmt.cash_beginning),
("Cash at end of period", stmt.cash_ending),
("Sections sum", f.sections_sum),
("Difference", f.difference),
] {
row("footing", "", "", None, None, label, amount, None);
}
// The warnings, as the tool gives them: no amount (figures are
// unaffected).
for w in &stmt.warnings {
csv.push_str(&format!(
"warning,{},,{},,{},,\n",
w.kind.key(),
w.entry,
csv_escape(&w.message)
));
}
csv
}
// ---------------------------------------------------------------------------
// ServerHandler
// ---------------------------------------------------------------------------
+165 -26
View File
@@ -807,61 +807,200 @@ pub struct CashFlowParams {
pub start: Option<String>,
/// Period end, YYYY-MM-DD. Defaults to today.
pub end: Option<String>,
/// Account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement reconciles to the net change in these accounts. Defaults
/// to `[books] cash_accounts`; one or the other is required.
/// Asset account codes treated as cash (e.g. `["1000", "1010"]`): the
/// statement explains the change in these accounts. Defaults to the
/// books' cash accounts setting (books.settings shows it); one or the
/// other is required.
#[serde(default)]
pub cash_accounts: Vec<String>,
/// Account codes for liabilities treated as financing (loans, and what
/// owners advanced: ASC 230-10-45-14(b)). Defaults to `[books]
/// financing_accounts`, else empty (liabilities are working capital).
/// Liability account codes treated as financing (loans, and what
/// members advanced: ASC 230-10-45-14(b)); equity is always financing.
/// Defaults to the books' financing accounts setting, else empty
/// (other liabilities are operating).
#[serde(default)]
pub financing_accounts: Vec<String>,
/// Asset account codes treated as investing for cash_flow: loans the
/// company makes, investments it buys (ASC 230-10-20). Defaults to
/// `[books] investing_accounts`.
/// Asset account codes treated as investing: loans the company makes,
/// investments it buys (ASC 230-10-20). Registered fixed assets are
/// investing unlisted. Defaults to the books' investing accounts
/// setting.
#[serde(default)]
pub investing_accounts: Vec<String>,
}
/// One cash flow line.
/// One entry's cash in one kind of one section of the cash flow
/// statement.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowLineOut {
/// Human label.
/// The entry that moved the cash.
pub entry: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// "<description> (#N)", "Reversal of #N — <N's description>", or
/// "Reinstatement of #A (reverses #B)".
pub label: String,
/// Signed amount, decimal string. Positive = inflow.
/// The kind it is listed under: an operating class ("Cash paid to
/// suppliers and employees") or an investing or financing kind
/// ("Contributions from members — <member>").
pub kind: String,
/// The cash, decimal string; positive is cash in.
pub amount: String,
/// Of `amount`, what a member or a lender paid or received for the
/// company with no cash account moving (a constructive receipt or
/// payment, D-42: an expense a member paid personally is borrowed from
/// the member and spent), decimal string; absent when none.
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf: Option<String>,
/// Codes of this kind's accounts the entry moved.
pub accounts: Vec<String>,
/// The entry this one reverses, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub reverses: Option<u64>,
/// Operating lines: the ASC 230-10-45-25 class key
/// (`from_customers`, `interest_and_dividends_received`,
/// `other_receipts`, `to_suppliers_and_employees`, `interest_paid`,
/// `income_taxes_paid`, `other_payments`).
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
}
/// Result of `books.cash_flow`.
/// A kind's total: how the printed statement and the viewer show a
/// section.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowKindOut {
/// The caption.
pub kind: String,
/// The operating class key, for an operating kind.
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The kind's cash, decimal string.
pub amount: String,
/// Its entries.
pub entries: Vec<u64>,
}
/// One reconciling item between net income and operating cash.
#[derive(Debug, Serialize, JsonSchema)]
pub struct ReconcilingLineOut {
/// `depreciation`, `investing_gain_loss`, `noncash_through`,
/// `other_noncash`, or `operating_balance_change`.
pub kind: String,
/// "Depreciation", "Noncash: through 2500 Bank loan", "Increase
/// in 1300 Prepaid Expenses", ...
pub label: String,
/// Added to net income, decimal string.
pub amount: String,
/// The account it is about, if one.
#[serde(skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
/// The entries behind it (an entry and its reversal in the period
/// left out).
pub entries: Vec<u64>,
}
/// Net income reconciled to net cash from operating activities (ASC
/// 230-10-45-30: a separate schedule with the direct method).
#[derive(Debug, Serialize, JsonSchema)]
pub struct OperatingReconciliationOut {
/// The period's net income, decimal string.
pub net_income: String,
/// The reconciling items.
pub lines: Vec<ReconcilingLineOut>,
/// Net income plus the items: equal to cash_from_operations.
pub net_cash_from_operating: String,
}
/// A noncash investing or financing activity (ASC 230-10-50-3).
#[derive(Debug, Serialize, JsonSchema)]
pub struct NoncashDisclosureOut {
/// The entry.
pub entry: u64,
/// Its date, YYYY-MM-DD.
pub date: String,
/// Its label.
pub label: String,
/// The amount disclosed, decimal string.
pub amount: String,
/// The operating part no cash settled, debit-positive.
pub operating: String,
/// The investing part no cash settled, debit-positive.
pub investing: String,
/// The financing part no cash settled, debit-positive.
pub financing: String,
/// What was exchanged inside investing and financing (debt to equity,
/// one asset for another).
pub exchanged: String,
/// The entry it reverses, if any.
#[serde(skip_serializing_if = "Option::is_none")]
pub reverses: Option<u64>,
}
/// The footing check: always a zero difference (a nonzero one is an
/// engine error, never output).
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowFootingOut {
/// Operating + investing + financing.
pub sections_sum: String,
/// The cash accounts' ending balance less their beginning balance.
pub net_change: String,
/// sections_sum − net_change: "0.00".
pub difference: String,
}
/// An entry whose later settlement the books can't classify by
/// themselves, with what to change.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowWarningOut {
/// The entry.
pub entry: u64,
/// `asset_on_operating_payable` or `asset_sold_on_operating_receivable`.
pub kind: String,
/// What happened and the fix.
pub message: String,
}
/// Result of `books.cash_flow`; `books.export` writes the same JSON.
#[derive(Debug, Serialize, JsonSchema)]
pub struct CashFlowOut {
/// "direct" (ASC 230-10-45-25).
pub method: String,
/// Period start, YYYY-MM-DD.
pub start: String,
/// Period end, YYYY-MM-DD.
pub end: String,
/// Period net income, decimal string.
pub net_income: String,
/// Non-cash depreciation added back, decimal string.
pub depreciation: String,
/// Working-capital adjustments.
pub working_capital: Vec<CashFlowLineOut>,
/// Cash from operations, decimal string.
/// Operating cash by ASC 230-10-45-25 class, (a) to (g).
pub operating_by_class: Vec<CashFlowKindOut>,
/// Every entry's operating cash, by class, then date.
pub operating: Vec<CashFlowLineOut>,
/// Net cash from operating activities, decimal string.
pub cash_from_operations: String,
/// Investing flows (purchases, disposal proceeds).
/// Investing cash by kind (purchases and sales of property and
/// equipment, loans made and collected).
pub investing_by_kind: Vec<CashFlowKindOut>,
/// Every entry's investing cash, by kind, then date.
pub investing: Vec<CashFlowLineOut>,
/// Cash from investing, decimal string.
/// Net cash from investing activities, decimal string.
pub cash_from_investing: String,
/// Financing flows (equity, designated debt).
/// Financing cash by kind (contributions and distributions per member,
/// borrowing and repayments per lender).
pub financing_by_kind: Vec<CashFlowKindOut>,
/// Every entry's financing cash, by kind, then date.
pub financing: Vec<CashFlowLineOut>,
/// Cash from financing, decimal string.
/// Net cash from financing activities, decimal string.
pub cash_from_financing: String,
/// Net change in cash, decimal string.
pub net_change_in_cash: String,
/// Cash balance at period start, decimal string.
/// Cash at the start, decimal string.
pub cash_beginning: String,
/// Cash balance at period end, decimal string.
/// Cash at the end, decimal string.
pub cash_ending: String,
/// Noncash investing and financing activities.
pub noncash_disclosures: Vec<NoncashDisclosureOut>,
/// Net income reconciled to operating cash.
pub operating_reconciliation: OperatingReconciliationOut,
/// The footing check.
pub reconciles: CashFlowFootingOut,
/// Entries whose later settlement the books can't classify.
pub warnings: Vec<CashFlowWarningOut>,
}
/// Parameters for `books.import_bank_csv`.
+86 -4
View File
@@ -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
+253 -37
View File
@@ -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
+6 -2
View File
@@ -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}"
);
}