cash flow: financing and investing gross; account roles are a setting
check / check (push) Canceled after 0s
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:
1 parent
08d1a9c230
commit
819cd09e0d
13 files changed
+521
-126
No files matched your search
@@ -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<String>,
|
||||
/// `[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<String>,
|
||||
/// `[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<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -134,6 +142,10 @@ struct BooksSection {
|
||||
close_sweep: Vec<String>,
|
||||
#[serde(default)]
|
||||
cash_accounts: Vec<String>,
|
||||
#[serde(default)]
|
||||
financing_accounts: Vec<String>,
|
||||
#[serde(default)]
|
||||
investing_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// The return a federal tax classification files: "partnership" → 1065,
|
||||
@@ -269,6 +281,8 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
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]
|
||||
|
||||
@@ -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<crate::AccountId>,
|
||||
/// 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<crate::AccountId>,
|
||||
/// 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<crate::AccountId>,
|
||||
}
|
||||
|
||||
/// 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<crate::AccountId> = params.cash_accounts.iter().copied().collect();
|
||||
let financing_set: HashSet<crate::AccountId> =
|
||||
params.financing_accounts.iter().copied().collect();
|
||||
let investing_set: HashSet<crate::AccountId> =
|
||||
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
|
||||
|
||||
@@ -724,6 +724,7 @@ fn cash_flow_params(cash: AccountId) -> CashFlowParams {
|
||||
end: date(31),
|
||||
cash_accounts: vec![cash],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶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);
|
||||
}
|
||||
@@ -77,6 +77,10 @@ pub struct Books {
|
||||
pub entries: Vec<EntryInfo>,
|
||||
pub entry_index: HashMap<EntryId, usize>,
|
||||
pub cash_accounts: Vec<AccountId>,
|
||||
/// `[books] financing_accounts`, resolved (empty when not set).
|
||||
pub financing_accounts: Vec<AccountId>,
|
||||
/// `[books] investing_accounts`, resolved (empty when not set).
|
||||
pub investing_accounts: Vec<AccountId>,
|
||||
pub card_accounts: Vec<AccountId>,
|
||||
/// Entries created or changed by the most recent refresh.
|
||||
pub fresh: HashSet<EntryId>,
|
||||
@@ -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<AccountId> {
|
||||
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<AccountId> = ledger
|
||||
.chart()
|
||||
.all()
|
||||
@@ -303,6 +314,8 @@ impl Books {
|
||||
entries,
|
||||
entry_index,
|
||||
cash_accounts,
|
||||
financing_accounts,
|
||||
investing_accounts,
|
||||
card_accounts,
|
||||
fresh,
|
||||
fresh_from_seq,
|
||||
|
||||
@@ -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<AccountId> {
|
||||
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()
|
||||
|
||||
@@ -113,6 +113,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
},
|
||||
(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<dyn std::error::Error>> {
|
||||
.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<dyn std::error::Error>> {
|
||||
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()),
|
||||
|
||||
@@ -36,6 +36,19 @@ pub struct BooksServer {
|
||||
store: Arc<Mutex<Store>>,
|
||||
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<String>,
|
||||
/// `[books] financing_accounts`.
|
||||
pub financing: Vec<String>,
|
||||
/// `[books] investing_accounts`.
|
||||
pub investing: Vec<String>,
|
||||
}
|
||||
|
||||
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<ledger_core::reports::CashFlowStatement, String> {
|
||||
let or = |given: &'_ [String], default: &'_ [String]| -> Vec<String> {
|
||||
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<ledger_core::reports::CashFlowStatement, String> {
|
||||
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, 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<String, String> {
|
||||
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(),
|
||||
|
||||
@@ -621,13 +621,21 @@ 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"]`). 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<String>,
|
||||
/// 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<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`.
|
||||
#[serde(default)]
|
||||
pub investing_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// One cash flow line.
|
||||
@@ -1315,13 +1323,19 @@ pub struct ExportParams {
|
||||
pub end: Option<String>,
|
||||
/// As-of date for balance_sheet, YYYY-MM-DD.
|
||||
pub as_of: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<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`.
|
||||
#[serde(default)]
|
||||
pub investing_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<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`.
|
||||
#[serde(default)]
|
||||
pub investing_accounts: Vec<String>,
|
||||
/// Include every receipt for the year's entries and the company's
|
||||
/// documents (default true).
|
||||
#[serde(default = "default_true")]
|
||||
|
||||
@@ -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<String> {
|
||||
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");
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
+29
-1
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user