From bd6ea3e67e302c36923ee3ac3c2b14fcf53385f0 Mon Sep 17 00:00:00 2001 From: Stephen Waits <1+swaits@noreply.git.swaits.com> Date: Sun, 11 Oct 2026 00:46:21 +0000 Subject: [PATCH] Initial commit: wanderbooks accounting semantics engine --- crates/ledger-core/src/reports.rs | 200 ++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 crates/ledger-core/src/reports.rs diff --git a/crates/ledger-core/src/reports.rs b/crates/ledger-core/src/reports.rs new file mode 100644 index 0000000..ca98c69 --- /dev/null +++ b/crates/ledger-core/src/reports.rs @@ -0,0 +1,200 @@ +//! 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(), + }, + })) +}