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
1815 lines
62 KiB
Rust
1815 lines
62 KiB
Rust
//! 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);
|
||
}
|
||
}
|