//! Financial statements and the accountant's reports, over any range, //! split into months, quarters, or years, compared with a prior period. //! Every amount drills down to the account's transactions for exactly the //! dates behind it. Statements come from `ledger-core`; this module lays //! them out. use crate::app::Shell; use crate::books::Books; use crate::fmt; use crate::nav::{ReportKind, Route}; use crate::theme::{self, pal}; use crate::widgets; use chrono::{Datelike, Days, Months, NaiveDate}; use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2}; use egui_phosphor::regular as icon; use ledger_core::analysis::{Interval, intervals}; use ledger_core::reports::{ balance_sheet_as_of, general_ledger, members_equity, profit_and_loss_for_period, trial_balance_as_of, }; use ledger_core::{AccountId, AccountType, MemberCapital, tax_summary, vendor_1099_report}; use rust_decimal::Decimal; use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Range { ThisMonth, LastMonth, ThisQuarter, #[default] YearToDate, LastYear, Last12Months, AllTime, Custom(NaiveDate, NaiveDate), } impl Range { pub fn dates(self, b: &Books) -> (NaiveDate, NaiveDate) { let t = b.today; let ms = b.month_start(); match self { Range::ThisMonth => (ms, t), Range::LastMonth => (ms - Months::new(1), ms - Days::new(1)), Range::ThisQuarter => ( ledger_core::analysis::interval_start(t, Interval::Quarter), t, ), Range::YearToDate => b.fiscal_ytd(), Range::LastYear => { let (s, _) = b.fiscal_ytd(); (s - Months::new(12), s - Days::new(1)) } Range::Last12Months => (ms - Months::new(11), t), Range::AllTime => (b.kpis.first_entry.unwrap_or(t).min(t), t), Range::Custom(s, e) => (s, e), } } fn label(self) -> &'static str { match self { Range::ThisMonth => "This month", Range::LastMonth => "Last month", Range::ThisQuarter => "This quarter", Range::YearToDate => "Year to date", Range::LastYear => "Last year", Range::Last12Months => "Last 12 months", Range::AllTime => "All time", Range::Custom(..) => "Custom", } } const PRESETS: [Range; 7] = [ Range::ThisMonth, Range::LastMonth, Range::ThisQuarter, Range::YearToDate, Range::LastYear, Range::Last12Months, Range::AllTime, ]; } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Columns { #[default] Total, Month, Quarter, Year, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Compare { #[default] None, PriorPeriod, PriorYear, } #[derive(Default)] pub struct State { pub range: Range, pub columns: Columns, pub compare: Compare, /// A PDF being typeset in the background. pdf_job: Option>>, /// How the last PDF went, and when (to fade the message). pdf_done: Option<(Result, f64)>, } /// What a PDF of the report on screen needs besides the table. struct PdfInfo { title: String, period: String, meta: ledger_import::ReportMeta, } impl PdfInfo { fn new(b: &Books, title: &str, period: &str) -> Self { PdfInfo { title: title.to_string(), period: period.to_string(), meta: ledger_import::ReportMeta { company: b.company_name().to_string(), basis: b.settings.company.basis.clone(), prepared_on: b.today, journal_seq: b.seq(), }, } } } // ---------------------------------------------------------------------- // The table model every statement renders through. #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Section, Line, /// A transaction under the line above it (a cash flow kind): part of /// that line's amount, set further in and quieter. Detail, Subtotal, Grand, Note, } #[derive(Clone, Copy)] struct Drill { account: AccountId, start: NaiveDate, end: NaiveDate, } #[derive(Clone)] struct Cell { value: Option, drill: Option, } impl Cell { fn none() -> Self { Cell { value: None, drill: None, } } fn of(v: Decimal) -> Self { Cell { value: Some(v), drill: None, } } } struct Row { kind: Kind, label: String, detail: String, cells: Vec, } struct Table { columns: Vec, rows: Vec, /// Format cells as percentages from this column index on. pct_from: Option, } impl Table { fn new(columns: Vec) -> Self { Table { columns, rows: Vec::new(), pct_from: None, } } fn section(&mut self, label: &str) { self.rows.push(Row { kind: Kind::Section, label: label.into(), detail: String::new(), cells: Vec::new(), }); } fn row(&mut self, kind: Kind, label: impl Into, cells: Vec) { self.rows.push(Row { kind, label: label.into(), detail: String::new(), cells, }); } fn note(&mut self, text: impl Into) { self.rows.push(Row { kind: Kind::Note, label: text.into(), detail: String::new(), cells: Vec::new(), }); } /// Add "Change" and "%" columns comparing column 0 to column 1. fn add_change(&mut self) { self.columns.push("Change".into()); self.columns.push("%".into()); self.pct_from = Some(self.columns.len() - 1); for r in &mut self.rows { if r.cells.len() < 2 { continue; } let (a, b) = (r.cells[0].value, r.cells[1].value); let diff = match (a, b) { (Some(a), Some(b)) => Some(a - b), (Some(a), None) => Some(a), (None, Some(b)) => Some(-b), _ => None, }; let pct = match (diff, b) { (Some(d), Some(b)) if !b.is_zero() => { Some((d / b.abs() * Decimal::from(100)).round_dp(1)) } _ => None, }; r.cells.push(Cell { value: diff, drill: None, }); r.cells.push(Cell { value: pct, drill: None, }); } } /// Add a "% of income" column: each line as a share of total income. fn add_share_of_income(&mut self) { let income = self .rows .iter() .find(|r| r.kind == Kind::Subtotal && r.label == "Total income") .and_then(|r| r.cells.first()) .and_then(|c| c.value) .unwrap_or_default(); if income.is_zero() { return; } self.columns.push("% of income".into()); self.pct_from = Some(self.columns.len() - 1); for r in &mut self.rows { if let Some(v) = r.cells.first().and_then(|c| c.value) { r.cells .push(Cell::of((v / income * Decimal::from(100)).round_dp(1))); } } } /// The table as a statement for the PDF typesetter: sections spaced /// apart, sums ruled and signed, notes as italic subheadings. Detail /// lines (the accounts under a tax line) don't carry over. fn statement(&self, title: &str, period: &str) -> ledger_import::Statement { use ledger_import::{Row as PdfRow, RowKind}; let mut rows = Vec::new(); for r in &self.rows { let kind = match r.kind { Kind::Section => RowKind::Section, Kind::Line => RowKind::Line, Kind::Detail => RowKind::Detail, Kind::Subtotal => RowKind::Subtotal, Kind::Grand => RowKind::Total, Kind::Note => RowKind::Subheading, }; if matches!(kind, RowKind::Section | RowKind::Subheading) && !rows.is_empty() { rows.push(PdfRow { kind: RowKind::Spacer, label: String::new(), amounts: Vec::new(), currency: false, rule_above: false, }); } // Icon glyphs (private use) aren't in the PDF's font. let label: String = r .label .chars() .filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c)) .collect(); rows.push(PdfRow { kind, label: label.trim().to_string(), amounts: r.cells.iter().map(|c| c.value).collect(), currency: false, rule_above: false, }); } let columns: Vec<&str> = self.columns.iter().map(String::as_str).collect(); let mut st = ledger_import::report::statement(title, period.to_string(), &columns, rows); st.percent_from = self.pct_from; st } fn tsv(&self) -> String { let mut out = String::from("\t"); out.push_str(&self.columns.join("\t")); out.push('\n'); for r in &self.rows { out.push_str(&r.label); for c in &r.cells { out.push('\t'); if let Some(v) = c.value { out.push_str(&v.round_dp(2).to_string()); } } out.push('\n'); } out } } fn col_title(s: NaiveDate, e: NaiveDate, columns: Columns) -> String { match columns { Columns::Month => s.format("%b %Y").to_string(), Columns::Quarter => format!("Q{} {}", (s.month() - 1) / 3 + 1, s.year()), Columns::Year => s.year().to_string(), Columns::Total => { if s.year() == e.year() { format!("{} – {}", fmt::date_short(s), fmt::date_short(e)) } else { format!("{} – {}", fmt::month(s), fmt::month(e)) } } } } /// The most columns a split report shows. const MAX_COLUMNS: i64 = 240; /// How many columns splitting `s..=e` by `columns` makes, when that is /// more than [`MAX_COLUMNS`]; counted from the months, before building /// any (a range across chrono's calendar is millions of months). fn too_many_columns(s: NaiveDate, e: NaiveDate, columns: Columns) -> Option { let per = match columns { Columns::Total => return None, Columns::Month => 1, Columns::Quarter => 3, Columns::Year => 12, }; let months = (i64::from(e.year()) - i64::from(s.year())) * 12 + i64::from(e.month()) - i64::from(s.month()); let n = months / per + 1; (n > MAX_COLUMNS).then_some(n) } /// The column ranges for a range and split (with the prior period second /// when comparing a single column). fn column_ranges(st: &State, b: &Books) -> Vec<(NaiveDate, NaiveDate, String)> { let (s, e) = st.range.dates(b); let split = match st.columns { Columns::Total => None, Columns::Month => Some(Interval::Month), Columns::Quarter => Some(Interval::Quarter), Columns::Year => Some(Interval::Year), }; match split { None => { let mut v = vec![(s, e, col_title(s, e, Columns::Total))]; if let Some((ps, pe)) = prior(st, s, e) { v.push((ps, pe, col_title(ps, pe, Columns::Total))); // Same days a year apart read the same: say which year. if v[0].2 == v[1].2 { v[0].2 = format!("{}, {}", v[0].2, e.year()); v[1].2 = format!("{}, {}", v[1].2, pe.year()); } } v } Some(i) => intervals(s, e, i) .into_iter() .map(|(a, z)| (a, z, col_title(a, z, st.columns))) .collect(), } } /// The period compared against, if any: None too when it falls before /// chrono's first day (a range from the books' first entry, which an /// earlier version may have dated there: rules.md B-28). fn prior(st: &State, s: NaiveDate, e: NaiveDate) -> Option<(NaiveDate, NaiveDate)> { match st.compare { Compare::None => None, Compare::PriorYear => Some(( s.checked_sub_months(Months::new(12))?, e.checked_sub_months(Months::new(12))?, )), Compare::PriorPeriod => { let days = (e - s).num_days() + 1; let pe = s.checked_sub_days(Days::new(1))?; Some((pe.checked_sub_days(Days::new(days as u64 - 1))?, pe)) } } } type Cols = [(NaiveDate, NaiveDate, String)]; // ---------------------------------------------------------------------- // Builders. fn pnl(b: &Books, cols: &Cols, total: bool) -> Table { let mut cols = cols.to_vec(); if total && cols.len() > 1 { cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into())); } let stmts: Vec<_> = cols .iter() .map(|(s, e, _)| profit_and_loss_for_period(&b.ledger, *s, *e, None).ok()) .collect(); let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect()); let section = |t: &mut Table, title: &str, pick: &dyn Fn( &ledger_core::ProfitAndLossStatement, ) -> &Vec, total_label: &str| { let mut accounts: BTreeMap = BTreeMap::new(); for s in stmts.iter().flatten() { for l in pick(s) { accounts.insert(l.code.clone(), l.name.clone()); } } t.section(title); let mut totals = vec![Decimal::ZERO; cols.len()]; for (code, name) in &accounts { let id = b.ledger.chart().find_by_code(code).map(|a| a.id); let cells = cols .iter() .zip(&stmts) .enumerate() .map(|(i, ((s, e, _), st))| { let v = st .as_ref() .and_then(|st| pick(st).iter().find(|l| &l.code == code)) .map(|l| l.amount) .unwrap_or_default(); totals[i] += v; Cell { value: Some(v), drill: id.map(|account| Drill { account, start: *s, end: *e, }), } }) .collect(); t.row(Kind::Line, format!("{code} {name}"), cells); } t.row( Kind::Subtotal, total_label, totals.iter().map(|v| Cell::of(*v)).collect(), ); totals }; let rev = section(&mut t, "Income", &|s| &s.revenue, "Total income"); let exp = section(&mut t, "Expenses", &|s| &s.expenses, "Total expenses"); t.row( Kind::Grand, "Net income", rev.iter().zip(&exp).map(|(r, e)| Cell::of(r - e)).collect(), ); t } fn balance_sheet(b: &Books, dates: &[(NaiveDate, String)]) -> Table { let stmts: Vec<_> = dates .iter() .map(|(d, _)| balance_sheet_as_of(&b.ledger, *d).ok()) .collect(); let start = b.kpis.first_entry.unwrap_or(b.today); let mut t = Table::new(dates.iter().map(|d| d.1.clone()).collect()); let section = |t: &mut Table, title: &str, pick: &dyn Fn( &ledger_core::BalanceSheetStatement, ) -> &Vec| { let mut accounts: BTreeMap = BTreeMap::new(); for s in stmts.iter().flatten() { for l in pick(s) { accounts.insert(l.code.clone(), l.name.clone()); } } t.section(title); for (code, name) in &accounts { let id = b.ledger.chart().find_by_code(code).map(|a| a.id); let cells = dates .iter() .zip(&stmts) .map(|((d, _), st)| Cell { value: Some( st.as_ref() .and_then(|st| pick(st).iter().find(|l| &l.code == code)) .map(|l| l.amount) .unwrap_or_default(), ), drill: id.map(|account| Drill { account, start: start.min(*d), end: *d, }), }) .collect(); t.row(Kind::Line, format!("{code} {name}"), cells); } }; let total = |f: &dyn Fn(&ledger_core::BalanceSheetStatement) -> Decimal| -> Vec { stmts .iter() .map(|s| Cell { value: s.as_ref().map(f), drill: None, }) .collect() }; section(&mut t, "Assets", &|s| &s.assets); t.row(Kind::Subtotal, "Total assets", total(&|s| s.total_assets)); section(&mut t, "Liabilities", &|s| &s.liabilities); t.row( Kind::Subtotal, "Total liabilities", total(&|s| s.total_liabilities), ); section(&mut t, "Equity", &|s| &s.equity); t.row( Kind::Line, "Net income not yet closed", total(&|s| s.current_earnings), ); t.row(Kind::Subtotal, "Total equity", total(&|s| s.total_equity)); t.row( Kind::Grand, "Total liabilities and equity", total(&|s| s.total_liabilities + s.total_equity), ); if stmts .iter() .flatten() .all(|s| s.total_assets == s.total_liabilities + s.total_equity) { t.note(format!( "{} Assets equal liabilities plus equity in every column.", icon::CHECK )); } t } /// One row of the cash flow table, keyed across its columns: (key, label, /// amount, the account it drills to). type KeyedRow = (String, String, Decimal, Option); /// The statement of cash flows, one column per range: the server's own /// statement (ledger_package::cash_flow_for, the roles and members from /// the books' settings, refused alike), laid out as printed (D-35): /// operating by ASC 230-10-45-25 class; investing and financing by kind, /// each transaction listed under its kind; then net income reconciled to /// operating cash and the noncash investing and financing activities. fn cash_flow_table(b: &Books, cols: &Cols, total: bool) -> Table { use ledger_core::{CashFlowSection, ReconcilingKind}; let mut cols = cols.to_vec(); if total && cols.len() > 1 { cols.push((cols[0].0, cols[cols.len() - 1].1, "Total".into())); } let settings = ledger_package::Settings { company: &b.settings.company, cash_accounts: &b.settings.cash_accounts, financing_accounts: &b.settings.financing_accounts, investing_accounts: &b.settings.investing_accounts, }; let stmts: Vec> = cols .iter() .map(|(s, e, _)| { ledger_package::cash_flow_for( &b.ledger, &settings, Some(&s.to_string()), Some(&e.to_string()), &[], &[], &[], b.today, ) }) .collect(); let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect()); // A cell per column; blank where the statement was refused. let val = |f: &dyn Fn(&ledger_core::CashFlowStatement) -> Decimal| -> Vec { stmts .iter() .map(|s| Cell { value: s.as_ref().ok().map(f), drill: None, }) .collect() }; // Rows keyed across the columns, in first-seen order. let keyed = |t: &mut Table, kind: Kind, rows: &dyn Fn(&ledger_core::CashFlowStatement) -> Vec| { let mut order: Vec<(String, String, Option)> = Vec::new(); for s in stmts.iter().flatten() { for (key, label, _, account) in rows(s) { if !order.iter().any(|(k, _, _)| *k == key) { order.push((key, label, account)); } } } for (key, label, account) in order { let id = account .as_deref() .and_then(|c| b.ledger.chart().find_by_code(c)) .map(|a| a.id); let cells = cols .iter() .zip(&stmts) .map(|((s, e, _), st)| Cell { value: st.as_ref().ok().map(|st| { rows(st) .into_iter() .filter(|r| r.0 == key) .map(|r| r.2) .sum() }), drill: id.map(|account| Drill { account, start: *s, end: *e, }), }) .collect(); t.row(kind, label, cells); } }; // The account a line drills to: its one account, if it has one. let one = |accounts: &[String]| match accounts { [a] => Some(a.clone()), _ => None, }; t.section("Operating activities"); keyed(&mut t, Kind::Line, &|s| { s.operating_by_class() .into_iter() .map(|(c, a)| (c.label().to_string(), c.label().to_string(), a, None)) .collect() }); t.row( Kind::Subtotal, "Net cash from operating activities", val(&|s| s.cash_from_operations), ); for (sec, title, subtotal) in [ ( CashFlowSection::Investing, "Investing activities", "Net cash from investing activities", ), ( CashFlowSection::Financing, "Financing activities", "Net cash from financing activities", ), ] { t.section(title); // Each kind with its total (D-35), then its transactions listed // under it. let mut kinds: Vec = Vec::new(); for st in stmts.iter().flatten() { for k in st.kinds(sec) { if !kinds.contains(&k.kind) { kinds.push(k.kind); } } } for kind in kinds { keyed(&mut t, Kind::Line, &|s| { s.kinds(sec) .into_iter() .filter(|k| k.kind == kind) .map(|k| (k.kind.clone(), k.kind, k.amount, None)) .collect() }); keyed(&mut t, Kind::Detail, &|s| { s.lines(sec) .iter() .filter(|l| l.kind == kind) .map(|l| (l.label.clone(), l.label.clone(), l.amount, one(&l.accounts))) .collect() }); } t.row(Kind::Subtotal, subtotal, val(&|s| s.total(sec))); } t.row( Kind::Grand, "Net change in cash", val(&|s| s.net_change_in_cash), ); t.row(Kind::Line, "Cash at beginning", val(&|s| s.cash_beginning)); t.row(Kind::Line, "Cash at end", val(&|s| s.cash_ending)); t.section("Reconciliation of net income to operating cash"); t.row( Kind::Line, "Net income (loss)", val(&|s| s.operating_reconciliation.net_income), ); // A balance's change is keyed by its account (review item 17): one // row across the columns, whichever way it moved in each. keyed(&mut t, Kind::Line, &|s| { s.operating_reconciliation .lines .iter() .map(|l| match (&l.kind, &l.account) { (ReconcilingKind::OperatingBalanceChange, Some(code)) => { let liability = b .ledger .chart() .find_by_code(code) .is_some_and(|a| a.account_type != AccountType::Asset); let name = b .ledger .chart() .find_by_code(code) .map_or(code.clone(), |a| format!("{} {}", a.code, a.name)); let label = if liability { format!("Increase (decrease) in {name}") } else { format!("(Increase) decrease in {name}") }; ( format!("change {code}"), label, l.amount, Some(code.clone()), ) } _ => ( l.label.clone(), l.label.clone(), l.amount, l.account.clone(), ), }) .collect() }); t.row( Kind::Subtotal, "Net cash from operating activities", val(&|s| s.operating_reconciliation.net_cash_from_operating), ); t.section("Noncash investing and financing activities"); let any = stmts .iter() .flatten() .any(|s| !s.noncash_disclosures.is_empty()); if any { keyed(&mut t, Kind::Line, &|s| { s.noncash_disclosures .iter() .map(|n| (n.label.clone(), n.label.clone(), n.amount, None)) .collect() }); } else { t.note("None"); } let mut notes: Vec = Vec::new(); for st in &stmts { let note = match st { Err(why) => format!("This statement can't be shown: {why}."), Ok(s) => { for w in &s.warnings { let n = format!("Note: {}", w.message); if !notes.contains(&n) { notes.push(n); } } continue; } }; if !notes.contains(¬e) { notes.push(note); } } for n in notes { t.note(n); } if cols.len() > 1 { t.note( "An entry and its reversal in the same column drop out of it; in different columns each shows, and the Total nets them.", ); } let names: Vec = b .settings .cash_accounts .iter() .map(|code| { b.ledger .chart() .find_by_code(code) .map_or(code.clone(), |a| format!("{} {}", a.code, a.name)) }) .collect(); if !names.is_empty() { t.note(format!("Cash accounts: {}.", names.join(", "))); } t } fn trial_balance(b: &Books, as_of: NaiveDate) -> Table { let tb = trial_balance_as_of(&b.ledger, as_of); let start = b.kpis.first_entry.unwrap_or(as_of).min(as_of); let mut t = Table::new(vec!["Debit".into(), "Credit".into()]); for l in &tb.lines { let id = b.ledger.chart().find_by_code(&l.code).map(|a| a.id); let d = id.map(|account| Drill { account, start, end: as_of, }); let cell = |v: Decimal| Cell { value: (!v.is_zero()).then_some(v), drill: d, }; t.row( Kind::Line, format!("{} {}", l.code, l.name), vec![cell(l.debit), cell(l.credit)], ); } t.row( Kind::Grand, "Total", vec![Cell::of(tb.total_debits), Cell::of(tb.total_credits)], ); if tb.total_debits == tb.total_credits { t.note(format!("{} Debits equal credits.", icon::CHECK)); } t } fn owners_equity(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { let company = &b.settings.company; let members: Vec = company .members .iter() .filter_map(|m| { Some(MemberCapital { name: m.name.clone(), capital_account: b.ledger.chart().find_by_code(&m.capital_account)?.id, distribution_account: m .distribution_account .as_deref() .and_then(|c| b.ledger.chart().find_by_code(c)) .map(|a| a.id), share: m.share, }) }) .collect(); if !members.is_empty() && let Ok(me) = members_equity(&b.ledger, s, e, &members) { let mut cols: Vec = me.members.iter().map(|m| m.name.clone()).collect(); cols.push("Total".into()); let mut t = Table::new(cols); let all: Vec<&ledger_core::MemberEquityColumn> = me .members .iter() .chain(std::iter::once(&me.total)) .collect(); let row = |t: &mut Table, kind, label: &str, f: &dyn Fn(&ledger_core::MemberEquityColumn) -> Decimal| { t.row(kind, label, all.iter().map(|m| Cell::of(f(m))).collect()); }; row( &mut t, Kind::Line, &match s.pred_opt() { Some(day_before) => format!("Balance, {}", fmt::date(day_before)), // Nothing is dated before chrono's first day. None => "Opening balance".into(), }, &|m| m.beginning, ); row(&mut t, Kind::Line, "Contributions", &|m| m.contributions); row(&mut t, Kind::Line, "Share of net income", &|m| m.net_income); row(&mut t, Kind::Line, "Distributions", &|m| -m.distributions); row(&mut t, Kind::Line, "Other", &|m| m.other); row( &mut t, Kind::Grand, &format!("Balance, {}", fmt::date(e)), &|m| m.ending, ); let shares: Vec = members .iter() .map(|m| format!("{} {}%", m.name, (m.share * Decimal::from(100)).normalize())) .collect(); t.note(format!("Ownership: {}.", shares.join(", "))); return t; } // Single owner or a corporation: every equity account's movement. let mut t = Table::new(vec!["Beginning".into(), "Change".into(), "Ending".into()]); let eq: Vec = ledger_core::analysis::accounts_of_type(&b.ledger, &[AccountType::Equity]); let (mut tb, mut te) = (Decimal::ZERO, Decimal::ZERO); for a in eq { // Nothing is dated before chrono's first day: nothing to begin with. let begin = s.pred_opt().map_or(Decimal::ZERO, |d| b.balance_on(a, d)); let end = b.balance_on(a, e); tb += begin; te += end; let d = Some(Drill { account: a, start: s, end: e, }); t.row( Kind::Line, b.account_label(a), vec![ Cell::of(begin), Cell { value: Some(end - begin), drill: d, }, Cell::of(end), ], ); } let ni_begin = s .pred_opt() .and_then(|d| balance_sheet_as_of(&b.ledger, d).ok()) .map(|bs| bs.current_earnings) .unwrap_or_default(); let ni_end = balance_sheet_as_of(&b.ledger, e) .map(|bs| bs.current_earnings) .unwrap_or_default(); t.row( Kind::Line, "Net income not yet closed", vec![ Cell::of(ni_begin), Cell::of(ni_end - ni_begin), Cell::of(ni_end), ], ); t.row( Kind::Grand, "Total equity", vec![ Cell::of(tb + ni_begin), Cell::of(te + ni_end - tb - ni_begin), Cell::of(te + ni_end), ], ); t } fn tax(b: &Books, s: NaiveDate, e: NaiveDate) -> Table { let mut t = Table::new(vec!["Book".into(), "Tax".into()]); let Ok(ts) = tax_summary(&b.ledger, s, e, b.settings.company.tax_form) else { return t; }; let sec = |t: &mut Table, title: &str, lines: &[ledger_core::TaxSummaryLine]| { if lines.is_empty() { return; } t.section(title); for l in lines { t.rows.push(Row { kind: Kind::Line, label: l.label.clone(), detail: l.accounts.join(", "), cells: vec![Cell::of(l.book), Cell::of(l.tax)], }); } }; sec(&mut t, "Income", &ts.income); sec(&mut t, "Cost of goods sold", &ts.cost_of_goods); sec(&mut t, "Deductions", &ts.deductions); let net = if ts.uncategorized.is_empty() { "Net" } else { "Net of categorized accounts" }; t.row( Kind::Grand, net, vec![Cell::of(ts.book_net()), Cell::of(ts.tax_net())], ); if !ts.separately_stated.is_empty() { sec( &mut t, "Separately stated to owners (Schedule K)", &ts.separately_stated, ); } if !ts.uncategorized.is_empty() { t.section("Not categorized"); for u in &ts.uncategorized { let id = u .account .split_whitespace() .next() .and_then(|c| b.ledger.chart().find_by_code(c)) .map(|a| a.id); t.row( Kind::Line, &u.account, vec![ Cell { value: Some(u.book), drill: id.map(|account| Drill { account, start: s, end: e, }), }, Cell::none(), ], ); } } let form = ts.form.map(fmt::tax_form).unwrap_or("not set"); t.note(format!("Return: {form}. Deductible percentages applied (meals 50%, penalties 0%). A worksheet for the CPA, not a filing.")); t } /// A report the books can't compute (rules.md B-28: books an earlier /// version wrote may hold amounts past exact arithmetic): no figures, and a /// note saying why, never a guess. fn cant_compute(mut t: Table, why: &ledger_core::LedgerError) -> Table { t.note(format!( "This report can't be computed: {why}. Ask the bookkeeper to find the record behind it." )); t } fn aging( rows: Result, ledger_core::LedgerError>, as_of: NaiveDate, ) -> Table { let mut t = Table::new(vec![ "Current".into(), "1–30".into(), "31–60".into(), "61–90".into(), "Over 90".into(), "Total".into(), ]); let rows = match rows { Ok(rows) => rows, Err(e) => return cant_compute(t, &e), }; let too_large = || ledger_core::LedgerError::TooLargeToCompute("the aging's totals".into()); let mut tot = [Decimal::ZERO; 6]; for r in &rows { let total = match r.total() { Ok(total) => total, Err(e) => return cant_compute(t, &e), }; let v = [ r.current, r.days_30, r.days_60, r.days_90, r.days_90_plus, total, ]; for (i, x) in v.iter().enumerate() { match tot[i].checked_add(*x) { Some(sum) => tot[i] = sum, None => return cant_compute(t, &too_large()), } } t.row( Kind::Line, &r.name, v.iter() .map(|x| Cell { value: (!x.is_zero()).then_some(*x), drill: None, }) .collect(), ); } t.row( Kind::Grand, "Total", tot.iter().map(|x| Cell::of(*x)).collect(), ); t.note(format!("Days past due as of {}.", fmt::date(as_of))); t } /// The fixed asset register on `as_of`: ledger-core's /// `fixed_asset_register`, laid out, with its reconciliation to the /// general ledger and what that says (the Assets page's notices). Each /// asset's federal tax basis sits beside its book figures: contributor's /// basis, tax basis, and built-in gain (loss), the register row's own, /// with its rule (and citation) in the detail; the tax basis total is the /// register's. fn asset_register(b: &Books, as_of: NaiveDate) -> Table { let mut t = Table::new(vec![ "Cost".into(), "Accumulated".into(), "Book value".into(), "Contributor's basis".into(), "Tax basis".into(), "Built-in gain (loss)".into(), ]); let r = match b.asset_register(as_of) { Ok(r) => r, Err(e) => return cant_compute(t, &e), }; let none3 = || vec![Cell::none(), Cell::none(), Cell::none()]; if r.rows.is_empty() { t.note(format!( "No fixed asset was in service by {}.", fmt::date(as_of) )); return t; } t.section("Assets"); for x in &r.rows { let label = match &x.tag { Some(tag) => format!("{tag} · {}", x.description), None => format!("{} (no tag)", x.description), }; let mut detail = Vec::new(); if let Some(s) = &x.serial { detail.push(format!("serial {s}")); } detail.push(format!("in service {}", fmt::date(x.in_service))); detail.push(fmt::count(x.useful_life_months as usize, "month", "months")); if !x.salvage.is_zero() { detail.push(format!("salvage {}", fmt::dollars(x.salvage))); } let tax = super::assets::TaxFacts::of_row(x); detail.push(match tax.rule { Some(rule) => format!( "tax basis: {}", ledger_import::asset_register::rule_cited(rule) ), None => "tax basis not recorded".into(), }); let opt = |v: Option| Cell { value: v, drill: None, }; let cells = match x.disposed { Some(_) => { detail.push(format!( "{}; cost {}", super::assets::status_text(x.disposed), fmt::dollars(x.cost) )); [none3(), none3()].concat() } None => vec![ Cell::of(x.cost), Cell::of(x.accumulated), Cell::of(x.book_value), opt(tax.contributor_basis), opt(tax.tax_basis), opt(tax.built_in_gain), ], }; t.rows.push(Row { kind: Kind::Line, label, detail: detail.join(" · "), cells, }); } t.row( Kind::Grand, "Total held", vec![ Cell::of(r.total_cost), Cell::of(r.total_accumulated), Cell::of(r.total_book_value), Cell::none(), Cell::of(r.total_tax_basis), Cell::none(), ], ); t.section("Reconciliation to the books"); let start = b.kpis.first_entry.unwrap_or(as_of).min(as_of); let held: Vec<_> = r .rows .iter() .filter_map(|x| b.ledger.get_asset(x.asset_id)) .collect(); // One account behind a figure drills down to it. let only = |ids: Vec| -> Option { let mut ids = ids; ids.sort_unstable(); ids.dedup(); (ids.len() == 1).then(|| Drill { account: ids[0], start, end: as_of, }) }; let cost_drill = only(held.iter().map(|a| a.cost_account).collect()); let accum_drill = only(held.iter().map(|a| a.accum_depr_account).collect()); t.row( Kind::Line, "In the books", vec![ Cell { value: Some(r.ledger_cost), drill: cost_drill, }, Cell { value: Some(r.ledger_accumulated), drill: accum_drill, }, Cell::of(r.ledger_cost - r.ledger_accumulated), Cell::none(), Cell::none(), Cell::none(), ], ); t.row( Kind::Subtotal, "Register less books", vec![ Cell::of(-r.unregistered_cost()), Cell::of(r.unposted_depreciation()), Cell::of(-r.unregistered_cost() - r.unposted_depreciation()), Cell::none(), Cell::none(), Cell::none(), ], ); for n in super::assets::notices(b, &r) { t.note(n.text); } t.note( "Cost is what the company paid or, for a member's contribution, its fair market value then; the tax basis follows its rule and can differ. Built-in gain (loss) is that value less the contributor's basis (IRC §704(c)). Tax depreciation starts from the tax basis — the CPA's.", ); t } // ---------------------------------------------------------------------- // The page. /// The reports, grouped as the side list shows them. const GROUPS: [(&str, &[ReportKind]); 3] = [ ( "Statements", &[ ReportKind::ProfitAndLoss, ReportKind::BalanceSheet, ReportKind::CashFlow, ReportKind::MembersEquity, ], ), ( "Ledgers", &[ ReportKind::TrialBalance, ReportKind::GeneralLedger, ReportKind::FixedAssetRegister, ], ), ( "Tax and aging", &[ ReportKind::TaxSummary, ReportKind::Vendor1099, ReportKind::ArAging, ReportKind::ApAging, ], ), ]; /// Narrower than this, the side list of reports becomes a picker above /// the report. const SIDE_LIST_MIN: f32 = 760.0; pub fn show(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let rect = ui.available_rect_before_wrap(); if rect.width() < SIDE_LIST_MIN { super::page(ui, "report", |ui| report(ui, b, kind, shell, true)); return; } let nav_w = 210.0; let nav_rect = egui::Rect::from_min_size(rect.min, vec2(nav_w, rect.height())); ui.painter().line_segment( [nav_rect.right_top(), nav_rect.right_bottom()], Stroke::new(1.0, p.border), ); let mut nav = ui.new_child(egui::UiBuilder::new().max_rect(nav_rect.shrink2(vec2(12.0, 24.0)))); nav.label( RichText::new("REPORTS") .font(theme::semibold(11.5)) .color(p.faint), ); nav.add_space(4.0); for (g, kinds) in GROUPS { nav.add_space(10.0); nav.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted)); nav.add_space(2.0); for k in kinds { let on = *k == kind; let (r, resp) = nav.allocate_exact_size(vec2(nav_w - 24.0, 30.0), Sense::click()); if on { nav.painter() .rect_filled(r, CornerRadius::same(7), p.surface); nav.painter().rect_stroke( r, CornerRadius::same(7), Stroke::new(1.0, p.border), egui::StrokeKind::Inside, ); } else if resp.hovered() { nav.painter().rect_filled(r, CornerRadius::same(7), p.hover); } nav.painter().text( r.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, k.title(), if on { theme::semibold(13.0) } else { theme::regular(13.0) }, if on { p.text } else { p.muted }, ); if resp.clicked() { shell.go(Route::Reports(*k)); } } } let body = egui::Rect::from_min_max(egui::pos2(nav_rect.right() + 1.0, rect.top()), rect.max); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body)); child.set_clip_rect(body); super::page(&mut child, "report", |ui| report(ui, b, kind, shell, false)); ui.allocate_rect(rect, Sense::hover()); } fn report(ui: &mut Ui, b: &Books, kind: ReportKind, shell: &mut Shell<'_>, picker: bool) { let p = pal(ui.ctx()); let point_in_time = matches!( kind, ReportKind::TrialBalance | ReportKind::ArAging | ReportKind::ApAging | ReportKind::FixedAssetRegister ) || (kind == ReportKind::BalanceSheet && shell.state.reports.columns == Columns::Total); let splittable = matches!( kind, ReportKind::ProfitAndLoss | ReportKind::BalanceSheet | ReportKind::CashFlow ); let comparable = matches!( kind, ReportKind::ProfitAndLoss | ReportKind::BalanceSheet | ReportKind::CashFlow | ReportKind::TaxSummary ); let st = &mut shell.state.reports; // Controls. Each starts a new row when it won't fit on this one, by // its known width: a control that overflows would widen the page. let mut pick = None; let row_w = ui.available_width(); let mut used = 0.0; let mut room = |ui: &mut Ui, w: f32| { if used > 0.0 && used + 8.0 + w > row_w { ui.end_row(); used = 0.0; } else if used > 0.0 { ui.add_space(8.0); used += 8.0; } used += w; }; ui.horizontal_wrapped(|ui| { if picker { room(ui, 214.0); egui::ComboBox::from_id_salt("report_kind") .selected_text(RichText::new(format!( "{} {}", icon::FILE_TEXT, kind.title() ))) .width(190.0) .show_ui(ui, |ui| { for (g, kinds) in GROUPS { ui.label(RichText::new(g).font(theme::regular(12.0)).color(p.muted)); for k in kinds { if ui.selectable_label(*k == kind, k.title()).clicked() { pick = Some(*k); } } } }); } room(ui, 194.0); egui::ComboBox::from_id_salt("range") .selected_text(RichText::new(format!( "{} {}", icon::CALENDAR_BLANK, st.range.label() ))) .width(170.0) .show_ui(ui, |ui| { for r in Range::PRESETS { ui.selectable_value(&mut st.range, r, r.label()); } let (s, e) = st.range.dates(b); if ui .selectable_label(matches!(st.range, Range::Custom(..)), "Custom…") .clicked() { st.range = Range::Custom(s, e); } }); if let Range::Custom(s, e) = st.range { room(ui, 250.0); let s = widgets::date_picker(ui, "from", s); ui.label(RichText::new("to").color(p.muted)); let e = widgets::date_picker(ui, "to", e); st.range = Range::Custom(s.min(e), e.max(s)); } if splittable { room(ui, 270.0); widgets::segmented( ui, &mut st.columns, &[ (Columns::Total, "Total"), (Columns::Month, "Months"), (Columns::Quarter, "Quarters"), (Columns::Year, "Years"), ], ); } if comparable && (st.columns == Columns::Total || !splittable) { room(ui, 160.0); egui::ComboBox::from_id_salt("compare") .selected_text(match st.compare { Compare::None => "No comparison", Compare::PriorPeriod => "vs prior period", Compare::PriorYear => "vs prior year", }) .show_ui(ui, |ui| { ui.selectable_value(&mut st.compare, Compare::None, "No comparison"); ui.selectable_value(&mut st.compare, Compare::PriorPeriod, "vs prior period"); ui.selectable_value(&mut st.compare, Compare::PriorYear, "vs prior year"); }); } }); ui.add_space(18.0); let (s, e) = st.range.dates(b); // A split over a range from the books' first entry, which an earlier // version may have dated thousands of years back (rules.md B-28), // would be more columns than anyone can read or the frame can build: // shown as one total, with a note saying so. let split_note = splittable .then(|| too_many_columns(s, e, st.columns)) .flatten() .map(|n| { format!( "Split {} ways ({n} columns); more than {MAX_COLUMNS} can't be shown, so this is the total. Choose a shorter range to split it.", fmt::words(&format!("{:?}", st.columns)).to_lowercase() ) }); let state = State { range: st.range, columns: if splittable && split_note.is_none() { st.columns } else { Columns::Total }, compare: if comparable && (st.columns == Columns::Total || !splittable) { st.compare } else { Compare::None }, ..Default::default() }; let cols = column_ranges(&state, b); let compare = state.compare != Compare::None; let subtitle = if point_in_time { format!("As of {}", fmt::date(e)) } else { format!("{} – {}", fmt::date(s), fmt::date(e)) }; ui.label(RichText::new(kind.title()).font(theme::bold(26.0))); ui.label( RichText::new(format!( "{} · {} · {} basis", b.company_name(), subtitle, b.settings.company.basis )) .font(theme::regular(14.0)) .color(p.muted), ); ui.add_space(14.0); let table = match kind { ReportKind::ProfitAndLoss => { let mut t = pnl(b, &cols, state.columns != Columns::Total); if compare { t.add_change(); } else if cols.len() == 1 { t.add_share_of_income(); } Some(t) } ReportKind::BalanceSheet => { let dates: Vec<(NaiveDate, String)> = if state.columns == Columns::Total { cols.iter() .map(|(_, e, _)| (*e, format!("As of {}", fmt::date_short(*e)))) .collect() } else { cols.iter() .map(|(_, e, title)| (*e, title.clone())) .collect() }; let mut t = balance_sheet(b, &dates); if compare { t.add_change(); } Some(t) } ReportKind::CashFlow => { let mut t = cash_flow_table(b, &cols, state.columns != Columns::Total); if compare { t.add_change(); } Some(t) } ReportKind::TrialBalance => Some(trial_balance(b, e)), ReportKind::FixedAssetRegister => Some(asset_register(b, e)), ReportKind::MembersEquity => Some(owners_equity(b, s, e)), ReportKind::TaxSummary => { let mut t = tax(b, s, e); if compare && let Some((ps, pe)) = prior(&state, s, e) { let prev = tax(b, ps, pe); // Side by side: the tax column now and then. let mut m = Table::new(vec![ cols[0].2.clone(), cols.get(1).map(|c| c.2.clone()).unwrap_or_default(), ]); for r in &t.rows { let other = prev .rows .iter() .find(|x| x.label == r.label && x.kind == r.kind) .and_then(|x| x.cells.get(1)) .cloned() .unwrap_or(Cell::none()); let cells = if r.cells.is_empty() { Vec::new() } else { vec![r.cells.get(1).cloned().unwrap_or(Cell::none()), other] }; m.rows.push(Row { kind: r.kind, label: r.label.clone(), detail: r.detail.clone(), cells, }); } m.add_change(); t = m; } Some(t) } ReportKind::ArAging => Some(aging(b.ledger.ar_aging(e), e)), ReportKind::ApAging => Some(aging(b.ledger.ap_aging(e), e)), ReportKind::Vendor1099 => { vendor_1099(ui, b, e.year(), shell); None } ReportKind::GeneralLedger => { gl(ui, b, s, e, shell); None } }; if let Some(mut t) = table { if let Some(n) = split_note { t.note(n); } render(ui, &t, &PdfInfo::new(b, kind.title(), &subtitle), shell); } if let Some(k) = pick { shell.go(Route::Reports(k)); } } /// The "Save PDF" button (busy while one is being made). Also collects a /// finished job. True when clicked. fn pdf_button(ui: &mut Ui, st: &mut State) -> bool { let p = pal(ui.ctx()); if let Some(rx) = &st.pdf_job { if let Ok(done) = rx.try_recv() { st.pdf_job = None; st.pdf_done = Some((done, ui.input(|i| i.time))); } else { ui.ctx() .request_repaint_after(std::time::Duration::from_millis(100)); } } let busy = st.pdf_job.is_some(); let label = if busy { format!("{} Making PDF…", icon::HOURGLASS) } else { format!("{} Save PDF", icon::FILE_PDF) }; ui.add_enabled( !busy, egui::Button::new( RichText::new(label) .font(theme::regular(12.0)) .color(p.muted), ) .frame(false), ) .on_hover_text(format!( "Typesets this report as it's set up here and saves it to {}, then opens it", crate::export::downloads_dir().display() )) .clicked() } /// Where the last PDF went (or why it didn't), for a few seconds. fn pdf_status(ui: &mut Ui, st: &State) { let p = pal(ui.ctx()); let Some((done, at)) = &st.pdf_done else { return; }; if ui.input(|i| i.time) - at >= 8.0 { return; } let (text, color) = match done { Ok(path) => ( format!("{} Saved {}", icon::CHECK, path.display()), p.positive, ), Err(e) => ( format!("{} PDF failed: {e}", icon::WARNING_CIRCLE), p.negative, ), }; ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { ui.add( egui::Label::new(RichText::new(text).font(theme::regular(11.5)).color(color)) .truncate(), ); }); ui.ctx() .request_repaint_after(std::time::Duration::from_millis(500)); } /// Typeset `t` in the background, save it to Downloads, and open it. fn start_pdf(ctx: &egui::Context, st: &mut State, t: &Table, pdf: &PdfInfo) { let statement = t.statement(&pdf.title, &pdf.period); let meta = pdf.meta.clone(); let name = format!("{} — {} — {}", meta.company, pdf.title, pdf.period); let (tx, rx) = std::sync::mpsc::channel(); let ctx = ctx.clone(); std::thread::spawn(move || { let done = ledger_import::statement_pdf(&statement, &meta) .map_err(|e| e.to_string()) .and_then(|bytes| crate::export::save_and_open(&name, "pdf", &bytes)); let _ = tx.send(done); ctx.request_repaint(); }); st.pdf_job = Some(rx); st.pdf_done = None; } /// The general ledger as a table for the PDF: each account a section, a /// line per posting (date, entry, description, the other side), its /// beginning balance, and totals with the ending balance. fn gl_table(g: &ledger_core::reports::GeneralLedger) -> Table { let mut t = Table::new(vec!["Debit".into(), "Credit".into(), "Balance".into()]); let amt = |v: Decimal| { if v.is_zero() { Cell::none() } else { Cell::of(v) } }; for a in g .accounts .iter() .filter(|a| !a.postings.is_empty() || !a.beginning_balance.is_zero()) { t.section(&format!("{} {}", a.code, a.name)); t.row( Kind::Line, "Beginning balance", vec![Cell::none(), Cell::none(), Cell::of(a.beginning_balance)], ); for post in &a.postings { t.row( Kind::Line, format!( "{} · #{} · {} · {}", fmt::date_short(post.date), post.entry_id, post.description, post.split ), vec![amt(post.debit), amt(post.credit), Cell::of(post.balance)], ); } t.row( Kind::Subtotal, "Totals and ending balance", vec![ Cell::of(a.total_debits), Cell::of(a.total_credits), Cell::of(a.ending_balance), ], ); } t } fn render(ui: &mut Ui, t: &Table, pdf: &PdfInfo, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let tsv = t.tsv(); let mut go: Option = None; let st = &mut shell.state.reports; let mut save_pdf = false; widgets::card(ui, |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { save_pdf = pdf_button(ui, st); ui.add_space(8.0); if ui .add( egui::Button::new( RichText::new(format!("{} Copy for a spreadsheet", icon::COPY)) .font(theme::regular(12.0)) .color(p.muted), ) .frame(false), ) .on_hover_text("Copies the table as tab-separated values") .clicked() { ui.ctx().copy_text(tsv.clone()); } }); pdf_status(ui, st); let n = t.columns.len().max(1) as f32; let base: f32 = if t.columns.len() > 6 { 112.0 } else { 136.0 }; // Narrow, the amount columns give up their slack before the table // scrolls sideways. let col_w = ((ui.available_width() - 180.0) / n).clamp(100.0, base); let cols_w = col_w * n; egui::ScrollArea::horizontal() .id_salt("report_table") .show(ui, |ui| { // Labels get what's left, but never so much that the eye loses // the line between a label and its amount. let label_w = (ui.available_width() - cols_w).clamp(180.0, 480.0); let width = label_w + cols_w; ui.set_min_width(width); let (hr, _) = ui.allocate_exact_size(vec2(width, 30.0), Sense::hover()); for (i, c) in t.columns.iter().enumerate() { let x = hr.left() + label_w + col_w * (i as f32 + 1.0) - 4.0; ui.painter().text( egui::pos2(x, hr.center().y), egui::Align2::RIGHT_CENTER, c, theme::semibold(12.5), p.muted, ); } ui.painter().line_segment( [hr.left_bottom(), hr.right_bottom()], Stroke::new(1.0, p.border), ); for (ri, r) in t.rows.iter().enumerate() { let h = match r.kind { Kind::Section => 36.0, Kind::Grand => 38.0, Kind::Note => 32.0, _ => { if r.detail.is_empty() { 27.0 } else { 40.0 } } }; let (rr, resp) = ui.allocate_exact_size(vec2(width, h), Sense::hover()); if matches!(r.kind, Kind::Line | Kind::Detail) && resp.hovered() { ui.painter().rect_filled(rr, CornerRadius::same(4), p.hover); } let (font, color, indent) = match r.kind { Kind::Section => (theme::semibold(13.5), p.text, 0.0), Kind::Line => (theme::regular(13.5), p.text, 18.0), Kind::Detail => (theme::regular(12.5), p.muted, 36.0), Kind::Subtotal => (theme::semibold(13.5), p.text, 0.0), Kind::Grand => (theme::bold(14.5), p.text, 0.0), Kind::Note => (theme::regular(12.0), p.muted, 0.0), }; let y = match r.kind { Kind::Section => rr.bottom() - 11.0, _ if !r.detail.is_empty() => rr.center().y - 7.0, _ => rr.center().y, }; // Labels stop short of the first amount column (notes, // which have none, run the full width). let label_room = if r.cells.is_empty() { width - indent - 8.0 } else { label_w - indent - 12.0 }; widgets::clipped_text( ui.painter(), egui::pos2(rr.left() + indent + 4.0, y), &r.label, font.clone(), color, label_room, ); if !r.detail.is_empty() { widgets::clipped_text( ui.painter(), egui::pos2(rr.left() + indent + 4.0, rr.center().y + 9.0), &r.detail, theme::regular(11.5), p.faint, label_room, ); } for (i, c) in r.cells.iter().enumerate() { let Some(v) = c.value else { continue; }; let is_pct = t.pct_from.is_some_and(|f| i >= f); let text = if is_pct { format!("{}%", v.normalize()) } else { fmt::accounting(v) }; let x1 = rr.left() + label_w + col_w * (i as f32 + 1.0) - 4.0; let cell = egui::Rect::from_min_max( egui::pos2(x1 - col_w + 8.0, rr.top()), egui::pos2(x1, rr.bottom()), ); let copy = fmt::plain(&text); let cresp = ui.interact( cell, ui.id().with(("cell", ri, i)), if c.drill.is_some() || copy.is_some() { Sense::click() } else { Sense::hover() }, ); // An amount copies as its plain decimal: a click // where the cell doesn't drill down, the right-click // menu where it does. if let Some(v) = © { if c.drill.is_some() { cresp.context_menu(|ui| widgets::copy_item(ui, "Copy amount", v)); } else { if cresp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } if cresp.clicked() { widgets::copy_now(ui, cresp.id, v); } let note = if widgets::recently_copied(ui, cresp.id) { "Copied" } else { "Click to copy" }; cresp.clone().on_hover_text(note); } } let hovered = c.drill.is_some() && cresp.hovered(); let vcolor = if hovered { p.accent } else { color }; let vfont = if matches!(r.kind, Kind::Line) { theme::regular(13.5) } else { font.clone() }; let g = ui.painter().layout_no_wrap(text, vfont, vcolor); let pos = egui::pos2(x1 - g.size().x, y - g.size().y / 2.0); if hovered { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); ui.painter().line_segment( [ egui::pos2(pos.x, pos.y + g.size().y), egui::pos2(x1, pos.y + g.size().y), ], Stroke::new(1.0, p.accent), ); } ui.painter().galley(pos, g, vcolor); let rule_x0 = x1 - col_w + 28.0; if matches!(r.kind, Kind::Subtotal) { ui.painter().line_segment( [ egui::pos2(rule_x0, rr.top() + 1.0), egui::pos2(x1, rr.top() + 1.0), ], Stroke::new(1.0, p.faint), ); } if matches!(r.kind, Kind::Grand) { let painter = ui.painter(); painter.line_segment( [ egui::pos2(rule_x0, rr.top() + 2.0), egui::pos2(x1, rr.top() + 2.0), ], Stroke::new(1.0, p.text), ); let yb = rr.bottom() - 5.0; painter.line_segment( [egui::pos2(rule_x0, yb), egui::pos2(x1, yb)], Stroke::new(1.0, p.text), ); painter.line_segment( [egui::pos2(rule_x0, yb + 2.5), egui::pos2(x1, yb + 2.5)], Stroke::new(1.0, p.text), ); } if let Some(d) = c.drill { let cresp = cresp.on_hover_text(format!( "Show the transactions behind this ({} – {})", fmt::date(d.start), fmt::date(d.end) )); if cresp.clicked() { go = Some(d); } } } } }); }); if save_pdf { start_pdf(ui.ctx(), st, t, pdf); } if let Some(d) = go { shell.state.accounts.range = Some((d.start, d.end)); shell.go(Route::Account(d.account)); } } fn gl(ui: &mut Ui, b: &Books, s: NaiveDate, e: NaiveDate, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let Ok(g) = general_ledger(&b.ledger, s, e) else { return; }; let st = &mut shell.state.reports; let mut save_pdf = false; ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { save_pdf = pdf_button(ui, st); }); pdf_status(ui, st); if save_pdf { let pdf = PdfInfo::new( b, ReportKind::GeneralLedger.title(), &format!("{} – {}", fmt::date(s), fmt::date(e)), ); start_pdf(ui.ctx(), st, &gl_table(&g), &pdf); } ui.add_space(6.0); let mut go: Option = None; for a in g .accounts .iter() .filter(|a| !a.postings.is_empty() || !a.beginning_balance.is_zero()) { widgets::card(ui, |ui| { ui.horizontal(|ui| { let r = widgets::link( ui, RichText::new(format!("{} {}", a.code, a.name)).font(theme::semibold(14.0)), ); if r.clicked() && let Some(acct) = b.ledger.chart().find_by_code(&a.code) { go = Some(Route::Account(acct.id)); } ui.label( RichText::new(format!("{:?}", a.account_type)) .font(theme::regular(12.0)) .color(p.muted), ); }); ui.add_space(6.0); // Narrow, the description gives way down to 120 points, then // the table scrolls sideways. let fixed = [86.0, 52.0, 0.0, 110.0, 110.0, 120.0]; let w = ui.available_width().max(fixed.iter().sum::() + 120.0); let flex = w - fixed.iter().sum::(); widgets::min_width(ui, &format!("gl_{}", a.code), w, |ui| { let row = |ui: &mut Ui, vals: [String; 6], strong: bool, color: egui::Color32| -> egui::Response { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); resp.context_menu(|ui| { for v in &vals { if let Some(id) = v.strip_prefix('#') { widgets::copy_item(ui, "Copy entry number", &format!("#{id}")); } else if let Some(a) = fmt::plain(v) { widgets::copy_item(ui, &format!("Copy {v}"), &a); } } }); if resp.hovered() && !strong { ui.painter().rect_filled(r, CornerRadius::same(4), p.hover); } let painter = ui.painter_at(r); let mut x = r.left(); for (i, v) in vals.iter().enumerate() { let cw = if i == 2 { flex } else { fixed[i] }; let font = if strong { theme::semibold(12.5) } else { theme::regular(12.5) }; if i >= 3 { painter.text( egui::pos2(x + cw - 4.0, r.center().y), egui::Align2::RIGHT_CENTER, v, font, color, ); } else { widgets::clipped_text( &painter, egui::pos2(x + 4.0, r.center().y), v, font, color, cw - 8.0, ); } x += cw; } resp }; row( ui, [ "Date".into(), "#".into(), "Description".into(), "Debit".into(), "Credit".into(), "Balance".into(), ], true, p.muted, ); row( ui, [ String::new(), String::new(), "Beginning balance".into(), String::new(), String::new(), fmt::accounting(a.beginning_balance), ], false, p.muted, ); for post in &a.postings { let r = row( ui, [ fmt::date_short(post.date), post.entry_id.to_string(), format!("{} · {}", post.description, post.split), if post.debit.is_zero() { String::new() } else { fmt::money(post.debit) }, if post.credit.is_zero() { String::new() } else { fmt::money(post.credit) }, fmt::accounting(post.balance), ], false, p.text, ); if r.clicked() { go = Some(Route::Entry(post.entry_id)); } } row( ui, [ String::new(), String::new(), "Totals and ending balance".into(), fmt::money(a.total_debits), fmt::money(a.total_credits), fmt::accounting(a.ending_balance), ], true, p.text, ); }); }); ui.add_space(12.0); } if let Some(r) = go { if let Route::Account(_) = r { shell.state.accounts.range = Some((s, e)); } shell.go(r); } } fn vendor_1099(ui: &mut Ui, b: &Books, year: i32, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); let r = match vendor_1099_report(&b.ledger, year, None) { Ok(r) => r, Err(e) => { // Never guess a threshold (rules.md B-19): say why there's no // worksheet, in the engine's words. ui.label(RichText::new(e.to_string()).color(p.muted)); return; } }; ui.label( RichText::new(format!( "Calendar {year}. Threshold {} ($600 through 2025; $2,000 from 2026, indexed after). Counts cash paid on entries tagged with the vendor; card payments are reported by the card processor (1099-K) and drop out.", fmt::dollars(r.threshold) )) .color(p.muted), ); ui.add_space(10.0); let mut t = Table::new(vec!["Paid".into()]); for v in &r.vendors { let form = v.form.map(|f| f.label()).unwrap_or("No 1099"); let w9 = if v.tin_on_file { "W-9 on file" } else { "no W-9" }; let status = if v.reportable { if v.tin_on_file { "File a 1099" } else { "File a 1099 · get a W-9 first" } } else { "Not reportable" }; t.rows.push(Row { kind: Kind::Line, label: v.name.clone(), detail: format!("{form} · {w9} · {status}"), cells: vec![Cell::of(v.paid)], }); } if r.vendors.is_empty() { t.note("No vendors marked for a 1099 and none paid this year."); } let pdf = PdfInfo::new( b, ReportKind::Vendor1099.title(), &format!("Calendar year {year}"), ); render(ui, &t, &pdf, shell); } #[cfg(test)] mod tests { /// A report shows the range and split chosen: last year's P&L by /// month has a column for each of last year's months, not this year's. #[test] fn a_report_shows_the_range_and_split_chosen() { use crate::test_support::{Build, d, find, has, render}; let mut b = Build::new("report-range"); let (cash, capital, rent) = (b.cash, b.capital, b.rent); b.post(d(2025, 1, 2), cash, capital, 1_000_000); b.post(d(2025, 3, 2), rent, cash, 10_000); let (_dir, books) = b.books(); let (texts, _) = render(books, Route::Reports(ReportKind::ProfitAndLoss), |s| { s.reports.range = Range::Custom(d(2025, 1, 1), d(2025, 12, 31)); s.reports.columns = Columns::Month; }); find(&texts, "Mar 2025"); assert!(!has(&texts, "Mar 2026")); } /// prior: the period before, as long as the range in days, ending the /// day before it starts; or the same days a year before; none before /// chrono's first day. #[test] fn the_prior_period_is_as_long_and_just_before() { let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap(); let mut st = State::default(); assert_eq!(prior(&st, d(2026, 4, 1), d(2026, 6, 30)), None); st.compare = Compare::PriorPeriod; // 91 days: the 91 days through March 31. assert_eq!( prior(&st, d(2026, 4, 1), d(2026, 6, 30)), Some((d(2025, 12, 31), d(2026, 3, 31))) ); assert_eq!( prior(&st, d(2026, 3, 1), d(2026, 3, 1)), Some((d(2026, 2, 28), d(2026, 2, 28))) ); assert_eq!(prior(&st, NaiveDate::MIN, d(2026, 1, 1)), None); st.compare = Compare::PriorYear; assert_eq!( prior(&st, d(2026, 4, 1), d(2026, 6, 30)), Some((d(2025, 4, 1), d(2025, 6, 30))) ); assert_eq!(prior(&st, NaiveDate::MIN, d(2026, 1, 1)), None); } /// too_many_columns: a split's column count, counted from the months /// (each started interval a column), only when past MAX_COLUMNS. #[test] fn too_many_columns_counts_the_columns_only_past_the_limit() { let d = |y, m, day| NaiveDate::from_ymd_opt(y, m, day).unwrap(); assert_eq!( too_many_columns(d(2006, 1, 1), d(2026, 12, 31), Columns::Total), None ); // 240 months, January 2007 through December 2026: at the limit. assert_eq!( too_many_columns(d(2007, 1, 15), d(2026, 12, 1), Columns::Month), None ); // 241 months. assert_eq!( too_many_columns(d(2006, 12, 31), d(2026, 12, 1), Columns::Month), Some(241) ); // 3,000 years by quarter, and by year. assert_eq!( too_many_columns(d(-500, 2, 1), d(2499, 11, 30), Columns::Quarter), Some((2999 * 12 + 9) / 3 + 1) ); assert_eq!( too_many_columns(d(-500, 2, 1), d(2499, 11, 30), Columns::Year), Some((2999 * 12 + 9) / 12 + 1) ); assert_eq!( too_many_columns(d(2000, 1, 1), d(2239, 12, 31), Columns::Year), None ); } use super::*; /// A test's dir, removed when the test ends, pass or panic. struct TestDir(std::path::PathBuf); impl Drop for TestDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } /// The fixed asset register report's tax columns are the register's: /// each held asset's contributor's basis, tax basis, and built-in gain /// (ledger-core's Asset::tax_basis and built_in_gain), the total its /// total_tax_basis, and its rule (cited) in the detail. #[test] fn asset_register_report_carries_tax_basis() { let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); let dir = std::env::temp_dir().join(format!("ledger-gui-tax-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let _cleanup = TestDir(dir.clone()); crate::demo::build(&dir, today).unwrap(); let b = crate::data::load_once(&crate::data::Source { data_dir: dir.join("books-data"), today: Some(today), }) .unwrap(); let r = ledger_core::fixed_asset_register(&b.ledger, today).unwrap(); assert!(!r.rows.is_empty(), "the demo has an asset"); assert_eq!(r.missing_tax_basis, 0, "the demo's asset has a tax basis"); let t = asset_register(&b, today); assert_eq!( t.columns, [ "Cost", "Accumulated", "Book value", "Contributor's basis", "Tax basis", "Built-in gain (loss)" ] ); for x in &r.rows { let a = b.ledger.get_asset(x.asset_id).unwrap(); let row = t .rows .iter() .find(|row| row.kind == Kind::Line && row.label.contains(&x.description)) .expect("a line per asset"); let v: Vec> = row.cells.iter().map(|c| c.value).collect(); assert_eq!( v, [ Some(x.cost), Some(x.accumulated), Some(x.book_value), a.tax.and_then(|t| t.contributor_basis), a.tax_basis(), a.built_in_gain(), ] ); assert!(row.detail.contains("tax basis: Cost"), "{}", row.detail); } let total = t.rows.iter().find(|row| row.label == "Total held").unwrap(); assert_eq!(total.cells[4].value, Some(r.total_tax_basis)); assert_eq!(total.cells[4].value, r.rows[0].tax_basis); assert!( t.rows .iter() .all(|row| row.cells.is_empty() || row.cells.len() == 6) ); assert!( t.rows .iter() .any(|row| row.kind == Kind::Note && row.label.contains("fair market value")) ); } /// A two-member LLC's first weeks (Member A and Member B, generic /// vendors, every amount and date made up): cash contributions, /// expenses the members paid personally (borrowed and spent, D-42) and /// their reimbursement, property contributed (one contribution /// reversed), a card, and a bank loan left out of the financing /// setting. Settings journaled: cash 1000, financing 2100, the members /// on 3000 and 3001. fn two_member_llc_shape(tag: &str) -> (crate::test_support::TempDir, Books) { use crate::test_support::{Build, d}; let mut b = Build::new(tag); let mut add = |code: &str, name: &str, ty| b.s.add_account(code, name, ty, None, false).unwrap(); let equipment = add("1500", "Equipment", AccountType::Asset); let accum = add("1510", "Accumulated Depreciation", AccountType::Asset); let card = add("2050", "Business Card", AccountType::Liability); let due = add("2100", "Due to Members", AccountType::Liability); let loan = add("2500", "Bank Loan", AccountType::Liability); let member_b = add("3001", "Capital — Member B", AccountType::Equity); let depr = add("6500", "Depreciation Expense", AccountType::Expense); let toml = "[company]\nname = \"Acme LLC\"\n\ [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ [books]\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\n"; let import = ledger_config::codec::import(toml, None, b.s.ledger().settings(), "USD").unwrap(); b.s.adopt_settings( &import.changes, &ledger_core::settings::Source { decided_by: "the test".into(), decided_on: d(2026, 1, 1), citation: None, note: None, }, ) .unwrap(); let (cash, capital, rent) = (b.cash, b.capital, b.rent); b.post_as("Formation filing fee", d(2026, 3, 2), rent, due, 8_500); b.post_as( "Contribution — Member A", d(2026, 3, 9), cash, capital, 120_000, ); b.post_as( "Contribution — Member B", d(2026, 3, 10), cash, member_b, 80_000, ); b.post_as("Subscription", d(2026, 3, 12), rent, due, 21_640); b.post_as("Reimburse members", d(2026, 3, 23), due, cash, 30_140); b.post_as("API credits", d(2026, 3, 25), rent, card, 1_420); let laptop = b.post_as( "Laptop contributed", d(2026, 3, 27), equipment, capital, 148_000, ); b.post_as( "Tablet contributed", d(2026, 3, 27), equipment, member_b, 63_000, ); b.post_as("Bank loan", d(2026, 3, 27), cash, loan, 250_000); b.s.register_asset(ledger_core::NewAsset { description: "Tablet".into(), purchase_date: d(2026, 3, 27), cost: Money::new(Decimal::new(63_000, 2), ledger_core::Currency::USD), salvage_value: Money::new(Decimal::ZERO, ledger_core::Currency::USD), useful_life_months: 36, cost_account: equipment, accum_depr_account: accum, expense_account: depr, }) .unwrap(); b.s.reverse_entry(laptop, d(2026, 3, 27), "Contributed jointly instead") .unwrap(); b.books() } use ledger_core::Money; /// The viewer's cash flow is the server's statement (D1-f: no guess /// from account names), laid out as printed: operating by class, /// investing and financing by kind with each transaction under it, /// the reconciliation and the noncash activities. The bank loan the /// financing setting leaves out is operating in both. #[test] fn the_cash_flow_page_is_the_servers_statement() { let (_dir, b) = two_member_llc_shape("cf-server"); let cols = vec![( NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(), "Total".to_string(), )]; let t = cash_flow_table(&b, &cols, false); let settings = ledger_package::Settings { company: &b.settings.company, cash_accounts: &b.settings.cash_accounts, financing_accounts: &b.settings.financing_accounts, investing_accounts: &b.settings.investing_accounts, }; let cf = ledger_package::cash_flow_for( &b.ledger, &settings, Some("2026-01-01"), Some("2026-03-27"), &[], &[], &[], b.today, ) .unwrap(); let rows: Vec<(String, Option)> = t .rows .iter() .filter(|r| r.kind != Kind::Section) .map(|r| (r.label.clone(), r.cells.first().and_then(|c| c.value))) .collect(); let mut want: Vec<(String, Option)> = Vec::new(); for (c, a) in cf.operating_by_class() { want.push((c.label().into(), Some(a))); } want.push(( "Net cash from operating activities".into(), Some(cf.cash_from_operations), )); for (sec, sub) in [ ( ledger_core::CashFlowSection::Investing, "Net cash from investing activities", ), ( ledger_core::CashFlowSection::Financing, "Net cash from financing activities", ), ] { for k in cf.kinds(sec) { want.push((k.kind.clone(), Some(k.amount))); for l in cf.lines(sec).iter().filter(|l| l.kind == k.kind) { want.push((l.label.clone(), Some(l.amount))); } } want.push((sub.into(), Some(cf.total(sec)))); } want.push(("Net change in cash".into(), Some(cf.net_change_in_cash))); want.push(("Cash at beginning".into(), Some(cf.cash_beginning))); want.push(("Cash at end".into(), Some(cf.cash_ending))); let r = &cf.operating_reconciliation; want.push(("Net income (loss)".into(), Some(r.net_income))); for l in &r.lines { let label = match l.kind { ledger_core::ReconcilingKind::OperatingBalanceChange => { let a = b .ledger .chart() .find_by_code(l.account.as_deref().unwrap()) .unwrap(); if a.account_type == AccountType::Asset { format!("(Increase) decrease in {} {}", a.code, a.name) } else { format!("Increase (decrease) in {} {}", a.code, a.name) } } _ => l.label.clone(), }; want.push((label, Some(l.amount))); } want.push(( "Net cash from operating activities".into(), Some(r.net_cash_from_operating), )); for n in &cf.noncash_disclosures { want.push((n.label.clone(), Some(n.amount))); } want.push(("Cash accounts: 1000 Cash.".into(), None)); assert_eq!(rows, want); // Each investing and financing kind is a line with its total; its // transactions are details under it, never summed into the section. let kind_of = |label: &str| t.rows.iter().find(|r| r.label == label).map(|r| r.kind); for sec in [ ledger_core::CashFlowSection::Investing, ledger_core::CashFlowSection::Financing, ] { for k in cf.kinds(sec) { assert!(kind_of(&k.kind) == Some(Kind::Line), "{}", k.kind); } for l in cf.lines(sec) { assert!(kind_of(&l.label) == Some(Kind::Detail), "{}", l.label); } } // The figures, by hand: the loan (not a financing setting) is an // operating receipt, 2,500.00; what the members paid personally // (85.00 and 216.40, 301.40) is borrowed from them through 2100 // and spent (D-42, rules.md B-14 rule 11); the members' // contributions are financing, each named (1,200.00 and 800.00); // the reimbursement repays 2100 (301.40); the tablet is disclosed // (630.00), the reversed laptop gone. assert_eq!( cf.operating_by_class(), [ ( ledger_core::OperatingClass::OtherReceipts, Decimal::new(250_000, 2) ), ( ledger_core::OperatingClass::ToSuppliersAndEmployees, Decimal::new(-30_140, 2) ), ] ); let kinds: Vec<(String, Decimal)> = cf .kinds(ledger_core::CashFlowSection::Financing) .into_iter() .map(|k| (k.kind, k.amount)) .collect(); assert_eq!( kinds, [ ( "Contributions from members — Member A".to_string(), Decimal::new(120_000, 2) ), ( "Contributions from members — Member B".to_string(), Decimal::new(80_000, 2) ), ( "Proceeds from borrowing — 2100 Due to Members".to_string(), Decimal::new(30_140, 2) ), ( "Repayments of borrowing — 2100 Due to Members".to_string(), Decimal::new(-30_140, 2) ), ] ); let disclosed: Vec = cf.noncash_disclosures.iter().map(|n| n.amount).collect(); assert_eq!(disclosed, [Decimal::new(63_000, 2)]); assert!( !t.rows .iter() .any(|r| r.label.contains("Bank Loan") && r.label.contains("borrowing")) ); } /// A statement the server refuses is refused here too: blank cells /// and the reason, never a number. #[test] fn a_refused_cash_flow_shows_why_and_no_number() { use crate::test_support::{Build, d}; let mut b = Build::new("cf-refused"); let (cash, capital) = (b.cash, b.capital); b.post(d(2026, 10, 7), cash, capital, 50_000); let (_dir, books) = b.books(); let cols = vec![(d(2026, 1, 1), d(2026, 10, 15), "Total".to_string())]; let t = cash_flow_table(&books, &cols, false); assert!( t.rows .iter() .flat_map(|r| &r.cells) .all(|c| c.value.is_none()) ); assert!( t.rows .iter() .any(|r| r.kind == Kind::Note && r.label.contains("cash_accounts is required")), "{:?}", t.rows.iter().map(|r| &r.label).collect::>() ); } /// The page as painted (and, with `SHOT_DIR` set, as a PNG for eyes: /// `cashflow-two-member-llc-shape.png`). #[test] fn the_cash_flow_page_paints_its_schedules() { use crate::test_support::{find, harness, painted}; let (_dir, b) = two_member_llc_shape("cf-paint"); let mut h = harness(b, Route::Reports(ReportKind::CashFlow)); h.state_mut().state_mut().reports.range = Range::Custom( NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), NaiveDate::from_ymd_opt(2026, 3, 27).unwrap(), ); h.run_steps(4); let texts = painted(h.output()); for text in [ "Operating activities", "Other operating cash receipts", "Cash paid to suppliers and employees", "Contributions from members — Member A", "Proceeds from borrowing — 2100 Due to Members", "Repayments of borrowing — 2100 Due to Members", "Reconciliation of net income to operating cash", "Noncash investing and financing activities", "Tablet contributed (#8)", ] { find(&texts, text); } if let Ok(dir) = std::env::var("SHOT_DIR") { let dir = std::path::PathBuf::from(dir); std::fs::create_dir_all(&dir).unwrap(); let img = h.render().unwrap(); img.save(dir.join("cashflow-two-member-llc-shape.png")) .unwrap(); } } /// Every table-backed report typesets as a PDF, in its plain, by-month, /// and compared layouts. Set `PDF_DIR` to keep the files for eyes. #[test] fn reports_typeset_as_pdf() { let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); let dir = std::env::temp_dir().join(format!("ledger-gui-pdf-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let _cleanup = TestDir(dir.clone()); crate::demo::build(&dir, today).unwrap(); let b = crate::data::load_once(&crate::data::Source { data_dir: dir.join("books-data"), today: Some(today), }) .unwrap(); let ytd = |columns, compare| State { range: Range::YearToDate, columns, compare, ..Default::default() }; let (s, e) = Range::YearToDate.dates(&b); let mut tables: Vec<(&str, Table)> = Vec::new(); let mut t = pnl( &b, &column_ranges(&ytd(Columns::Total, Compare::None), &b), false, ); t.add_share_of_income(); tables.push(("pnl", t)); let cols = column_ranges(&ytd(Columns::Month, Compare::None), &b); tables.push(("pnl-months", pnl(&b, &cols, true))); let mut t = pnl( &b, &column_ranges(&ytd(Columns::Total, Compare::PriorYear), &b), false, ); t.add_change(); tables.push(("pnl-vs-prior-year", t)); tables.push(("balance", balance_sheet(&b, &[(e, "Total".into())]))); let cols = column_ranges(&ytd(Columns::Total, Compare::None), &b); tables.push(("cashflow", cash_flow_table(&b, &cols, false))); tables.push(("trial", trial_balance(&b, e))); tables.push(("equity", owners_equity(&b, s, e))); tables.push(("tax", tax(&b, s, e))); tables.push(("ar", aging(b.ledger.ar_aging(e), e))); tables.push(("assets", asset_register(&b, e))); tables.push(("gl", gl_table(&general_ledger(&b.ledger, s, e).unwrap()))); let keep = std::env::var_os("PDF_DIR").map(std::path::PathBuf::from); for (name, t) in tables { let info = PdfInfo::new(&b, name, "Jan 1 – Oct 12, 2026"); let bytes = ledger_import::statement_pdf(&t.statement(&info.title, &info.period), &info.meta) .unwrap_or_else(|e| panic!("{name}: {e}")); assert!(bytes.starts_with(b"%PDF"), "{name}"); if let Some(d) = &keep { std::fs::create_dir_all(d).unwrap(); std::fs::write(d.join(format!("{name}.pdf")), bytes).unwrap(); } } } }