diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index 1817b85..be83ee5 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -97,6 +97,14 @@ pub struct BooksSettings { /// savings, cash on hand), for cash reports and dashboards. Empty: /// callers decide (e.g. by account name). pub cash_accounts: Vec, + /// `[books] financing_accounts`: liability account codes the cash flow + /// statement treats as financing (loans, and what owners advanced: + /// ASC 230-10-45-14(2)). Empty: callers decide. + pub financing_accounts: Vec, + /// `[books] investing_accounts`: asset account codes the cash flow + /// statement treats as investing (loans the company makes, investments + /// it buys: ASC 230-10-20). Registered fixed assets need not be listed. + pub investing_accounts: Vec, } #[derive(Deserialize)] @@ -134,6 +142,10 @@ struct BooksSection { close_sweep: Vec, #[serde(default)] cash_accounts: Vec, + #[serde(default)] + financing_accounts: Vec, + #[serde(default)] + investing_accounts: Vec, } /// The return a federal tax classification files: "partnership" → 1065, @@ -269,6 +281,8 @@ pub fn parse(text: &str) -> Result { company, receipt_threshold, cash_accounts: raw.books.cash_accounts, + financing_accounts: raw.books.financing_accounts, + investing_accounts: raw.books.investing_accounts, }) } @@ -337,7 +351,7 @@ mod tests { #[test] fn keeps_the_whole_profile_in_order() { let s = parse( - "[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\n", + "[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n", ) .unwrap(); assert_eq!( @@ -349,6 +363,8 @@ mod tests { ] ); assert_eq!(s.cash_accounts, ["1000", "1010"]); + assert_eq!(s.financing_accounts, ["2100"]); + assert_eq!(s.investing_accounts, ["1400"]); } #[test] diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index 305bff9..dca7415 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -1041,9 +1041,16 @@ pub struct CashFlowParams { pub end: chrono::NaiveDate, /// Accounts treated as cash. Their net change is the reconciling total. pub cash_accounts: Vec, - /// Liability accounts treated as financing (debt). Default empty: all - /// liabilities are operating working capital, all equity is financing. + /// Liability accounts treated as financing (debt, including what the + /// owners advanced: ASC 230-10-45-14(2) "other short- or long-term + /// borrowing"). Default empty: all liabilities are operating working + /// capital. All equity is financing. pub financing_accounts: Vec, + /// Asset accounts treated as investing: loans the entity makes and + /// investments it buys (ASC 230-10-20; 230-10-45-13). Registered fixed + /// assets are investing without being listed. Default empty: other + /// assets are operating working capital. + pub investing_accounts: Vec, } /// Cash flow statement, indirect method. @@ -1092,32 +1099,42 @@ pub struct CashFlowStatement { /// /// Pure function of the ledger: net income plus non-cash depreciation plus /// working-capital changes gives operating cash flow; fixed-asset purchases -/// and disposal proceeds give investing; equity and designated debt give -/// financing. The three sections sum to the net change in `cash_accounts`. +/// and disposal proceeds, and the accounts named as investing, give +/// investing; equity and the debt named as financing give financing. The +/// three sections sum to the net change in `cash_accounts`. /// /// ```text /// GOAL: the statement of cash flows by the indirect method: net income /// adjusted to operating cash, plus investing and financing, for a /// period. /// SOURCE: docs/spec/rules.md B-14 (ASC 230-10-45-28 indirect method; -/// 230-10-45-16 operating inflows). +/// 230-10-45-16 operating inflows; 230-10-45-7/8 gross over net; +/// 230-10-45-13/14/15 investing and financing flows; 230-10-20 +/// investing activities). /// INVARIANTS: /// 1. Operating + investing + financing MUST equal the net change in the /// cash accounts over the period, exactly. /// 2. Operating MUST start from the period's net income (closing entries /// excluded) and adjust for changes in non-cash working-capital /// accounts. -/// 3. Investing MUST hold changes in non-current assets (fixed assets); -/// a disposal's gain or loss moves out of operating into investing. +/// 3. Investing MUST hold fixed-asset purchases and disposal proceeds +/// (a disposal's gain or loss moves out of operating into investing) +/// and the changes in the accounts named as investing. /// 4. Financing MUST hold changes in equity (other than earnings) and in /// the liabilities named as financing. +/// 4a. Investing and financing MUST be gross (ASC 230-10-45-7: gross +/// receipts and payments are "more relevant" than net): per account, +/// cash in and cash out are separate lines (contributions apart from +/// distributions, borrowing apart from repayment), netted only within +/// one entry. /// 5. NEVER count closing entries. /// 6. The period is `[start, end]`, inclusive: an entry dated `start` is /// period activity, not part of the beginning cash balance. /// 7. Every line's amount is its cash effect: positive is cash in. Its -/// label names the direction the balance moved ("Increase in" / -/// "Decrease in"; "Proceeds from" / "Repayment of" for financing -/// debt; "Owner contributions" / "Owner distributions" for equity). +/// label names the direction ("Increase in" / "Decrease in" for +/// working capital; "Proceeds from" / "Repayment of" for financing +/// debt; "Owner contributions" / "Owner distributions" for equity; +/// "Payments for" / "Collections on" for investing accounts). /// ``` pub fn cash_flow( ledger: &Ledger, @@ -1148,6 +1165,8 @@ pub fn cash_flow( let cash_set: HashSet = params.cash_accounts.iter().copied().collect(); let financing_set: HashSet = params.financing_accounts.iter().copied().collect(); + let investing_set: HashSet = + params.investing_accounts.iter().copied().collect(); // Movement helpers: debits minus credits over a date predicate. Closing // entries are skipped: a close moves earnings into retained earnings, @@ -1229,6 +1248,12 @@ pub fn cash_flow( if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) { continue; // cash reconciles; fixed assets are investing (gross, below) } + if account.account_type == AccountType::Equity + || financing_set.contains(&id) + || investing_set.contains(&id) + { + continue; // financing and investing roles are gross, below + } let begin = normal(id, begin_movement(id)); let end = normal(id, period_movement(id) + begin_movement(id)); let delta = end - begin; @@ -1248,14 +1273,6 @@ pub fn cash_flow( }; (format!("{dir} {}", display(id)), cash_effect) } - AccountType::Liability if financing_set.contains(&id) => { - let dir = if delta > Decimal::ZERO { - "Proceeds from" - } else { - "Repayment of" - }; - (format!("{dir} {}", display(id)), cash_effect) - } AccountType::Liability => { let dir = if delta > Decimal::ZERO { "Increase in" @@ -1264,28 +1281,88 @@ pub fn cash_flow( }; (format!("{dir} {}", display(id)), cash_effect) } - AccountType::Equity => { - let dir = if delta > Decimal::ZERO { - "Owner contributions" - } else { - "Owner distributions" - }; - (format!("{dir} — {}", display(id)), cash_effect) - } _ => continue, // revenue/expense handled via net income }; - let target = match account.account_type { - AccountType::Equity => &mut financing, - AccountType::Liability if financing_set.contains(&id) => &mut financing, - _ => &mut working_capital, - }; - target.push(CashFlowLine { + working_capital.push(CashFlowLine { label, amount: line, account: Some(account.code.clone()), }); } + // --- Financing and investing accounts, gross (ASC 230-10-45-7) --- + // Per account, cash in and cash out as separate lines; an entry's own + // debits and credits to the account net first (one transaction, one + // flow). + let mut roles: Vec<&crate::Account> = ledger + .chart() + .all() + .filter(|a| { + !a.placeholder + && !cash_set.contains(&a.id) + && !cost_accounts.contains(&a.id) + && !accum_accounts.contains(&a.id) + && (a.account_type == AccountType::Equity + || financing_set.contains(&a.id) + || investing_set.contains(&a.id)) + }) + .collect(); + roles.sort_by(|a, b| a.code.cmp(&b.code)); + for account in roles { + let (mut cash_in, mut cash_out) = (Decimal::ZERO, Decimal::ZERO); + for entry in ledger.posted_entries() { + let d = entry.date(); + if d < params.start || d > params.end || closings.contains(&entry.id()) { + continue; + } + // Cash effect of this entry's change to the account: minus its + // movement (debits - credits), for assets and claims alike. + let effect: Decimal = entry + .lines() + .iter() + .filter(|l| l.account == account.id) + .map(|l| match l.side { + crate::Side::Debit => -l.amount.amount, + crate::Side::Credit => l.amount.amount, + }) + .sum(); + if effect > Decimal::ZERO { + cash_in += effect; + } else { + cash_out += effect; + } + } + let name = display(account.id); + let (in_label, out_label, target) = if investing_set.contains(&account.id) { + ( + format!("Collections on {name}"), + format!("Payments for {name}"), + &mut investing, + ) + } else if account.account_type == AccountType::Equity { + ( + format!("Owner contributions — {name}"), + format!("Owner distributions — {name}"), + &mut financing, + ) + } else { + ( + format!("Proceeds from {name}"), + format!("Repayment of {name}"), + &mut financing, + ) + }; + for (label, amount) in [(in_label, cash_in), (out_label, cash_out)] { + if !amount.is_zero() { + target.push(CashFlowLine { + label, + amount, + account: Some(account.code.clone()), + }); + } + } + } + // --- Investing, gross: purchases and disposal proceeds --- // A disposal entry credits a cost account and debits its linked accum. let mut disposal_gain_loss = Decimal::ZERO; // +gain / -loss, to remove from operating diff --git a/crates/ledger-core/tests/invariants.rs b/crates/ledger-core/tests/invariants.rs index 40177da..66a72f2 100644 --- a/crates/ledger-core/tests/invariants.rs +++ b/crates/ledger-core/tests/invariants.rs @@ -724,6 +724,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams { end: date(31), cash_accounts: vec![cash], financing_accounts: vec![], + investing_accounts: vec![], } } diff --git a/crates/ledger-core/tests/spec_reports.rs b/crates/ledger-core/tests/spec_reports.rs index 764951b..762cb0d 100644 --- a/crates/ledger-core/tests/spec_reports.rs +++ b/crates/ledger-core/tests/spec_reports.rs @@ -1328,6 +1328,7 @@ fn cf_transfer_between_cash_accounts_is_not_a_flow() { end: d(2026, 2, 28), cash_accounts: vec![cash, ar], financing_accounts: vec![], + investing_accounts: vec![], }, ) .unwrap(); @@ -1416,6 +1417,7 @@ fn cf_hand_computed_with_designated_debt() { end: d(2026, 2, 28), cash_accounts: vec![co.cash], financing_accounts: vec![co.loan], + investing_accounts: vec![], }; let cf = cash_flow(&co.l, ¶ms).unwrap(); assert_eq!(cf.start, d(2026, 2, 1)); @@ -1445,6 +1447,7 @@ fn cf_undesignated_liability_is_working_capital() { end: d(2026, 2, 28), cash_accounts: vec![co.cash], financing_accounts: vec![], + investing_accounts: vec![], }; let cf = cash_flow(&co.l, ¶ms).unwrap(); assert_eq!(cf.cash_from_operations, c(48000)); @@ -1463,6 +1466,7 @@ fn cf_never_counts_closing_entries() { end: d(2026, 2, 28), cash_accounts: vec![co.cash], financing_accounts: vec![co.loan], + investing_accounts: vec![], }; let before = cash_flow(&co.l, ¶ms).unwrap(); let p1 = @@ -1544,6 +1548,7 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() { end: d(2026, 2, 28), cash_accounts: vec![cash], financing_accounts: vec![], + investing_accounts: vec![], }, ) .unwrap(); @@ -1561,6 +1566,7 @@ fn cf_fixed_asset_purchase_depreciation_and_disposal() { end: d(2026, 3, 31), cash_accounts: vec![cash], financing_accounts: vec![], + investing_accounts: vec![], }, ) .unwrap(); @@ -1991,7 +1997,7 @@ proptest! { fn prop_drafts_change_no_report(gens in arb_books(), drafts in prop::collection::vec(arb_gen(), 1..5), (a, _m, e) in three_days()) { let mut b = build(&gens); let (s0, e0) = (day(a), day(e)); - let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![] }; + let cfp = CashFlowParams { start: s0, end: e0, cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![] }; let tb = tb_lines(&trial_balance_as_of(&b.l, e0)); let pl = pnl_key(&profit_and_loss_for_period(&b.l, s0, e0, None).unwrap()); let bs = bs_key(&balance_sheet_as_of(&b.l, e0).unwrap()); @@ -2429,6 +2435,7 @@ proptest! { end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] }, + investing_accounts: vec![], }; let cf = cash_flow(&b.l, ¶ms).unwrap(); prop_assert_eq!(cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, cf.net_change_in_cash); @@ -2457,6 +2464,7 @@ proptest! { end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: if designate { vec![b.ids[LOAN]] } else { vec![] }, + investing_accounts: vec![], }; let cf = cash_flow(&b.l, ¶ms).unwrap(); let m = model(&gens, day(a), day(e), None); @@ -2473,7 +2481,7 @@ proptest! { let open = build(&gens); let mut closed = build(&gens); close_through(&mut closed, Some(month), &re_close()); - let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]] }; + let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![b.ids[LOAN]], investing_accounts: vec![] }; let x = cash_flow(&open.l, &p(&open)).unwrap(); let y = cash_flow(&closed.l, &p(&closed)).unwrap(); prop_assert_eq!(x.net_income, y.net_income); @@ -2490,7 +2498,7 @@ proptest! { let kk = Decimal::from(k); let one = build(&gens); let many = build_scaled(&gens, k); - let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![] }; + let p = |b: &Books| CashFlowParams { start: day(a), end: day(e), cash_accounts: vec![b.ids[CASH]], financing_accounts: vec![], investing_accounts: vec![] }; let x = cash_flow(&one.l, &p(&one)).unwrap(); let y = cash_flow(&many.l, &p(&many)).unwrap(); prop_assert_eq!(y.net_income, x.net_income * kk); diff --git a/crates/ledger-core/tests/statements.rs b/crates/ledger-core/tests/statements.rs index dd7e31c..caa251d 100644 --- a/crates/ledger-core/tests/statements.rs +++ b/crates/ledger-core/tests/statements.rs @@ -214,6 +214,7 @@ fn a_closed_period_still_reports_its_earnings() { end: d(1, 31), cash_accounts: vec![b.cash], financing_accounts: vec![], + investing_accounts: vec![], }, ) .unwrap(); @@ -249,6 +250,7 @@ fn cash_flow_lines_are_in_code_order() { end: d(12, 31), cash_accounts: vec![cash], financing_accounts: vec![], + investing_accounts: vec![], }, ) .unwrap(); @@ -529,11 +531,17 @@ fn cash_flow_lines_name_their_account() { end: d(12, 31), cash_accounts: vec![1], financing_accounts: vec![], + investing_accounts: vec![], }, ) .unwrap(); let accounts: Vec> = cf.financing.iter().map(|f| f.account.as_deref()).collect(); - assert_eq!(accounts, [Some("3000"), Some("3001")]); + // Gross (ASC 230-10-45-7): 3001 contributed and took a distribution, + // so it has a line for each. + assert_eq!(accounts, [Some("3000"), Some("3001"), Some("3001")]); + assert!(cf.financing[1].label.starts_with("Owner contributions")); + assert!(cf.financing[2].label.starts_with("Owner distributions")); + assert!(cf.financing[1].amount > Decimal::ZERO && cf.financing[2].amount < Decimal::ZERO); } #[test] @@ -1006,6 +1014,7 @@ fn cf_params(start: NaiveDate, end: NaiveDate, cash: AccountId) -> CashFlowParam end, cash_accounts: vec![cash], financing_accounts: vec![], + investing_accounts: vec![], } } @@ -1085,6 +1094,7 @@ fn cash_flow_labels_say_which_way_each_balance_moved() { end, cash_accounts: vec![cash], financing_accounts: vec![loan], + investing_accounts: vec![], }; // February: net income 100 - 50 = 50; receivable up 100 (cash out), @@ -1273,3 +1283,125 @@ fn distribution_accounts_reduce_capital_and_returns_reduce_distributions() { ); assert!(feb.members.iter().all(|m| m.other.is_zero())); } + +/// The cash flow fixture for financing and investing roles. Hand-computed: +/// +/// | date | entry | Dr | Cr | +/// |--------|----------------------------------------|----------------|----------------| +/// | Mar 1 | bank loan 1,000 | 1000 Cash | 2500 Loan | +/// | Mar 5 | owner pays software 100 personally | 6000 Software | 2100 Due to owners | +/// | Mar 10 | lend a member 500 | 1400 Member loan | 1000 Cash | +/// | Mar 20 | repay 400 of the bank loan | 2500 Loan | 1000 Cash | +/// | Mar 25 | the member repays 200 | 1000 Cash | 1400 Member loan | +/// | Mar 28 | reimburse the owner 100 | 2100 Due to owners | 1000 Cash | +/// +/// Cash: +1,000 - 500 - 400 + 200 - 100 = +200. +fn roles() -> (Ledger, CashFlowParams) { + let mut l = Ledger::new(Currency::USD); + let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let cash = add("1000", AccountType::Asset); + let member_loan = add("1400", AccountType::Asset); + let due_owners = add("2100", AccountType::Liability); + let loan = add("2500", AccountType::Liability); + let software = add("6000", AccountType::Expense); + post(&mut l, d(3, 1), "loan", cash, loan, 1000); + post(&mut l, d(3, 5), "software", software, due_owners, 100); + post(&mut l, d(3, 10), "lend", member_loan, cash, 500); + post(&mut l, d(3, 20), "repay", loan, cash, 400); + post(&mut l, d(3, 25), "collect", cash, member_loan, 200); + post(&mut l, d(3, 28), "reimburse", due_owners, cash, 100); + let params = CashFlowParams { + start: d(3, 1), + end: d(3, 31), + cash_accounts: vec![cash], + financing_accounts: vec![loan, due_owners], + investing_accounts: vec![member_loan], + }; + (l, params) +} + +fn lines(ls: &[ledger_core::reports::CashFlowLine]) -> Vec<(String, Decimal)> { + ls.iter() + .map(|l| (l.account.clone().unwrap_or_default(), l.amount)) + .collect() +} + +/// Oracle: ASC 230-10-45-7 ("information about the gross amounts of cash +/// receipts and cash payments during a period is more relevant than +/// information about the net amounts"), 230-10-45-14(2) (proceeds from +/// "other short- or long-term borrowing") and 45-15(2) ("Repayments of +/// amounts borrowed"): borrowing and repayment are separate lines. +#[test] +fn financing_is_gross_borrowing_apart_from_repayment() { + let (l, params) = roles(); + let cf = cash_flow(&l, ¶ms).unwrap(); + assert_eq!( + lines(&cf.financing), + [ + ("2100".into(), dec(100)), + ("2100".into(), dec(-100)), + ("2500".into(), dec(1000)), + ("2500".into(), dec(-400)), + ] + ); + assert_eq!(cf.cash_from_financing, dec(600)); +} + +/// Oracle: ASC 230-10-20 ("Investing activities include making and +/// collecting loans") and 230-10-45-13 ("Disbursements for loans made by +/// the entity"): a loan to a member is investing, gross, not working +/// capital. +#[test] +fn a_loan_the_entity_makes_is_investing_gross() { + let (l, params) = roles(); + let cf = cash_flow(&l, ¶ms).unwrap(); + assert_eq!( + lines(&cf.investing), + [("1400".into(), dec(200)), ("1400".into(), dec(-500))] + ); + assert!(cf.investing[0].label.starts_with("Collections on")); + assert!(cf.investing[1].label.starts_with("Payments for")); + assert!( + cf.working_capital + .iter() + .all(|w| w.account.as_deref() != Some("1400")) + ); + assert_eq!(cf.cash_from_investing, dec(-300)); +} + +/// Oracle: PwC Viewpoint, Financial statement presentation 6.9.3.1 +/// (constructive receipts and disbursements: cash a third party pays on +/// the entity's behalf is presented "as though the transaction took place +/// through the bank accounts of the reporting entity"). The owner paying +/// software personally is a borrowing (financing in) and an operating +/// payment (in net income); reimbursing the owner is a repayment. +/// Hand-computed: operating -100, investing -300, financing +600 = +200, +/// the change in cash. +#[test] +fn an_expense_the_owner_paid_is_borrowing_then_operating() { + let (l, params) = roles(); + let cf = cash_flow(&l, ¶ms).unwrap(); + assert_eq!(cf.net_income, dec(-100)); + assert_eq!(cf.cash_from_operations, dec(-100)); + assert_eq!(cf.net_change_in_cash, dec(200)); + assert_eq!( + cf.cash_from_operations + cf.cash_from_investing + cf.cash_from_financing, + cf.net_change_in_cash + ); +} + +/// Gross lines net only within one entry: an entry that both debits and +/// credits a financing account by the same amount moves no cash and adds +/// no line. +#[test] +fn an_entry_that_nets_to_nothing_on_an_account_adds_no_line() { + let (mut l, params) = roles(); + let loan = l.chart().find_by_code("2500").unwrap().id; + let e = l.create_entry(d(3, 30), "reclass within the loan"); + l.add_line(e, loan, Side::Debit, usd(50)).unwrap(); + l.add_line(e, loan, Side::Credit, usd(50)).unwrap(); + l.post_entry(e).unwrap(); + let cf = cash_flow(&l, ¶ms).unwrap(); + assert_eq!(cf.cash_from_financing, dec(600)); + assert_eq!(cf.financing.len(), 4); +} diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index ddb033f..045a3ae 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -77,6 +77,10 @@ pub struct Books { pub entries: Vec, pub entry_index: HashMap, pub cash_accounts: Vec, + /// `[books] financing_accounts`, resolved (empty when not set). + pub financing_accounts: Vec, + /// `[books] investing_accounts`, resolved (empty when not set). + pub investing_accounts: Vec, pub card_accounts: Vec, /// Entries created or changed by the most recent refresh. pub fresh: HashSet, @@ -164,6 +168,13 @@ impl Books { .and_then(|t| t.parse().ok()) .unwrap_or(Decimal::new(25, 0)); let cash_accounts = cash_accounts(&ledger, &settings); + let codes = |cs: &[String]| -> Vec { + cs.iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect() + }; + let financing_accounts = codes(&settings.financing_accounts); + let investing_accounts = codes(&settings.investing_accounts); let card_accounts: Vec = ledger .chart() .all() @@ -303,6 +314,8 @@ impl Books { entries, entry_index, cash_accounts, + financing_accounts, + investing_accounts, card_accounts, fresh, fresh_from_seq, diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 52e2053..a6d9529 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -554,7 +554,12 @@ fn balance_sheet(b: &Books, dates: &[(NaiveDate, String)]) -> Table { t } +/// Financing accounts: from `[books] financing_accounts`, else liabilities +/// whose names say they are debt. fn financing_accounts(b: &Books) -> Vec { + if !b.financing_accounts.is_empty() { + return b.financing_accounts.clone(); + } b.ledger .chart() .all() @@ -585,6 +590,7 @@ fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table { end: *e, cash_accounts: b.cash_accounts.clone(), financing_accounts: fin.clone(), + investing_accounts: b.investing_accounts.clone(), }, ) .ok() diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs index b4e0c3f..544e262 100644 --- a/crates/ledger-mcp/src/main.rs +++ b/crates/ledger-mcp/src/main.rs @@ -113,6 +113,11 @@ fn main() -> Result<(), Box> { }, (None, None) => ReceiptPolicy::default(), }; + let roles = server::CashRoles { + cash: settings.cash_accounts, + financing: settings.financing_accounts, + investing: settings.investing_accounts, + }; let company = settings.company; let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() { @@ -138,12 +143,14 @@ fn main() -> Result<(), Box> { .build()?; if let Some(addr) = http_addr { eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}"); - rt.block_on(serve_http(addr, store, policy, company))?; + rt.block_on(serve_http(addr, store, policy, company, roles))?; return Ok(()); } eprintln!("ledger-mcp: store verified, serving over stdio"); - let server = BooksServer::new(store, policy).with_company(company); + let server = BooksServer::new(store, policy) + .with_company(company) + .with_cash_roles(roles); rt.block_on(async { let running = server.serve(stdio()).await?; let _ = running.waiting().await; @@ -157,6 +164,7 @@ async fn serve_http( store: Store, policy: ReceiptPolicy, company: company::Company, + roles: server::CashRoles, ) -> Result<(), Box> { use rmcp::transport::streamable_http_server::{ StreamableHttpService, session::local::LocalSessionManager, @@ -167,7 +175,8 @@ async fn serve_http( let service = StreamableHttpService::new( move || { let server = server::BooksServer::new_shared(store.clone(), policy.clone()) - .with_company(company.clone()); + .with_company(company.clone()) + .with_cash_roles(roles.clone()); Ok(server) }, Arc::new(LocalSessionManager::default()), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 5903052..7179253 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -36,6 +36,19 @@ pub struct BooksServer { store: Arc>, policy: ReceiptPolicy, company: Company, + roles: CashRoles, +} + +/// `[books]` account roles for the cash flow statement, used when a call +/// doesn't pass its own: cash, financing, and investing account codes. +#[derive(Clone, Debug, Default)] +pub struct CashRoles { + /// `[books] cash_accounts`. + pub cash: Vec, + /// `[books] financing_accounts`. + pub financing: Vec, + /// `[books] investing_accounts`. + pub investing: Vec, } impl BooksServer { @@ -45,6 +58,7 @@ impl BooksServer { store: Arc::new(Mutex::new(store)), policy, company: Company::default(), + roles: CashRoles::default(), } } @@ -55,6 +69,7 @@ impl BooksServer { store, policy, company: Company::default(), + roles: CashRoles::default(), } } @@ -64,6 +79,40 @@ impl BooksServer { self } + /// Set the `[books]` cash flow account roles. + pub fn with_cash_roles(mut self, roles: CashRoles) -> Self { + self.roles = roles; + self + } + + /// The cash flow statement from account codes, each list defaulting to + /// the `[books]` roles when the call passes none. + fn cash_flow_for( + &self, + ledger: &Ledger, + start: Option<&str>, + end: Option<&str>, + cash: &[String], + financing: &[String], + investing: &[String], + ) -> Result { + let or = |given: &'_ [String], default: &'_ [String]| -> Vec { + if given.is_empty() { + default.to_vec() + } else { + given.to_vec() + } + }; + cash_flow_statement( + ledger, + start, + end, + &or(cash, &self.roles.cash), + &or(financing, &self.roles.financing), + &or(investing, &self.roles.investing), + ) + } + /// Header and provenance for a rendered report. fn report_meta(&self, store: &Store) -> ReportMeta { ReportMeta { @@ -446,6 +495,7 @@ fn cash_flow_statement( end: Option<&str>, cash_codes: &[String], financing_codes: &[String], + investing_codes: &[String], ) -> Result { let resolve = |codes: &[String], what: &str| -> Result, String> { codes @@ -461,9 +511,13 @@ fn cash_flow_statement( }; let cash_accounts = resolve(cash_codes, "cash")?; if cash_accounts.is_empty() { - return Err("cash_accounts is required for the cash flow statement".to_string()); + return Err( + "cash_accounts is required for the cash flow statement (pass it, or set [books] cash_accounts in wanderbooks.toml)" + .to_string(), + ); } let financing_accounts = resolve(financing_codes, "financing")?; + let investing_accounts = resolve(investing_codes, "investing")?; let (start, end) = statement_range(ledger, start, end)?; ledger_core::cash_flow( ledger, @@ -472,6 +526,7 @@ fn cash_flow_statement( end, cash_accounts, financing_accounts, + investing_accounts, }, ) .map_err(|e| e.to_string()) @@ -769,46 +824,14 @@ impl BooksServer { let p = params.0; let store = self.lock()?; let ledger = store.ledger(); - let start = match p.start { - Some(s) => parse_date(&s)?, - None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), - }; - let end = match p.end { - Some(s) => parse_date(&s)?, - None => today(), - }; - // Resolve cash and financing account codes to ids. - let mut cash_accounts = Vec::new(); - for code in &p.cash_accounts { - let id = ledger - .chart() - .find_by_code(code) - .map(|a| a.id) - .ok_or_else(|| format!("unknown cash account code: {code}"))?; - cash_accounts.push(id); - } - if cash_accounts.is_empty() { - return Err("at least one cash account code is required".to_string()); - } - let mut financing_accounts = Vec::new(); - for code in &p.financing_accounts { - let id = ledger - .chart() - .find_by_code(code) - .map(|a| a.id) - .ok_or_else(|| format!("unknown financing account code: {code}"))?; - financing_accounts.push(id); - } - let stmt = ledger_core::cash_flow( + let stmt = self.cash_flow_for( ledger, - &ledger_core::CashFlowParams { - start, - end, - cash_accounts, - financing_accounts, - }, - ) - .map_err(|e| e.to_string())?; + p.start.as_deref(), + p.end.as_deref(), + &p.cash_accounts, + &p.financing_accounts, + &p.investing_accounts, + )?; let line = |l: &ledger_core::CashFlowLine| CashFlowLineOut { label: l.label.clone(), amount: money(l.amount), @@ -2454,12 +2477,13 @@ impl BooksServer { report::balance_sheet(&bs, earnings, &members) } "cash_flow" => report::cash_flow( - &cash_flow_statement( + &self.cash_flow_for( ledger, p.start.as_deref(), p.end.as_deref(), &p.cash_accounts, &p.financing_accounts, + &p.investing_accounts, )?, &members, ), @@ -3556,12 +3580,13 @@ impl BooksServer { ( "cash-flow", report::cash_flow( - &cash_flow_statement( + &self.cash_flow_for( ledger, Some(&fy_start), Some(&as_of_s), &p.cash_accounts, &p.financing_accounts, + &p.investing_accounts, )?, &members, ), @@ -4056,36 +4081,14 @@ impl BooksServer { fn export_cash_flow(&self, format: &str, p: &ExportParams) -> Result { let store = self.lock()?; let ledger = store.ledger(); - let mut cash_accounts = Vec::new(); - for code in &p.cash_accounts { - let id = ledger - .chart() - .find_by_code(code) - .map(|a| a.id) - .ok_or_else(|| format!("unknown cash account code: {code}"))?; - cash_accounts.push(id); - } - if cash_accounts.is_empty() { - return Err("cash_accounts is required for cash_flow".to_string()); - } - let start = match &p.start { - Some(s) => parse_date(s)?, - None => NaiveDate::from_ymd_opt(1, 1, 1).unwrap(), - }; - let end = match &p.end { - Some(s) => parse_date(s)?, - None => today(), - }; - let stmt = ledger_core::cash_flow( + let stmt = self.cash_flow_for( ledger, - &ledger_core::CashFlowParams { - start, - end, - cash_accounts, - financing_accounts: vec![], - }, - ) - .map_err(|e| e.to_string())?; + p.start.as_deref(), + p.end.as_deref(), + &p.cash_accounts, + &p.financing_accounts, + &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(), diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 929c895..9d0d37c 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -621,13 +621,21 @@ pub struct CashFlowParams { pub start: Option, /// Period end, YYYY-MM-DD. Defaults to today. pub end: Option, - /// Account codes treated as cash (e.g. `["1000", "1010"]`). Required: - /// the statement reconciles to the net change in these accounts. + /// 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. + #[serde(default)] pub cash_accounts: Vec, - /// Account codes for liabilities treated as financing (debt). - /// Defaults to empty: liabilities are operating working capital. + /// Account codes for liabilities treated as financing (loans, and what + /// owners advanced: ASC 230-10-45-14(2)). Defaults to `[books] + /// financing_accounts`, else empty (liabilities are working capital). #[serde(default)] pub financing_accounts: Vec, + /// 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`. + #[serde(default)] + pub investing_accounts: Vec, } /// One cash flow line. @@ -1315,13 +1323,19 @@ pub struct ExportParams { pub end: Option, /// As-of date for balance_sheet, YYYY-MM-DD. pub as_of: Option, - /// Cash account codes for cash_flow (required for that report). + /// Cash account codes for cash_flow. Defaults to `[books] + /// cash_accounts`; one or the other is required for that report. #[serde(default)] pub cash_accounts: Vec, - /// Liability account codes treated as financing (debt) for cash_flow. - /// Defaults to empty: liabilities are operating working capital. + /// Liability account codes treated as financing for cash_flow. + /// Defaults to `[books] financing_accounts`, else empty. #[serde(default)] pub financing_accounts: Vec, + /// 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`. + #[serde(default)] + pub investing_accounts: Vec, } /// Result of `books.export`. @@ -1997,14 +2011,20 @@ pub struct ProfitAndLossByClassOut { pub struct ExportCpaPackageParams { /// As-of date, YYYY-MM-DD. Defaults to today. pub as_of: Option, - /// Cash account codes the cash flow statement reconciles to (required), - /// e.g. `["1000"]`. + /// Cash account codes the cash flow statement reconciles to, e.g. + /// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other + /// is required. #[serde(default)] pub cash_accounts: Vec, - /// Liability account codes treated as financing (debt). Defaults to - /// empty: liabilities are operating working capital. + /// Liability account codes treated as financing. Defaults to `[books] + /// financing_accounts`, else empty. #[serde(default)] pub financing_accounts: Vec, + /// 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`. + #[serde(default)] + pub investing_accounts: Vec, /// Include every receipt for the year's entries and the company's /// documents (default true). #[serde(default = "default_true")] diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index d70b567..137b351 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -1707,3 +1707,79 @@ fn a_restarted_server_waits_for_the_old_one_to_let_go() { let chart = new.call("books.chart", json!({})); assert!(chart.is_object()); } + +/// `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. +#[test] +fn cash_flow_reads_its_account_roles_from_the_books_settings() { + let dir = test_dir("cash-roles"); + let toml = dir.join("wanderbooks.toml"); + std::fs::write( + &toml, + "[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n", + ) + .unwrap(); + let mut mcp = Mcp::spawn_with( + &dir.join("books-data"), + &[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()], + ); + 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"), + ("1400", "Loan to member", "asset"), + ("2100", "Due to owners", "liability"), + ("3000", "Capital", "equity"), + ("6000", "Software", "expense"), + ] { + mcp.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": typ}), + ); + } + let mut post = |date: &str, dr: &str, cr: &str, amount: &str| { + let e = mcp.call( + "books.draft_entry", + json!({"date": date, "description": "test"}), + ); + let id = e["entry_id"].as_u64().unwrap(); + for (account, side) in [(dr, "debit"), (cr, "credit")] { + mcp.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + mcp.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + }; + post("2026-03-01", "1000", "3000", "1000.00"); + post("2026-03-05", "6000", "2100", "100.00"); + post("2026-03-10", "1400", "1000", "500.00"); + post("2026-03-28", "2100", "1000", "100.00"); + let cf = mcp.call( + "books.cash_flow", + json!({"start": "2026-03-01", "end": "2026-03-31"}), + ); + let amounts = |section: &str| -> Vec { + cf[section] + .as_array() + .unwrap() + .iter() + .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"]); + assert_eq!(amounts("investing"), ["-500.00"]); + assert_eq!(cf["net_change_in_cash"], "400.00"); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 96f7131..6311124 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -36,6 +36,12 @@ footers, and `[books] receipt_threshold` sets the receipt policy unless `distribution_account`; single-owner businesses may set `[books] close_into` (the equity account a close puts net income in; default Retained Earnings) and `close_sweep` (draw, distribution, or dividend accounts closed into it). +The cash flow statement's account roles are `[books] cash_accounts` (what +it reconciles to), `financing_accounts` (loans, and money the owners +advanced, such as a "Due to owners" liability: ASC 230-10-45-14(2)), and +`investing_accounts` (loans the company makes, investments it buys: ASC +230-10-20); the tools and the viewer both read them, and a call may pass +its own (docs/spec/rules.md B-14). ## Transports @@ -102,7 +108,7 @@ below. `revenue_accounts` and `expense_accounts` for the range. `end` defaults to today, `start` to the first posted entry. Closing entries are excluded, so a closed period still shows what it earned. -- `books.cash_flow { start?, end?, cash_accounts: ["1000"], financing_accounts? }` +- `books.cash_flow { start?, end?, cash_accounts?, financing_accounts?, investing_accounts? }` (each defaults to `[books]` in wanderbooks.toml; cash is required one way or the other) → indirect-method cash flow statement. `cash_accounts` are the reconciling accounts (required); the three sections always sum to their net change. Disposal gains/losses move from operating to investing automatically. @@ -295,7 +301,7 @@ posting. ## Tools — export - `books.export { format, report, start?, end?, as_of?, cash_accounts?, - financing_accounts? }` → `{ content, content_encoding? }`. + financing_accounts?, investing_accounts? }` → `{ content, content_encoding? }`. - Reports: `profit_and_loss`, `balance_sheet`, `cash_flow` (needs `cash_accounts`), `trial_balance`, `members_equity` (statement of changes in members' equity; members from `wanderbooks.toml`), @@ -486,7 +492,7 @@ entries. ## Tools — CPA handoff -- `books.export_cpa_package { as_of?, cash_accounts, financing_accounts? }` +- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, investing_accounts? }` → `{ file_name, content_base64 }`. A zip with the full set, each statement as PDF and XLSX: `profit-and-loss`, `balance-sheet`, `cash-flow` (reconciled to `cash_accounts`), `members-equity` diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 568cb40..1e782d0 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -235,9 +235,37 @@ inflows from operating activities: Cash receipts from sales of goods or services…". A closing entry is not income or spending (B-11), so period statements exclude it. +Investing and financing are gross (ASC 230-10-45-7, via Deloitte DART: +"information about the gross amounts of cash receipts and cash payments +during a period is more relevant than information about the net +amounts"; 45-8 allows netting only where "the turnover is quick, the +amounts are large, and the maturities are short"). Financing inflows +include "Proceeds from issuing equity instruments" and "other short- or +long-term borrowing" (45-14(1), (2)); outflows include "distributions to +owners" and "Repayments of amounts borrowed" (45-15(1), (2)). Investing +"include[s] making and collecting loans and acquiring and disposing of +debt or equity instruments and property, plant, and equipment" (ASC +230-10-20). Which accounts hold loans made, investments, and debt is a +fact of each company's chart, so it is a setting: `[books] +financing_accounts` and `investing_accounts` in wanderbooks.toml (with +`cash_accounts`), read by the MCP tools and the viewer alike. + +Money an owner advances, by paying a company expense personally (a +"Due to owners" liability), is borrowing: PwC Viewpoint, Financial +statement presentation 6.9.3.1 (constructive receipts and +disbursements) presents cash a third party pays on the entity's behalf +"as though the transaction took place through the bank accounts of the +reporting entity": a financing inflow and an operating outflow. Listing +that liability in `financing_accounts` gives exactly that; repaying the +owner is then a repayment of amounts borrowed. "Judgment is required" +(PwC), so it is the company's setting, not a fixed rule. + **Enforced:** `profit_and_loss_for_period`, `cash_flow`. **Checked:** Lean `statements_add_up`; `prop_period_pnl_partitions`; -`cash_flow_reconciles_to_change_in_cash`. +`cash_flow_reconciles_to_change_in_cash`; +`financing_is_gross_borrowing_apart_from_repayment`, +`a_loan_the_entity_makes_is_investing_gross`, +`an_expense_the_owner_paid_is_borrowing_then_operating`. ## B-15 Depreciation: straight line, salvage never depreciated, partial months by days