cash flow: financing and investing gross; account roles are a setting
check / check (push) Canceled after 0s

From ASC 230 (via Deloitte DART and PwC Viewpoint), replacing two
formerly assumed behaviors:

- Financing and investing are gross (230-10-45-7): per account, cash in
  and cash out are separate lines (contributions apart from
  distributions, borrowing apart from repayment), netted only within
  one entry. Lines come in code order, in before out.
- Investing includes making and collecting loans and buying investments
  (230-10-20, 45-13), not only registered fixed assets:
  CashFlowParams gains investing_accounts.
- The roles are per-company settings: [books] financing_accounts and
  investing_accounts in wanderbooks.toml (beside cash_accounts). The MCP
  tools (cash_flow, export, CPA package) default every list to them and
  share one path; the viewer reads them too, falling back to its name
  guess for debt. A due-to-owners liability listed as financing gives
  PwC 6.9.3.1's constructive treatment of expenses an owner paid.

rules.md B-14 quotes the sources; new tests cite them; docs/testing.md
#8 is resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-13 18:36:21 -06:00
1 parent 08d1a9c230
commit 819cd09e0d
13 files changed
+521 -126

No files matched your search

+1
View File
@@ -724,6 +724,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
end: date(31),
cash_accounts: vec![cash],
financing_accounts: vec![],
investing_accounts: vec![],
}
}
+11 -3
View File
@@ -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, &params).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, &params).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, &params).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, &params).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, &params).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);
+133 -1
View File
@@ -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<Option<&str>> = 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, &params).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, &params).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, &params).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, &params).unwrap();
assert_eq!(cf.cash_from_financing, dec(600));
assert_eq!(cf.financing.len(), 4);
}