Files
wanderbooks/crates/ledger-gui/src/pages/reports.rs
T
swaitsandClaude Opus 5.5 12308fe3e9 gui: Save PDF on every report; short and narrow windows hold up
Reports get a Save PDF button beside Copy for a spreadsheet. It typesets
the report as it's set up on screen (range, months/quarters/years,
comparison) through ledger-import's Typst statement template, the same
one the CPA package uses, in the background, writes it to the Downloads
folder (never the books: the viewer stays read-only), and opens it.

- ledger-import: Statement.percent_from marks percentage columns
  (comparison %, % of income), typeset as 12.5% with no dollar sign;
  wide statements turn the page, and past seven columns set smaller so
  a year of months fits; statement() is public for other callers.
- Compared columns that read the same (Jan 1 - Oct 12 vs a year
  earlier) now say which year, on screen and on paper.
- Short windows: the sidebar's and rail's links scroll above the status
  box instead of under it. The search palette fits the window.
- A test typesets every table-backed report from the demo books,
  plain, by month, and compared (PDF_DIR keeps the files).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-12 20:14:35 -06:00

1815 lines
62 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::{
CashFlowParams, balance_sheet_as_of, cash_flow, 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,
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::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 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(),
}
}
fn prior(st: &State, s: NaiveDate, e: NaiveDate) -> Option<(NaiveDate, NaiveDate)> {
match st.compare {
Compare::None => None,
Compare::PriorYear => Some((s - Months::new(12), e - Months::new(12))),
Compare::PriorPeriod => {
let days = (e - s).num_days() + 1;
let pe = s - Days::new(1);
Some((pe - Days::new(days as u64 - 1), pe))
}
}
}
type Cols = [(NaiveDate, NaiveDate, String)];
fn to_jiff(d: NaiveDate) -> jiff::civil::Date {
jiff::civil::Date::new(d.year() as i16, d.month() as i8, d.day() as i8).expect("valid date")
}
fn from_jiff(d: jiff::civil::Date) -> NaiveDate {
NaiveDate::from_ymd_opt(i32::from(d.year()), d.month() as u32, d.day() as u32)
.expect("valid date")
}
// ----------------------------------------------------------------------
// 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
}
fn financing_accounts(b: &Books) -> Vec<AccountId> {
b.ledger
.chart()
.all()
.filter(|a| {
let n = a.name.to_lowercase();
a.account_type == AccountType::Liability
&& (n.contains("loan")
|| n.contains("note payable")
|| n.contains("line of credit"))
})
.map(|a| a.id)
.collect()
}
fn cash_flow_table(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 fin = financing_accounts(b);
let stmts: Vec<_> = cols
.iter()
.map(|(s, e, _)| {
cash_flow(
&b.ledger,
&CashFlowParams {
start: *s,
end: *e,
cash_accounts: b.cash_accounts.clone(),
financing_accounts: fin.clone(),
},
)
.ok()
})
.collect();
let mut t = Table::new(cols.iter().map(|c| c.2.clone()).collect());
let val = |f: &dyn Fn(&ledger_core::CashFlowStatement) -> Decimal| -> Vec<Cell> {
stmts
.iter()
.map(|s| Cell {
value: s.as_ref().map(f),
drill: None,
})
.collect()
};
let lines = |t: &mut Table,
pick: &dyn Fn(
&ledger_core::CashFlowStatement,
) -> &Vec<ledger_core::CashFlowLine>| {
let mut labels: Vec<(String, Option<String>)> = Vec::new();
for s in stmts.iter().flatten() {
for l in pick(s) {
if !labels.iter().any(|(x, _)| x == &l.label) {
labels.push((l.label.clone(), l.account.clone()));
}
}
}
for (label, account) in labels {
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: Some(
st.as_ref()
.and_then(|st| pick(st).iter().find(|l| l.label == label))
.map(|l| l.amount)
.unwrap_or_default(),
),
drill: id.map(|account| Drill {
account,
start: *s,
end: *e,
}),
})
.collect();
t.row(Kind::Line, label, cells);
}
};
t.section("Operating activities");
t.row(Kind::Line, "Net income", val(&|s| s.net_income));
t.row(
Kind::Line,
"Depreciation (non-cash)",
val(&|s| s.depreciation),
);
lines(&mut t, &|s| &s.working_capital);
t.row(
Kind::Subtotal,
"Cash from operations",
val(&|s| s.cash_from_operations),
);
t.section("Investing activities");
lines(&mut t, &|s| &s.investing);
t.row(
Kind::Subtotal,
"Cash from investing",
val(&|s| s.cash_from_investing),
);
t.section("Financing activities");
lines(&mut t, &|s| &s.financing);
t.row(
Kind::Subtotal,
"Cash from financing",
val(&|s| s.cash_from_financing),
);
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));
let names: Vec<String> = b
.cash_accounts
.iter()
.map(|a| b.account_label(*a))
.collect();
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,
&format!("Balance, {}", fmt::date(s - Days::new(1))),
&|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 {
let begin = b.balance_on(a, s - Days::new(1));
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 = balance_sheet_as_of(&b.ledger, s - Days::new(1))
.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.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
}
fn aging(rows: Vec<ledger_core::AgingRow>, 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 mut tot = [Decimal::ZERO; 6];
for r in &rows {
let v = [
r.current,
r.days_30,
r.days_60,
r.days_90,
r.days_90_plus,
r.total(),
];
for (i, x) in v.iter().enumerate() {
tot[i] += x;
}
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 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],
),
(
"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
) || (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 (mut js, mut je) = (to_jiff(s), to_jiff(e));
ui.add(
egui_extras::DatePickerButton::new(&mut js)
.id_salt("from")
.calendar_week(false),
);
ui.label(RichText::new("to").color(p.muted));
ui.add(
egui_extras::DatePickerButton::new(&mut je)
.id_salt("to")
.calendar_week(false),
);
let (s, e) = (from_jiff(js), from_jiff(je));
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);
let state = State {
range: st.range,
columns: if splittable {
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::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(t) = table {
render(ui, &t, &PdfInfo::new(b, kind.title(), &subtitle), shell);
}
if let Some(k) = pick {
shell.go(Route::Reports(k));
}
}
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;
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 mut save_pdf = false;
widgets::card(ui, |ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
let busy = st.pdf_job.is_some();
let label = if busy {
format!("{} Making PDF…", icon::HOURGLASS)
} else {
format!("{} Save PDF", icon::FILE_PDF)
};
if 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()
{
save_pdf = true;
}
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());
}
});
if let Some((done, at)) = &st.pdf_done {
let now = ui.input(|i| i.time);
if now - at < 8.0 {
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));
}
}
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) && 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::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 cresp = ui.interact(
cell,
ui.id().with(("cell", ri, i)),
if c.drill.is_some() {
Sense::click()
} else {
Sense::hover()
},
);
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 {
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 = ui.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;
}
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 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);
let w = ui.available_width();
let fixed = [86.0, 52.0, 0.0, 110.0, 110.0, 120.0];
let flex = (w - fixed.iter().sum::<f32>()).max(120.0);
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());
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 {
let max_chars = ((cw - 8.0) / 6.8) as usize;
let shown: String = if v.chars().count() > max_chars {
v.chars()
.take(max_chars.saturating_sub(1))
.collect::<String>()
+ "…"
} else {
v.clone()
};
painter.text(
egui::pos2(x + 4.0, r.center().y),
egui::Align2::LEFT_CENTER,
shown,
font,
color,
);
}
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 = vendor_1099_report(&b.ledger, year, None);
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 {
use super::*;
/// 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);
crate::demo::build(&dir, today).unwrap();
let b = crate::data::load_once(&crate::data::Source {
data_dir: dir.join("books-data"),
books_toml: Some(dir.join("wanderbooks.toml")),
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)));
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();
}
}
let _ = std::fs::remove_dir_all(&dir);
}
}