//! 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::periods::allocate; use crate::{AccountType, Class, ClassId, JournalEntry, JournalLine, Ledger, LedgerError}; use chrono::NaiveDate; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; /// 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 { trial_balance_where(ledger, &|_| true) } /// The trial balance as of a date: every posted entry dated on or before /// it (closing entries included, so after a close it is the post-closing /// trial balance). pub fn trial_balance_as_of(ledger: &Ledger, as_of: NaiveDate) -> TrialBalance { trial_balance_where(ledger, &|e| e.date() <= as_of) } fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool) -> TrialBalance { let mut movements: BTreeMap = BTreeMap::new(); for entry in ledger.posted_entries().filter(|e| include(e)) { for line in entry.lines() { let signed = match line.side { crate::Side::Debit => line.amount.amount, crate::Side::Credit => -line.amount.amount, }; *movements.entry(line.account).or_default() += signed; } } 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 = movements.get(&account.id).copied().unwrap_or_default(); 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. For a /// date range, with a line per account, use [`profit_and_loss_for_period`]. #[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 { profit_and_loss_filtered(ledger, None) } /// Build the profit and loss statement, optionally filtered to one class. /// /// With `None` every line counts and the result is identical to /// [`profit_and_loss`]. With `Some(class)` only lines tagged with that /// class count. Unlike the account-at-a-time accumulation this works line /// by line, because classes live on lines: each line contributes its signed /// amount (positive on the account's normal side) to its account type's /// total. pub fn profit_and_loss_filtered( ledger: &Ledger, class_id: Option, ) -> Result { profit_and_loss_where(ledger, &|line| match class_id { None => true, Some(c) => line.class_id == Some(c), }) } /// P&L for one class. `class: None` is the unclassified bucket: lines with /// no class tag. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ClassProfitAndLoss { /// The class, or `None` for unclassified lines. pub class: Option, /// That bucket's profit and loss. pub profit_and_loss: ProfitAndLoss, } /// Profit and loss broken down by class: one row per class that has any P&L /// lines, plus an unclassified row when untagged P&L lines exist. Rows are /// sorted by class name; the unclassified row comes last. /// /// The rows partition the ledger: summing every row's revenue and expenses /// reproduces [`profit_and_loss`]. pub fn profit_and_loss_by_class(ledger: &Ledger) -> Result, LedgerError> { // Which classes actually have P&L lines, and whether any P&L line is // unclassified. Balance-sheet-only classes get no row. let mut buckets: BTreeSet> = BTreeSet::new(); for entry in ledger.posted_entries() { for line in entry.lines() { let account = ledger .chart() .get(line.account) .ok_or(LedgerError::UnknownAccount(line.account))?; if account.placeholder { continue; } if !matches!( account.account_type, AccountType::Revenue | AccountType::Expense ) { continue; } buckets.insert(line.class_id); } } let mut classes: Vec<&Class> = buckets .iter() .filter_map(|c| c.and_then(|id| ledger.get_class(id))) .collect(); classes.sort_by(|a, b| a.name.cmp(&b.name)); let mut rows = Vec::with_capacity(classes.len() + 1); for class in classes { let id = class.id; rows.push(ClassProfitAndLoss { class: Some(class.clone()), profit_and_loss: profit_and_loss_where(ledger, &|line| line.class_id == Some(id))?, }); } if buckets.contains(&None) { rows.push(ClassProfitAndLoss { class: None, profit_and_loss: profit_and_loss_where(ledger, &|line| line.class_id.is_none())?, }); } Ok(rows) } /// P&L over the posted lines accepted by `include`. Each line contributes /// its signed amount — positive on the account's normal side — to its /// account type's total. fn profit_and_loss_where( ledger: &Ledger, include: &dyn Fn(&JournalLine) -> bool, ) -> Result { let mut revenue = Decimal::ZERO; let mut expenses = Decimal::ZERO; for entry in ledger.posted_entries() { for line in entry.lines() { if !include(line) { continue; } let account = ledger .chart() .get(line.account) .ok_or(LedgerError::UnknownAccount(line.account))?; if account.placeholder { continue; } let signed = if line.side == account.account_type.normal_side() { line.amount.amount } else { -line.amount.amount }; match account.account_type { AccountType::Revenue => revenue += signed, AccountType::Expense => expenses += signed, _ => {} } } } 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, }) } /// One account's line on a financial statement. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct StatementLine { /// Account code, e.g. `"6000"`. pub code: String, /// Account name. pub name: String, /// Amount on the account's normal side: positive is a normal balance /// (or movement), negative a contra one. pub amount: Decimal, } /// Profit and loss for a date range, with a line per account. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ProfitAndLossStatement { /// First date, inclusive. pub start: NaiveDate, /// Last date, inclusive. pub end: NaiveDate, /// Revenue accounts with movement in the range, by code. pub revenue: Vec, /// Expense accounts with movement in the range, by code. pub expenses: Vec, /// Sum of the revenue lines. pub total_revenue: Decimal, /// Sum of the expense lines. pub total_expenses: Decimal, } impl ProfitAndLossStatement { /// Revenue minus expenses. Positive is profit. pub fn net_income(&self) -> Decimal { self.total_revenue - self.total_expenses } } /// Balance sheet as of a date, with a line per account. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BalanceSheetStatement { /// The as-of date, inclusive. pub as_of: NaiveDate, /// Asset accounts with a balance, by code. pub assets: Vec, /// Liability accounts with a balance, by code. pub liabilities: Vec, /// Equity accounts with a balance, by code (current earnings excluded). pub equity: Vec, /// Revenue minus expenses through `as_of` not yet closed to retained /// earnings. Shown as its own equity line. pub current_earnings: Decimal, /// Sum of the asset lines. pub total_assets: Decimal, /// Sum of the liability lines. pub total_liabilities: Decimal, /// Sum of the equity lines plus current earnings. pub total_equity: Decimal, } impl BalanceSheetStatement { /// The accounting equation: assets == liabilities + equity, exactly. pub fn balanced(&self) -> bool { self.total_assets == self.total_liabilities + self.total_equity } } /// Normal-side amount per account over the posted lines accepted by /// `include(entry, line)`, as statement lines keyed (and so sorted) by /// code, skipping placeholders and zero amounts. fn statement_lines( ledger: &Ledger, include: &dyn Fn(&JournalEntry, &JournalLine) -> bool, ) -> Result, LedgerError> { let mut movement: BTreeMap = BTreeMap::new(); for entry in ledger.posted_entries() { for line in entry.lines() { if !include(entry, line) { continue; } let signed = match line.side { crate::Side::Debit => line.amount.amount, crate::Side::Credit => -line.amount.amount, }; *movement.entry(line.account).or_default() += signed; } } let mut out = BTreeMap::new(); for (id, m) in movement { let account = ledger .chart() .get(id) .ok_or(LedgerError::UnknownAccount(id))?; if account.placeholder || m.is_zero() { continue; } let amount = match account.account_type.normal_side() { crate::Side::Debit => m, crate::Side::Credit => -m, }; out.insert( account.code.clone(), ( account.account_type, StatementLine { code: account.code.clone(), name: account.name.clone(), amount, }, ), ); } Ok(out) } /// Profit and loss for `[start, end]`, optionally for one class (`None` /// counts every line). Closing entries are excluded: they move a period's /// earnings into retained earnings, so counting them would report a closed /// period as breaking even. pub fn profit_and_loss_for_period( ledger: &Ledger, start: NaiveDate, end: NaiveDate, class_id: Option, ) -> Result { if end < start { return Err(LedgerError::InvalidDateRange { start: start.to_string(), end: end.to_string(), }); } let closings = ledger.closing_entry_ids(); let lines = statement_lines(ledger, &|entry, line| { entry.date() >= start && entry.date() <= end && !closings.contains(&entry.id()) && class_id.is_none_or(|c| line.class_id == Some(c)) })?; let mut revenue = Vec::new(); let mut expenses = Vec::new(); for (typ, line) in lines.into_values() { match typ { AccountType::Revenue => revenue.push(line), AccountType::Expense => expenses.push(line), _ => {} } } let total_revenue = revenue.iter().map(|l| l.amount).sum(); let total_expenses = expenses.iter().map(|l| l.amount).sum(); Ok(ProfitAndLossStatement { start, end, revenue, expenses, total_revenue, total_expenses, }) } /// Balance sheet as of `as_of`: every posted entry dated on or before it. /// Revenue and expense not yet closed to retained earnings appear as /// current earnings inside equity, so the accounting equation holds on /// every date, before and after closes. pub fn balance_sheet_as_of( ledger: &Ledger, as_of: NaiveDate, ) -> Result { let lines = statement_lines(ledger, &|entry, _| entry.date() <= as_of)?; let mut assets = Vec::new(); let mut liabilities = Vec::new(); let mut equity = Vec::new(); let mut current_earnings = Decimal::ZERO; for (typ, line) in lines.into_values() { match typ { AccountType::Asset => assets.push(line), AccountType::Liability => liabilities.push(line), AccountType::Equity => equity.push(line), AccountType::Revenue => current_earnings += line.amount, AccountType::Expense => current_earnings -= line.amount, } } let total_assets = assets.iter().map(|l| l.amount).sum(); let total_liabilities = liabilities.iter().map(|l| l.amount).sum(); let total_equity = equity.iter().map(|l| l.amount).sum::() + current_earnings; Ok(BalanceSheetStatement { as_of, assets, liabilities, equity, current_earnings, total_assets, total_liabilities, total_equity, }) } // --------------------------------------------------------------------------- // General ledger (by account) // --------------------------------------------------------------------------- /// One posting in the general ledger: a journal line, seen from its account. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct LedgerPosting { /// Entry date. pub date: NaiveDate, /// The journal entry: its other lines are the other side. pub entry_id: crate::EntryId, /// Entry description. pub description: String, /// The other side: `"CODE Name"` of the one account on the entry's /// opposite side, or `"-Split-"` when that side is several accounts /// (the QuickBooks convention). pub split: String, /// Debit amount, or zero. pub debit: Decimal, /// Credit amount, or zero. pub credit: Decimal, /// Running balance after this posting, on the account's normal side. pub balance: Decimal, } /// One account's section of the general ledger. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LedgerAccount { /// Account code. pub code: String, /// Account name. pub name: String, /// Account type. pub account_type: AccountType, /// Balance before the period, normal side. pub beginning_balance: Decimal, /// Postings in the period, by date then entry. pub postings: Vec, /// Sum of the period's debits. pub total_debits: Decimal, /// Sum of the period's credits. pub total_credits: Decimal, /// Balance at period end, normal side. Ties to the trial balance. pub ending_balance: Decimal, } /// The general ledger for a period: every account with a beginning balance /// or activity, in chart (code) order. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct GeneralLedger { /// First date, inclusive. pub start: NaiveDate, /// Last date, inclusive. pub end: NaiveDate, /// Accounts by code. pub accounts: Vec, } /// Build the general ledger for `[start, end]`. Every journal line is a /// posting to its account, with the entry's other side as the split, and a /// running balance from the account's balance before `start`. Closing /// entries are postings like any other. pub fn general_ledger( ledger: &Ledger, start: NaiveDate, end: NaiveDate, ) -> Result { if end < start { return Err(LedgerError::InvalidDateRange { start: start.to_string(), end: end.to_string(), }); } let display = |id: crate::AccountId| -> Result { let a = ledger .chart() .get(id) .ok_or(LedgerError::UnknownAccount(id))?; Ok(format!("{} {}", a.code, a.name)) }; let mut entries: Vec<&JournalEntry> = ledger.posted_entries().collect(); entries.sort_by_key(|e| (e.date(), e.id())); let mut begin: BTreeMap = BTreeMap::new(); let mut postings: BTreeMap> = BTreeMap::new(); for entry in &entries { let lines = entry.lines(); for (i, line) in lines.iter().enumerate() { let signed = match line.side { crate::Side::Debit => line.amount.amount, crate::Side::Credit => -line.amount.amount, }; if entry.date() < start { *begin.entry(line.account).or_default() += signed; continue; } if entry.date() > end { continue; } // The other side is the accounts on the opposite side of the // entry: a credit's split is what was debited, and vice versa. let others: BTreeSet = lines .iter() .enumerate() .filter(|(j, l)| *j != i && l.side != line.side) .map(|(_, l)| l.account) .collect(); let split = match others.len() { 1 => display(*others.iter().next().expect("one"))?, _ => "-Split-".to_string(), }; let (debit, credit) = match line.side { crate::Side::Debit => (line.amount.amount, Decimal::ZERO), crate::Side::Credit => (Decimal::ZERO, line.amount.amount), }; postings.entry(line.account).or_default().push(( line.side, line.amount.amount, LedgerPosting { date: entry.date(), entry_id: entry.id(), description: entry.description().to_string(), split, debit, credit, balance: Decimal::ZERO, }, )); } } let mut accounts: Vec<_> = ledger.chart().all().collect(); accounts.sort_by(|a, b| a.code.cmp(&b.code)); let mut out = Vec::new(); for account in accounts { let begin_movement = begin.get(&account.id).copied().unwrap_or_default(); let lines = postings.remove(&account.id).unwrap_or_default(); if begin_movement.is_zero() && lines.is_empty() { continue; } let normal = account.account_type.normal_side(); let to_normal = |m: Decimal| if normal == crate::Side::Debit { m } else { -m }; let beginning_balance = to_normal(begin_movement) + Decimal::ZERO; let mut balance = beginning_balance; let (mut total_debits, mut total_credits) = (Decimal::ZERO, Decimal::ZERO); let mut posted = Vec::with_capacity(lines.len()); for (side, amount, mut p) in lines { balance += if side == normal { amount } else { -amount }; total_debits += p.debit; total_credits += p.credit; p.balance = balance; posted.push(p); } out.push(LedgerAccount { code: account.code.clone(), name: account.name.clone(), account_type: account.account_type, beginning_balance: zero_clean(beginning_balance), postings: posted, total_debits, total_credits, ending_balance: zero_clean(balance), }); } Ok(GeneralLedger { start, end, accounts: out, }) } /// Never let a negative zero reach a report. fn zero_clean(d: Decimal) -> Decimal { if d.is_zero() { Decimal::ZERO } else { d } } // --------------------------------------------------------------------------- // Statement of changes in members' equity (partnership capital accounts) // --------------------------------------------------------------------------- /// A member of a partnership (multi-member LLC): their capital account and /// their share of income. #[derive(Clone, Debug)] pub struct MemberCapital { /// Member's name. pub name: String, /// Their capital account (equity): contributions credit it, /// distributions debit it. pub capital_account: crate::AccountId, /// Their distributions account (contra-equity), if distributions are /// recorded there rather than directly against capital. Part of their /// capital on every statement; zeroed into capital at close. pub distribution_account: Option, /// Share of income, e.g. 0.5. The shares must sum to exactly 1. pub share: Decimal, } /// One member's (or the total) capital roll-forward for a period. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct MemberEquityColumn { /// Member's name, or "Total". pub name: String, /// Capital at the start of the period, including their share of /// earnings not yet distributed. pub beginning: Decimal, /// Contributions during the period. pub contributions: Decimal, /// Their share of the period's net income (loss). pub net_income: Decimal, /// Distributions during the period (a positive number, subtracted). pub distributions: Decimal, /// Any other change in equity, allocated by share. Zero unless equity /// moved through an account that isn't a member's capital account, retained /// earnings, or the period's income. pub other: Decimal, /// Capital at the end of the period. pub ending: Decimal, } /// The statement of changes in members' equity: each member's capital /// account roll-forward, plus a total that ties to the balance sheet. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MembersEquityStatement { /// First date, inclusive. pub start: NaiveDate, /// Last date, inclusive. pub end: NaiveDate, /// One column per member, in the order given. pub members: Vec, /// Sum of the members. `ending` equals total equity on the balance /// sheet as of `end`. pub total: MemberEquityColumn, } /// Build the statement of changes in members' equity for `[start, end]`. /// /// Each member's capital account supplies contributions (credits) and /// distributions (debits). Earnings are partnership earnings: net income /// for the period, and earnings from before it not yet distributed (retained /// earnings plus unclosed income), are allocated by share. Allocation is to /// the cent with the last member absorbing rounding, so every row's total /// is exact and the ending total equals balance-sheet equity. pub fn members_equity( ledger: &Ledger, start: NaiveDate, end: NaiveDate, members: &[MemberCapital], ) -> Result { if members.is_empty() { return Err(LedgerError::InvalidMembers("no members given".to_string())); } let shares: Vec = members.iter().map(|m| m.share).collect(); let total_share: Decimal = shares.iter().sum(); if total_share != Decimal::ONE || shares.iter().any(|s| *s <= Decimal::ZERO) { return Err(LedgerError::InvalidMembers(format!( "member shares must be positive and sum to 1, got {total_share}" ))); } let mut seen = BTreeSet::new(); for m in members { for id in std::iter::once(m.capital_account).chain(m.distribution_account) { let account = ledger .chart() .get(id) .ok_or(LedgerError::UnknownAccount(id))?; if account.account_type != AccountType::Equity || !seen.insert(id) { return Err(LedgerError::InvalidMembers(format!( "{}: account {} must be a distinct equity account", m.name, account.code ))); } } } let day_before = start.pred_opt().ok_or(LedgerError::InvalidDateRange { start: start.to_string(), end: end.to_string(), })?; let pnl = profit_and_loss_for_period(ledger, start, end, None)?; let begin_bs = balance_sheet_as_of(ledger, day_before)?; let end_bs = balance_sheet_as_of(ledger, end)?; // Capital account balances before the period, and their movement in it. let capital_before = |id: crate::AccountId| -> Decimal { let mut m = Decimal::ZERO; for e in ledger.posted_entries().filter(|e| e.date() < start) { for l in e.lines().iter().filter(|l| l.account == id) { match l.side { crate::Side::Credit => m += l.amount.amount, crate::Side::Debit => m -= l.amount.amount, } } } m }; // Contributions and distributions move a capital account; a closing // entry allocating earnings into it is income, already counted below. let closings = ledger.closing_entry_ids(); let flows = |id: crate::AccountId| -> (Decimal, Decimal) { let (mut credits, mut debits) = (Decimal::ZERO, Decimal::ZERO); for e in ledger .posted_entries() .filter(|e| e.date() >= start && e.date() <= end && !closings.contains(&e.id())) { for l in e.lines().iter().filter(|l| l.account == id) { match l.side { crate::Side::Credit => credits += l.amount.amount, crate::Side::Debit => debits += l.amount.amount, } } } (credits, debits) }; // A member's capital is their capital account plus their distributions // account (a debit balance, so it reduces capital). let capital: Vec = members .iter() .map(|m| { capital_before(m.capital_account) + m.distribution_account.map_or(Decimal::ZERO, capital_before) }) .collect(); // (contributions, distributions): credits to capital are // contributions; debits to capital and net debits to the distributions // account are distributions. let flow: Vec<(Decimal, Decimal)> = members .iter() .map(|m| { let (credits, debits) = flows(m.capital_account); let (dc, dd) = m .distribution_account .map_or((Decimal::ZERO, Decimal::ZERO), flows); (credits, debits + dd - dc) }) .collect(); // Everything in equity that isn't a capital account is partnership // earnings not yet distributed: allocate it by share. let undistributed = begin_bs.total_equity - capital.iter().sum::(); let begin_alloc = allocate(undistributed, &shares); let income_alloc = allocate(pnl.net_income(), &shares); let explained = flow.iter().map(|(c, d)| c - d).sum::() + pnl.net_income(); let other_alloc = allocate( end_bs.total_equity - begin_bs.total_equity - explained, &shares, ); let mut cols = Vec::with_capacity(members.len()); for (i, m) in members.iter().enumerate() { let beginning = capital[i] + begin_alloc[i]; let (contributions, distributions) = flow[i]; let ending = beginning + contributions - distributions + income_alloc[i] + other_alloc[i]; cols.push(MemberEquityColumn { name: m.name.clone(), beginning: zero_clean(beginning), contributions, net_income: zero_clean(income_alloc[i]), distributions, other: zero_clean(other_alloc[i]), ending: zero_clean(ending), }); } let sum = |f: fn(&MemberEquityColumn) -> Decimal| cols.iter().map(f).sum::(); let total = MemberEquityColumn { name: "Total".to_string(), beginning: sum(|c| c.beginning), contributions: sum(|c| c.contributions), net_income: sum(|c| c.net_income), distributions: sum(|c| c.distributions), other: sum(|c| c.other), ending: sum(|c| c.ending), }; debug_assert_eq!(total.ending, end_bs.total_equity); Ok(MembersEquityStatement { start, end, members: cols, total, }) } /// 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, /// Code of the account the line is about, when it is about one (so a /// renderer can relabel, e.g. a member's capital account by name). #[serde(default)] pub account: Option, } /// 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. Closing // entries are skipped: a close moves earnings into retained earnings, // which is neither net income nor an owner flow. let closings = ledger.closing_entry_ids(); 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()) || closings.contains(&entry.id()) { 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) }; // By code: the chart iterates in hash order, and a statement's lines // must come out in the same order every time. let mut accounts: Vec<_> = ledger.chart().all().collect(); accounts.sort_by(|a, b| a.code.cmp(&b.code)); for account in accounts { 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, 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 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, account: ledger.chart().get(cost_acct).map(|a| a.code.clone()), }); 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, account: ledger.chart().get(line.account).map(|a| a.code.clone()), }); } } } // 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, account: None, }); } 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, }) }