From 5225abb9dd27ea814aa7e1c182f2eca125a329ad Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 03:01:19 +0000 Subject: [PATCH] Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan --- crates/ledger-core/src/reports.rs | 346 ++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs index ca98c69..ce95f74 100644 --- a/crates/ledger-core/src/reports.rs +++ b/crates/ledger-core/src/reports.rs @@ -198,3 +198,349 @@ pub fn snapshot(ledger: &Ledger) -> Result { }, })) } + +// --------------------------------------------------------------------------- +// Cash flow statement (indirect method, R-FUTURE-3). +// --------------------------------------------------------------------------- + +/// One labeled cash flow line. Positive is an inflow, negative an outflow. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CashFlowLine { + /// Human label, e.g. `"Increase in Accounts Receivable"` or account code/name. + pub label: String, + /// Signed amount: positive = cash in, negative = cash out. + pub amount: Decimal, +} + +/// Parameters for [`cash_flow`]. +#[derive(Clone, Debug)] +pub struct CashFlowParams { + /// Period start, inclusive. + pub start: chrono::NaiveDate, + /// Period end, inclusive. + 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. + pub financing_accounts: Vec, +} + +/// Cash flow statement, indirect method. +/// +/// Starts from period net income, adds back non-cash depreciation, adjusts +/// for working-capital changes, then reports investing (fixed-asset +/// purchases at cost, disposal proceeds) and financing (equity changes, +/// designated debt accounts) flows. The three sections always sum to the +/// net change in the designated cash accounts. +/// +/// Disposals are identified structurally: a posted entry that credits a +/// registered fixed-asset cost account and debits its linked accumulated +/// depreciation account. The gain/loss in that entry is removed from +/// operating and the proceeds are shown in investing. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CashFlowStatement { + /// Period start, inclusive. + pub start: chrono::NaiveDate, + /// Period end, inclusive. + pub end: chrono::NaiveDate, + /// Period net income (revenue minus expenses). + pub net_income: Decimal, + /// Non-cash depreciation added back to net income. + pub depreciation: Decimal, + /// Working-capital adjustments (non-cash balance-sheet changes). + pub working_capital: Vec, + /// Net cash from operating activities. + pub cash_from_operations: Decimal, + /// Investing flows: asset purchases and disposal proceeds. + pub investing: Vec, + /// Net cash from investing activities. + pub cash_from_investing: Decimal, + /// Financing flows: equity changes and designated debt. + pub financing: Vec, + /// Net cash from financing activities. + pub cash_from_financing: Decimal, + /// Net change in cash: the three sections summed. + pub net_change_in_cash: Decimal, + /// Cash balance at period start. + pub cash_beginning: Decimal, + /// Cash balance at period end. + pub cash_ending: Decimal, +} + +/// Build the indirect-method cash flow statement for a period. +/// +/// 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`. +pub fn cash_flow( + ledger: &Ledger, + params: &CashFlowParams, +) -> Result { + use std::collections::{HashMap, HashSet}; + + if params.end < params.start { + return Err(LedgerError::InvalidDateRange { + start: params.start.to_string(), + end: params.end.to_string(), + }); + } + + // Fixed-asset account roles from the register. + let mut cost_accounts: HashSet = HashSet::new(); + let mut accum_accounts: HashSet = HashSet::new(); + let mut depr_expense_accounts: HashSet = HashSet::new(); + // cost_account -> accum_account, for disposal identification. + let mut cost_to_accum: HashMap = HashMap::new(); + for asset in ledger.registered_assets() { + cost_accounts.insert(asset.cost_account); + accum_accounts.insert(asset.accum_depr_account); + depr_expense_accounts.insert(asset.expense_account); + cost_to_accum.insert(asset.cost_account, asset.accum_depr_account); + } + + let cash_set: HashSet = params.cash_accounts.iter().copied().collect(); + let financing_set: HashSet = + params.financing_accounts.iter().copied().collect(); + + // Movement helpers: debits minus credits over a date predicate. + let movement_where = |account: crate::AccountId, pred: &dyn Fn(chrono::NaiveDate) -> bool| { + let mut total = Decimal::ZERO; + for entry in ledger.posted_entries() { + if !pred(entry.date()) { + continue; + } + for line in entry.lines() { + if line.account == account { + match line.side { + crate::Side::Debit => total += line.amount.amount, + crate::Side::Credit => total -= line.amount.amount, + } + } + } + } + total + }; + let begin_movement = |a: crate::AccountId| movement_where(a, &|d| d < params.start); + let period_movement = + |a: crate::AccountId| movement_where(a, &|d| d >= params.start && d <= params.end); + + // Normal-side balance from a movement. + let normal = |account: crate::AccountId, movement: Decimal| -> Decimal { + let acct = ledger.chart().get(account).expect("chart account exists"); + match acct.account_type.normal_side() { + crate::Side::Debit => movement, + crate::Side::Credit => -movement, + } + }; + + // --- Net income and depreciation (period P&L) --- + let mut net_income = Decimal::ZERO; + let mut depreciation = Decimal::ZERO; + for account in ledger.chart().all() { + if account.placeholder { + continue; + } + let pm = period_movement(account.id); + if pm.is_zero() { + continue; + } + match account.account_type { + AccountType::Revenue => net_income += -pm, // revenue movement is credits + AccountType::Expense => { + net_income -= pm; + if depr_expense_accounts.contains(&account.id) { + depreciation += pm; + } + } + _ => {} + } + } + + // --- Classify balance-sheet accounts --- + let mut working_capital: Vec = Vec::new(); + let mut investing: Vec = Vec::new(); + let mut financing: Vec = Vec::new(); + + // Account display helper. + let display = |account: crate::AccountId| -> String { + let a = ledger.chart().get(account).expect("chart account exists"); + format!("{} {}", a.code, a.name) + }; + + for account in ledger.chart().all() { + if account.placeholder { + continue; + } + let id = account.id; + if cash_set.contains(&id) || cost_accounts.contains(&id) || accum_accounts.contains(&id) { + continue; // cash reconciles; fixed assets are investing (gross, below) + } + let begin = normal(id, begin_movement(id)); + let end = normal(id, period_movement(id) + begin_movement(id)); + let delta = end - begin; + if delta.is_zero() { + continue; + } + // Cash effect of the balance change: for both assets and + // liabilities, it is minus the movement (debits - credits). + let movement_delta = period_movement(id); + let cash_effect = -movement_delta; + let (label, line) = match account.account_type { + AccountType::Asset => { + let dir = if delta > Decimal::ZERO { + "Increase in" + } else { + "Decrease in" + }; + (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" + } else { + "Decrease in" + }; + (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 { + label, + amount: line, + }); + } + + // --- 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 + for entry in ledger.posted_entries() { + let d = entry.date(); + if d < params.start || d > params.end { + continue; + } + // Is this a disposal? Credit to a cost account + debit to its accum. + let mut disposed: Option<(crate::AccountId, Decimal, Decimal)> = None; + for line in entry.lines() { + if line.side == crate::Side::Credit + && let Some(&accum) = cost_to_accum.get(&line.account) + { + // Confirm the linked accum account is debited in this entry. + let accum_debit: Decimal = entry + .lines() + .iter() + .filter(|l| l.account == accum && l.side == crate::Side::Debit) + .map(|l| l.amount.amount) + .sum(); + if !accum_debit.is_zero() { + disposed = Some((line.account, line.amount.amount, accum_debit)); + break; + } + } + } + if let Some((cost_acct, cost_amount, accum_debit)) = disposed { + let accum_acct = cost_to_accum[&cost_acct]; + let mut proceeds = Decimal::ZERO; + let mut proceeds_label = String::new(); + let mut gain = Decimal::ZERO; + let mut loss = Decimal::ZERO; + for line in entry.lines() { + if line.account == cost_acct || line.account == accum_acct { + continue; + } + let acct = ledger + .chart() + .get(line.account) + .expect("chart account exists"); + match (acct.account_type, line.side) { + (AccountType::Asset, crate::Side::Debit) => { + proceeds += line.amount.amount; + proceeds_label = display(line.account); + } + (AccountType::Revenue, crate::Side::Credit) => gain += line.amount.amount, + (AccountType::Expense, crate::Side::Debit) => loss += line.amount.amount, + _ => {} + } + } + let book_value = cost_amount - accum_debit; + // Sanity: the entry balances, so proceeds + accum debit = cost + gain - loss. + debug_assert!((proceeds + accum_debit - cost_amount - gain + loss).is_zero()); + let _ = book_value; + disposal_gain_loss += gain - loss; + investing.push(CashFlowLine { + label: format!("Sale of fixed assets — {proceeds_label}"), + amount: proceeds, + }); + continue; + } + // Not a disposal: debits to cost accounts are purchases. + for line in entry.lines() { + if cost_accounts.contains(&line.account) && line.side == crate::Side::Debit { + investing.push(CashFlowLine { + label: format!("Purchase of fixed assets — {}", display(line.account)), + amount: -line.amount.amount, + }); + } + } + } + + // Remove disposal gains/losses from operating (they live in investing). + if !disposal_gain_loss.is_zero() { + working_capital.push(CashFlowLine { + label: "Less: gain on sale of fixed assets (in investing)".to_string(), + amount: -disposal_gain_loss, + }); + } + + let cash_from_operations: Decimal = + net_income + depreciation + working_capital.iter().map(|l| l.amount).sum::(); + let cash_from_investing: Decimal = investing.iter().map(|l| l.amount).sum(); + let cash_from_financing: Decimal = financing.iter().map(|l| l.amount).sum(); + + // --- Cash reconciliation --- + let mut cash_beginning = Decimal::ZERO; + let mut cash_ending = Decimal::ZERO; + for &id in ¶ms.cash_accounts { + cash_beginning += normal(id, begin_movement(id)); + cash_ending += normal(id, begin_movement(id) + period_movement(id)); + } + let net_change_in_cash = cash_ending - cash_beginning; + + Ok(CashFlowStatement { + start: params.start, + end: params.end, + net_income, + depreciation, + working_capital, + cash_from_operations, + investing, + cash_from_investing, + financing, + cash_from_financing, + net_change_in_cash, + cash_beginning, + cash_ending, + }) +}