//! Reports: pure functions of a [`Ledger`](crate::Ledger). //! //! Reports never mutate and never guess. They are what the semantic oracle //! (`docs/oracle.md`) diffs: the same fixture books through two engines must //! produce byte-identical [`snapshot`] output. use crate::{AccountType, Ledger, LedgerError}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; /// One line of a trial balance. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TrialBalanceLine { /// Account code, e.g. `"1000"`. pub code: String, /// Account name, e.g. `"Cash"`. pub name: String, /// Debit balance, or zero. pub debit: Decimal, /// Credit balance, or zero. pub credit: Decimal, } /// The trial balance: every account with a non-zero balance, debit and credit /// columns, and column totals. A correct book always has equal totals. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct TrialBalance { /// Lines sorted by account code. Deterministic order matters for goldens. pub lines: Vec, /// Sum of the debit column. pub total_debits: Decimal, /// Sum of the credit column. pub total_credits: Decimal, } impl TrialBalance { /// The fundamental check: debits equal credits, exactly. pub fn balanced(&self) -> bool { self.total_debits == self.total_credits } } /// Build the trial balance from all posted entries. pub fn trial_balance(ledger: &Ledger) -> TrialBalance { let mut accounts: Vec<_> = ledger.chart().all().collect(); accounts.sort_by(|a, b| a.code.cmp(&b.code)); let mut lines = Vec::new(); let mut total_debits = Decimal::ZERO; let mut total_credits = Decimal::ZERO; for account in accounts { if account.placeholder { continue; } let movement = ledger.account_movement(account.id); if movement.is_zero() { continue; } let (debit, credit) = if movement > Decimal::ZERO { (movement, Decimal::ZERO) } else { (Decimal::ZERO, -movement) }; total_debits += debit; total_credits += credit; lines.push(TrialBalanceLine { code: account.code.clone(), name: account.name.clone(), debit, credit, }); } TrialBalance { lines, total_debits, total_credits, } } /// Profit and loss: revenue and expenses for the life of the ledger. /// (Period filtering is R-FUTURE work; see `docs/requirements.md` R-7.) #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProfitAndLoss { /// Total revenue (credit-normal balances, reported positive). pub revenue: Decimal, /// Total expenses (debit-normal balances, reported positive). pub expenses: Decimal, } impl ProfitAndLoss { /// Revenue minus expenses. Positive is profit. pub fn net_income(&self) -> Decimal { self.revenue - self.expenses } } /// Build the profit and loss statement from all posted entries. pub fn profit_and_loss(ledger: &Ledger) -> Result { let mut revenue = Decimal::ZERO; let mut expenses = Decimal::ZERO; for account in ledger.chart().all() { if account.placeholder { continue; } let balance = ledger.account_balance(account.id)?; match account.account_type { AccountType::Revenue => revenue += balance, AccountType::Expense => expenses += balance, _ => {} } } Ok(ProfitAndLoss { revenue, expenses }) } /// Balance sheet: assets, liabilities, and equity. /// /// Because day-one ledgers have no closing entries yet (R-7), current /// earnings (net income to date) are folded into equity so that the /// accounting equation holds on a live book: assets = liabilities + equity. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BalanceSheet { /// Total assets (debit-normal balances, reported positive). pub assets: Decimal, /// Total liabilities (credit-normal balances, reported positive). pub liabilities: Decimal, /// Total equity *including* current earnings. pub equity: Decimal, /// Net income to date, folded into equity above. pub current_earnings: Decimal, } impl BalanceSheet { /// The accounting equation: assets == liabilities + equity, exactly. pub fn balanced(&self) -> bool { self.assets == self.liabilities + self.equity } } /// Build the balance sheet from all posted entries. pub fn balance_sheet(ledger: &Ledger) -> Result { let mut assets = Decimal::ZERO; let mut liabilities = Decimal::ZERO; let mut equity = Decimal::ZERO; for account in ledger.chart().all() { if account.placeholder { continue; } let balance = ledger.account_balance(account.id)?; match account.account_type { AccountType::Asset => assets += balance, AccountType::Liability => liabilities += balance, AccountType::Equity => equity += balance, AccountType::Revenue | AccountType::Expense => {} } } let current_earnings = profit_and_loss(ledger)?.net_income(); equity += current_earnings; Ok(BalanceSheet { assets, liabilities, equity, current_earnings, }) } /// Render the three core reports as canonical JSON for oracle diffing. /// /// Amounts are decimal strings (never floats, never locale-formatted), lines /// are sorted by account code, and object keys are fixed. Two engines fed the /// same fixture books must produce byte-identical output — that is the whole /// point (see `docs/oracle.md`). pub fn snapshot(ledger: &Ledger) -> Result { let tb = trial_balance(ledger); let pl = profit_and_loss(ledger)?; let bs = balance_sheet(ledger)?; Ok(serde_json::json!({ "trial_balance": { "lines": tb.lines.iter().map(|l| serde_json::json!({ "code": l.code, "name": l.name, "debit": l.debit.to_string(), "credit": l.credit.to_string(), })).collect::>(), "total_debits": tb.total_debits.to_string(), "total_credits": tb.total_credits.to_string(), }, "profit_and_loss": { "revenue": pl.revenue.to_string(), "expenses": pl.expenses.to_string(), "net_income": pl.net_income().to_string(), }, "balance_sheet": { "assets": bs.assets.to_string(), "liabilities": bs.liabilities.to_string(), "equity": bs.equity.to_string(), "current_earnings": bs.current_earnings.to_string(), "balanced": bs.balanced(), }, })) } // --------------------------------------------------------------------------- // 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, }) }