//! The presentation model every statement renderer (XLSX, PDF) draws from. //! //! Core computes the statements; this module lays them out once — title, //! period, column headers, and rows (sections, account lines, subtotals, //! totals) — so the formats can't drift apart. Amounts stay exact //! `Decimal`s here; [`format_amount`] and [`amount_parts`] are the only //! places they become text. //! //! Presentation follows US small-business convention (QuickBooks-style): //! a currency sign on subtotals and totals, a rule above every sum, a //! double rule under statement totals, "Net income (loss)", and — for a //! multi-member LLC — "Members' equity" with each member named. use chrono::{Datelike, NaiveDate}; use ledger_core::RETAINED_EARNINGS_CODE; use ledger_core::reports::{ BalanceSheetStatement, CashFlowLine, CashFlowStatement, MembersEquityStatement, ProfitAndLossStatement, StatementLine, TrialBalance, }; use rust_decimal::Decimal; use serde::Serialize; /// Who the report is for and exactly what it was prepared from. #[derive(Clone, Debug, Serialize)] pub struct ReportMeta { /// Company name for the header, e.g. "Wanderland Labs LLC". pub company: String, /// Accounting basis for the footer: "Accrual" or "Cash". pub basis: String, /// The day the report was prepared. pub prepared_on: NaiveDate, /// Sequence number of the last journal line the report reflects. pub journal_seq: u64, } impl ReportMeta { /// "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114". pub fn footer(&self) -> String { format!( "{} basis · Prepared {}, {} · Journal seq {}", self.basis, self.prepared_on.format("%A"), long_date(self.prepared_on), self.journal_seq ) } } /// A member of a multi-member LLC, for naming capital accounts on reports. #[derive(Clone, Debug)] pub struct Member { /// The member's name. pub name: String, /// Code of their capital account. pub capital_code: String, } /// What a row is, which decides how renderers style it. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] #[serde(rename_all = "snake_case")] pub enum RowKind { /// A section heading ("Revenue"); no amounts. Section, /// An explanatory heading inside a section; no amounts. Subheading, /// One account or one flow, indented under its section. Line, /// A section total ("Total revenue"). Subtotal, /// A statement total ("Net income (loss)"); double rule beneath. Total, /// Vertical space between sections. Spacer, } /// One row of a statement. #[derive(Clone, Debug, Serialize)] pub struct Row { /// Row kind. pub kind: RowKind, /// Label text. pub label: String, /// One amount per column; `None` leaves the cell blank. pub amounts: Vec>, /// Print the currency sign with the amounts: subtotals and totals. pub currency: bool, /// Draw a rule above the row: the "sum of the above" line. Totals /// always have one; a subtotal only when amounts sit above it. pub rule_above: bool, } /// A laid-out financial statement. #[derive(Clone, Debug, Serialize)] pub struct Statement { /// "Profit and Loss", "Balance Sheet", ... pub title: String, /// "October 4 – October 11, 2026" or "As of October 11, 2026". pub period: String, /// Amount column headings ("Total"; "Debit", "Credit"; member names). pub columns: Vec, /// The rows, top to bottom. pub rows: Vec, } fn row(kind: RowKind, label: impl Into, amounts: Vec>) -> Row { Row { kind, label: label.into(), amounts, currency: false, rule_above: false, } } fn one(kind: RowKind, label: impl Into, amount: Decimal) -> Row { row(kind, label, vec![Some(amount)]) } fn section(label: &str) -> Row { row(RowKind::Section, label, vec![]) } fn subheading(label: &str) -> Row { row(RowKind::Subheading, label, vec![]) } fn spacer() -> Row { row(RowKind::Spacer, "", vec![]) } /// Finish a statement: currency signs on sums, rules above them. fn statement(title: &str, period: String, columns: &[&str], mut rows: Vec) -> Statement { let mut above: Option = None; for r in &mut rows { r.currency = matches!(r.kind, RowKind::Subtotal | RowKind::Total); r.rule_above = match r.kind { RowKind::Total => true, RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)), _ => false, }; if !matches!(r.kind, RowKind::Spacer | RowKind::Subheading) { above = Some(r.kind); } } Statement { title: title.to_string(), period, columns: columns.iter().map(|c| c.to_string()).collect(), rows, } } fn account_lines(lines: &[StatementLine]) -> impl Iterator + '_ { lines .iter() .map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)) } /// Lay out a profit and loss statement. pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement { let mut rows = vec![section("Revenue")]; rows.extend(account_lines(&pl.revenue)); rows.push(one(RowKind::Subtotal, "Total revenue", pl.total_revenue)); rows.push(spacer()); rows.push(section("Expenses")); rows.extend(account_lines(&pl.expenses)); rows.push(one(RowKind::Subtotal, "Total expenses", pl.total_expenses)); rows.push(spacer()); rows.push(one(RowKind::Total, "Net income (loss)", pl.net_income())); statement( "Profit and Loss", period_label(pl.start, pl.end), &["Total"], rows, ) } /// Lay out a balance sheet. `net_income_ytd` is the fiscal year to date's /// net income (loss): it shows as its own equity line, and earnings from /// before the fiscal year — the Retained Earnings account plus any not yet /// closed — show as "Retained earnings". With `members`, equity is /// "Members' equity". pub fn balance_sheet( bs: &BalanceSheetStatement, net_income_ytd: Decimal, members: &[Member], ) -> Statement { let equity_name = if members.is_empty() { "Equity" } else { "Members' equity" }; let mut rows = vec![section("Assets")]; rows.extend(account_lines(&bs.assets)); rows.push(one(RowKind::Subtotal, "Total assets", bs.total_assets)); rows.push(spacer()); rows.push(section("Liabilities")); rows.extend(account_lines(&bs.liabilities)); rows.push(one( RowKind::Subtotal, "Total liabilities", bs.total_liabilities, )); rows.push(spacer()); rows.push(section(equity_name)); let (retained, other): (Vec<_>, Vec<_>) = bs .equity .iter() .partition(|l| l.code == RETAINED_EARNINGS_CODE); rows.extend( other .iter() .map(|l| one(RowKind::Line, format!("{} {}", l.code, l.name), l.amount)), ); let retained: Decimal = retained.iter().map(|l| l.amount).sum::() + bs.current_earnings - net_income_ytd; if !retained.is_zero() { rows.push(one(RowKind::Line, "Retained earnings", retained)); } rows.push(one(RowKind::Line, "Net income (loss)", net_income_ytd)); rows.push(one( RowKind::Subtotal, format!("Total {}", equity_name.to_lowercase()), bs.total_equity, )); rows.push(spacer()); rows.push(one( RowKind::Total, format!("Total liabilities and {}", equity_name.to_lowercase()), bs.total_liabilities + bs.total_equity, )); statement( "Balance Sheet", format!("As of {}", long_date(bs.as_of)), &["Total"], rows, ) } /// Cash flow lines, naming members on their capital accounts. fn flow_lines<'a>( lines: &'a [CashFlowLine], members: &'a [Member], ) -> impl Iterator + 'a { lines.iter().map(move |l| { let member = members .iter() .find(|m| l.account.as_deref() == Some(m.capital_code.as_str())); let label = match member { Some(m) if l.amount >= Decimal::ZERO => format!("Member contributions — {}", m.name), Some(m) => format!("Member distributions — {}", m.name), None => l.label.clone(), }; one(RowKind::Line, label, l.amount) }) } /// Lay out a cash flow statement (indirect method). pub fn cash_flow(cf: &CashFlowStatement, members: &[Member]) -> Statement { let mut rows = vec![section("Operating activities")]; rows.push(one(RowKind::Line, "Net income (loss)", cf.net_income)); if !cf.depreciation.is_zero() || !cf.working_capital.is_empty() { rows.push(subheading( "Adjustments to reconcile net income (loss) to net cash from operating activities:", )); } if !cf.depreciation.is_zero() { rows.push(one(RowKind::Line, "Depreciation", cf.depreciation)); } rows.extend(flow_lines(&cf.working_capital, members)); rows.push(one( RowKind::Subtotal, "Net cash from operating activities", cf.cash_from_operations, )); rows.push(spacer()); rows.push(section("Investing activities")); rows.extend(flow_lines(&cf.investing, members)); rows.push(one( RowKind::Subtotal, "Net cash from investing activities", cf.cash_from_investing, )); rows.push(spacer()); rows.push(section("Financing activities")); rows.extend(flow_lines(&cf.financing, members)); rows.push(one( RowKind::Subtotal, "Net cash from financing activities", cf.cash_from_financing, )); rows.push(spacer()); rows.push(one( RowKind::Subtotal, "Net increase (decrease) in cash", cf.net_change_in_cash, )); rows.push(one( RowKind::Line, "Cash at beginning of period", cf.cash_beginning, )); rows.push(one(RowKind::Total, "Cash at end of period", cf.cash_ending)); statement( "Statement of Cash Flows", period_label(cf.start, cf.end), &["Total"], rows, ) } /// Lay out a trial balance as of a date: Debit and Credit columns, a zero /// side left blank. pub fn trial_balance(tb: &TrialBalance, as_of: NaiveDate) -> Statement { let nz = |d: Decimal| if d.is_zero() { None } else { Some(d) }; let mut rows: Vec = tb .lines .iter() .map(|l| { row( RowKind::Line, format!("{} {}", l.code, l.name), vec![nz(l.debit), nz(l.credit)], ) }) .collect(); rows.push(row( RowKind::Total, "Total", vec![Some(tb.total_debits), Some(tb.total_credits)], )); statement( "Trial Balance", format!("As of {}", long_date(as_of)), &["Debit", "Credit"], rows, ) } /// Lay out the statement of changes in members' equity: one column per /// member and a total. Distributions print as reductions. pub fn members_equity(me: &MembersEquityStatement) -> Statement { let cols: Vec<_> = me .members .iter() .chain(std::iter::once(&me.total)) .collect(); let pick = |f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| { cols.iter().map(|c| Some(f(c))).collect::>() }; let mut rows = vec![ row(RowKind::Line, "Beginning balance", pick(&|c| c.beginning)), row(RowKind::Line, "Contributions", pick(&|c| c.contributions)), row(RowKind::Line, "Net income (loss)", pick(&|c| c.net_income)), row(RowKind::Line, "Distributions", pick(&|c| -c.distributions)), ]; if !me.total.other.is_zero() { rows.push(row(RowKind::Line, "Other changes", pick(&|c| c.other))); } rows.push(row(RowKind::Total, "Ending balance", pick(&|c| c.ending))); let names: Vec<&str> = cols.iter().map(|c| c.name.as_str()).collect(); statement( "Statement of Changes in Members' Equity", period_label(me.start, me.end), &names, rows, ) } /// "October 11, 2026". pub fn long_date(d: NaiveDate) -> String { d.format("%B %-d, %Y").to_string() } /// "October 4 – October 11, 2026", or with both years when they differ. pub fn period_label(start: NaiveDate, end: NaiveDate) -> String { if start.year() == end.year() { format!("{} – {}", start.format("%B %-d"), long_date(end)) } else { format!("{} – {}", long_date(start), long_date(end)) } } /// Exact accounting format: thousands separators, two decimals, negatives /// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float. pub fn format_amount(d: Decimal) -> String { let negative = d.is_sign_negative() && !d.is_zero(); let fixed = format!("{:.2}", d.abs()); let (int, frac) = fixed.split_once('.').expect("two decimals"); let mut grouped = String::new(); for (i, c) in int.chars().enumerate() { if i > 0 && (int.len() - i) % 3 == 0 { grouped.push(','); } grouped.push(c); } if negative { format!("({grouped}.{frac})") } else { format!("{grouped}.{frac}") } } /// An amount split for column alignment: the digits, and whether it is a /// negative (printed in parentheses) or zero (printed as 0.00). Renderers /// right-align `digits` and reserve the closing parenthesis's width on every /// row, so decimal points line up whether or not a value is negative. #[derive(Clone, Debug, PartialEq, Eq, Serialize)] pub struct AmountParts { /// "1,234.56" — absolute value, grouped, two decimals. pub digits: String, /// Below zero. pub negative: bool, } /// Split an amount for aligned accounting display. Exact: no floats. pub fn amount_parts(d: Decimal) -> AmountParts { let rounded = d.round_dp(2); AmountParts { digits: format_amount(rounded.abs()), negative: rounded.is_sign_negative() && !rounded.is_zero(), } } #[cfg(test)] mod tests { use super::*; use std::str::FromStr; fn dec(s: &str) -> Decimal { Decimal::from_str(s).unwrap() } #[test] fn amounts_format_exactly() { assert_eq!(format_amount(dec("0")), "0.00"); assert_eq!(format_amount(dec("-0")), "0.00"); assert_eq!(format_amount(dec("5.8")), "5.80"); assert_eq!(format_amount(dec("207.68")), "207.68"); assert_eq!(format_amount(dec("-207.68")), "(207.68)"); assert_eq!(format_amount(dec("1000")), "1,000.00"); assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89"); assert_eq!(format_amount(dec("-100000")), "(100,000.00)"); } #[test] fn amounts_split_for_alignment() { let p = amount_parts(dec("-1234.5")); assert_eq!((p.digits.as_str(), p.negative), ("1,234.50", true)); let z = amount_parts(dec("-0.004")); assert_eq!((z.digits.as_str(), z.negative), ("0.00", false)); } #[test] fn currency_signs_and_rules_go_on_sums() { let rows = vec![ section("Assets"), one(RowKind::Line, "a", dec("1")), one(RowKind::Line, "b", dec("2")), one(RowKind::Subtotal, "t", dec("3")), spacer(), section("Investing"), one(RowKind::Subtotal, "empty", dec("0")), one(RowKind::Total, "T", dec("3")), ]; let st = statement("X", String::new(), &["Total"], rows); let signs: Vec = st.rows.iter().map(|r| r.currency).collect(); assert_eq!(signs, [false, false, false, true, false, false, true, true]); let rules: Vec = st.rows.iter().map(|r| r.rule_above).collect(); // No rule over an empty section's subtotal; always one over a total. assert_eq!( rules, [false, false, false, true, false, false, false, true] ); } #[test] fn balance_sheet_splits_retained_earnings_from_this_year() { let line = |code: &str, name: &str, a: &str| StatementLine { code: code.into(), name: name.into(), amount: dec(a), }; // Prior years closed 1,000 to Retained Earnings; 200 more unclosed // from last year; this year's net income is 300 (current earnings // 500 = 200 + 300). let bs = BalanceSheetStatement { as_of: NaiveDate::from_ymd_opt(2027, 3, 31).unwrap(), assets: vec![line("1000", "Cash", "2500")], liabilities: vec![], equity: vec![ line("3000", "Capital — Alice", "1000"), line("3999", "Retained Earnings", "1000"), ], current_earnings: dec("500"), total_assets: dec("2500"), total_liabilities: dec("0"), total_equity: dec("2500"), }; let members = [Member { name: "Alice".into(), capital_code: "3000".into(), }]; let st = balance_sheet(&bs, dec("300"), &members); let eq: Vec<(&str, Option)> = st .rows .iter() .skip_while(|r| r.label != "Members' equity") .map(|r| (r.label.as_str(), r.amounts.first().copied().flatten())) .collect(); assert_eq!( eq, [ ("Members' equity", None), ("3000 Capital — Alice", Some(dec("1000"))), ("Retained earnings", Some(dec("1200"))), ("Net income (loss)", Some(dec("300"))), ("Total members' equity", Some(dec("2500"))), ("", None), ("Total liabilities and members' equity", Some(dec("2500"))), ] ); } #[test] fn periods_read_like_english() { let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap(); assert_eq!( period_label(d(2026, 10, 4), d(2026, 10, 11)), "October 4 – October 11, 2026" ); assert_eq!( period_label(d(2026, 12, 1), d(2027, 1, 31)), "December 1, 2026 – January 31, 2027" ); let meta = ReportMeta { company: "X".into(), basis: "Accrual".into(), prepared_on: d(2026, 10, 11), journal_seq: 114, }; assert_eq!( meta.footer(), "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 114" ); } }