Files
wanderbooks/crates/ledger-core/src/reports.rs
T
swaitsandClaude Opus 5.5 dbdc8e671f feat: entity-aware owner equity — chart templates, draws, distributions, dividends
Every business has owners, and how owners put money in and take it out
depends on what the business is. wanderbooks now knows the four common
US shapes:

- books.chart_template {entity_type, owners}: a starter chart for a sole
  proprietorship (owner's capital and draws), partnership or
  multi-member LLC (capital and distributions per partner, guaranteed
  payments), S corporation (stock, APIC, shareholder distributions,
  officer compensation, retained earnings) or C corporation (dividends,
  income tax expense), plus common income and expense accounts with
  tax categories, and the wanderbooks.toml settings that go with it
  (tax form, members with equal-share placeholders that sum to exactly
  100, close target). A proposal for the agent to review with the human.
- Closing: CloseTo is now RetainedEarnings { sweep }, Account { into,
  sweep } (a sole proprietor's capital) or Members([MemberClose { capital,
  share, distributions }]). Sweeps zero owner-pay equity accounts into
  their target at close: draws into owner's capital, dividends or
  distributions into retained earnings, each partner's distributions
  into their own capital. Validation (equity, distinct, shares sum to 1)
  runs in the pre-close preview, before anything is journaled. The
  journal records the target; journals with no target or the old
  "retained_earnings" string still replay.
- Members' equity: a partner's distributions account is part of their
  capital; its activity reports as distributions.
- Config: members' distribution_account; [books] close_into and
  close_sweep for single-owner businesses (rejected alongside members).
  The cash flow names members on their distribution accounts too.

Tests: a sole proprietor (net 2,000 and 500 of draws into capital: 6,500,
the cash); a corporation (dividends 300 into retained earnings: 600); a
partnership with distribution accounts (Bob 100 + 25 - 20 = 105, swept at
close, February begins there); bad sweep accounts; templates round-trip
through the chart parser, unique codes, equity per entity, every tax
category valid for the entity's return; stdio: template -> settings ->
import -> postings -> close with the exact closing lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:01:31 -07:00

1261 lines
47 KiB
Rust

//! 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<TrialBalanceLine>,
/// 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<crate::AccountId, Decimal> = 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<ProfitAndLoss, LedgerError> {
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<ClassId>,
) -> Result<ProfitAndLoss, LedgerError> {
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<Class>,
/// 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<Vec<ClassProfitAndLoss>, 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<Option<ClassId>> = 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<ProfitAndLoss, LedgerError> {
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<BalanceSheet, LedgerError> {
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<StatementLine>,
/// Expense accounts with movement in the range, by code.
pub expenses: Vec<StatementLine>,
/// 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<StatementLine>,
/// Liability accounts with a balance, by code.
pub liabilities: Vec<StatementLine>,
/// Equity accounts with a balance, by code (current earnings excluded).
pub equity: Vec<StatementLine>,
/// 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<BTreeMap<String, (AccountType, StatementLine)>, LedgerError> {
let mut movement: BTreeMap<crate::AccountId, Decimal> = 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<ClassId>,
) -> Result<ProfitAndLossStatement, LedgerError> {
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<BalanceSheetStatement, LedgerError> {
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::<Decimal>() + 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<LedgerPosting>,
/// 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<LedgerAccount>,
}
/// 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<GeneralLedger, LedgerError> {
if end < start {
return Err(LedgerError::InvalidDateRange {
start: start.to_string(),
end: end.to_string(),
});
}
let display = |id: crate::AccountId| -> Result<String, LedgerError> {
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<crate::AccountId, Decimal> = BTreeMap::new();
let mut postings: BTreeMap<crate::AccountId, Vec<(crate::Side, Decimal, LedgerPosting)>> =
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<crate::AccountId> = 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<crate::AccountId>,
/// 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<MemberEquityColumn>,
/// 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<MembersEquityStatement, LedgerError> {
if members.is_empty() {
return Err(LedgerError::InvalidMembers("no members given".to_string()));
}
let shares: Vec<Decimal> = 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<Decimal> = 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::<Decimal>();
let begin_alloc = allocate(undistributed, &shares);
let income_alloc = allocate(pnl.net_income(), &shares);
let explained = flow.iter().map(|(c, d)| c - d).sum::<Decimal>() + 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::<Decimal>();
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<serde_json::Value, LedgerError> {
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::<Vec<_>>(),
"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<String>,
}
/// 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<crate::AccountId>,
/// Liability accounts treated as financing (debt). Default empty: all
/// liabilities are operating working capital, all equity is financing.
pub financing_accounts: Vec<crate::AccountId>,
}
/// 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<CashFlowLine>,
/// Net cash from operating activities.
pub cash_from_operations: Decimal,
/// Investing flows: asset purchases and disposal proceeds.
pub investing: Vec<CashFlowLine>,
/// Net cash from investing activities.
pub cash_from_investing: Decimal,
/// Financing flows: equity changes and designated debt.
pub financing: Vec<CashFlowLine>,
/// 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<CashFlowStatement, LedgerError> {
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<crate::AccountId> = HashSet::new();
let mut accum_accounts: HashSet<crate::AccountId> = HashSet::new();
let mut depr_expense_accounts: HashSet<crate::AccountId> = HashSet::new();
// cost_account -> accum_account, for disposal identification.
let mut cost_to_accum: HashMap<crate::AccountId, crate::AccountId> = 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<crate::AccountId> = params.cash_accounts.iter().copied().collect();
let financing_set: HashSet<crate::AccountId> =
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<CashFlowLine> = Vec::new();
let mut investing: Vec<CashFlowLine> = Vec::new();
let mut financing: Vec<CashFlowLine> = 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::<Decimal>();
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 &params.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,
})
}