Files
wanderbooks/crates/ledger-gui/src/pages/reports.rs
T
swaitsandClaude Opus 5.5 eb4329bb2a
check / check (push) Canceled after 0s
P3: the cash flow statement by ASC 230 (defect 1, D-35, D-42)
Direct method with the operating reconciliation and noncash schedules; it
foots by construction (Lean and Kani). Every non-cash account belongs to
one section; reversals net; a member or lender paying for the company is
cash borrowed and spent (D-42, constructive receipt and disbursement);
property a member contributes stays noncash. The bookspec seed: one loader
checks every example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-21 09:35:32 -06:00

2757 lines
97 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<std::sync::mpsc::Receiver<Result<std::path::PathBuf, String>>>,
/// How the last PDF went, and when (to fade the message).
pdf_done: Option<(Result<std::path::PathBuf, String>, 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<Decimal>,
drill: Option<Drill>,
}
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<Cell>,
}
struct Table {
columns: Vec<String>,
rows: Vec<Row>,
/// Format cells as percentages from this column index on.
pct_from: Option<usize>,
}
impl Table {
fn new(columns: Vec<String>) -> 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<String>, cells: Vec<Cell>) {
self.rows.push(Row {
kind,
label: label.into(),
detail: String::new(),
cells,
});
}
fn note(&mut self, text: impl Into<String>) {
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<i64> {
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<ledger_core::StatementLine>,
total_label: &str| {
let mut accounts: BTreeMap<String, String> = 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<ledger_core::StatementLine>| {
let mut accounts: BTreeMap<String, String> = 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<Cell> {
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<String>);
/// 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<Result<ledger_core::CashFlowStatement, String>> = 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<Cell> {
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<KeyedRow>| {
let mut order: Vec<(String, String, Option<String>)> = 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<String> = 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<String> = 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(&note) {
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<String> = 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<MemberCapital> = 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<String> = 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<String> = 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<AccountId> =
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<Vec<ledger_core::AgingRow>, 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<Decimal>| 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<AccountId>| -> Option<Drill> {
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<Drill> = 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) = &copy {
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<Route> = 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::<f32>() + 120.0);
let flex = w - fixed.iter().sum::<f32>();
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<Option<Decimal>> = 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<Decimal>)> = 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<Decimal>)> = 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<Decimal> = 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::<Vec<_>>()
);
}
/// 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();
}
}
}
}