feat: real dates and per-account detail in every statement export

Every statement export rendered life-of-ledger totals under whatever date
range or as-of label was asked for, and the P&L and balance sheet XLSX
were totals-only. Now:

- books.profit_and_loss / books.balance_sheet and the csv, json, xlsx,
  pdf exports and CPA package all use core's profit_and_loss_for_period
  and balance_sheet_as_of, with a line per account. The tools keep their
  totals and add revenue/expense/asset/liability/equity account lines.
  An omitted start is the first posted entry, never "0001-01-01".
- ledger-import::report lays a statement out once (section, line,
  subtotal, total) for every renderer, with one exact amount formatter:
  thousands separators, negatives in parentheses, no floats. XLSX keeps
  numeric cells with an accounting number format, named sheets, company
  header, totals with borders; the trial balance gains its totals row.
- ledger-mcp reads wanderbooks.toml (next to --data-dir, or
  --books-toml): company name for headers, fiscal_year_start for the CPA
  package's fiscal year to date (end derived; Feb 29 rejected), and
  [books] receipt_threshold, which the server had been ignoring.
  config.json's receipt_policy still wins when set. New dependency: toml
  1.x (already in the lockfile via another crate).
- Store::journal_seq(); every report notes the journal seq it reflects.

Tests: company.rs unit tests (fiscal years incl. a July start, scaffold
parsing, bad settings); report.rs formatting tests; an MCP stdio test with
--books-toml (threshold 5.00 enforced, February-only P&L with account
lines, start defaults to the first entry, as-of balance sheet).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-11 19:11:36 -07:00
1 parent c6faeeb827
commit b5af21bb0e
14 files changed
+1051 -613

No files matched your search

+19 -158
View File
@@ -3,13 +3,11 @@
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
use ledger_core::reports::CashFlowStatement;
use ledger_core::{AccountType, Ledger};
use crate::report::{ReportMeta, RowKind, Statement, format_amount};
use printpdf::{
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
Rgb, TextItem,
};
use rust_decimal::Decimal;
use thiserror::Error;
/// PDF rendering errors.
@@ -20,11 +18,6 @@ pub enum PdfError {
Save(String),
}
fn money(d: Decimal) -> String {
// Two decimals, no float anywhere.
format!("{:.2}", d)
}
fn text_ops(
title: &str,
subtitle: &str,
@@ -103,160 +96,28 @@ fn save_pdf(title: &str, ops: Vec<Op>) -> Result<Vec<u8>, PdfError> {
Ok(bytes)
}
/// Render a profit-and-loss statement PDF: revenue and expense accounts with
/// balances, then totals and net income.
pub fn profit_and_loss_pdf(ledger: &Ledger, start: &str, end: &str) -> Result<Vec<u8>, PdfError> {
let mut revenue_rows: Vec<(String, String)> = Vec::new();
let mut expense_rows: Vec<(String, String)> = Vec::new();
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
let balance = ledger
.account_balance(account.id)
.map_err(|e| PdfError::Save(e.to_string()))?;
if balance.is_zero() {
continue;
}
let label = format!("{} {}", account.code, account.name);
match account.account_type {
AccountType::Revenue => revenue_rows.push((label, money(balance))),
AccountType::Expense => expense_rows.push((label, money(balance))),
_ => {}
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
let mut rows = Vec::new();
let mut totals = Vec::new();
for r in &st.rows {
let amount = r.amount.map(format_amount).unwrap_or_default();
match r.kind {
RowKind::Spacer => rows.push((String::new(), String::new())),
RowKind::Section => rows.push((r.label.to_uppercase(), String::new())),
RowKind::Line => rows.push((format!(" {}", r.label), amount)),
RowKind::Subtotal | RowKind::Total => rows.push((r.label.clone(), amount)),
}
}
let pl =
ledger_core::reports::profit_and_loss(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
let mut rows = vec![("REVENUE".to_string(), "".to_string())];
rows.extend(revenue_rows);
rows.push(("".to_string(), "".to_string()));
rows.push(("EXPENSES".to_string(), "".to_string()));
rows.extend(expense_rows);
let totals = vec![
("Total revenue".to_string(), money(pl.revenue)),
("Total expenses".to_string(), money(pl.expenses)),
("Net income".to_string(), money(pl.net_income())),
];
totals.push((
format!("Prepared from journal through seq {}", meta.journal_seq),
String::new(),
));
let ops = text_ops(
"Profit and Loss",
&format!("{} to {}", start, end),
&format!("{} — {}", meta.company, st.title),
&st.period,
&rows,
&totals,
);
save_pdf("Profit and Loss", ops)
}
/// Render a balance-sheet PDF: assets, liabilities, equity with balances,
/// then totals proving the accounting equation.
pub fn balance_sheet_pdf(ledger: &Ledger, as_of: &str) -> Result<Vec<u8>, PdfError> {
let mut asset_rows: Vec<(String, String)> = Vec::new();
let mut liab_rows: Vec<(String, String)> = Vec::new();
let mut equity_rows: Vec<(String, String)> = Vec::new();
let mut accounts: Vec<_> = ledger.chart().all().collect();
accounts.sort_by(|a, b| a.code.cmp(&b.code));
for account in accounts {
if account.placeholder {
continue;
}
let balance = ledger
.account_balance(account.id)
.map_err(|e| PdfError::Save(e.to_string()))?;
if balance.is_zero() {
continue;
}
let label = format!("{} {}", account.code, account.name);
match account.account_type {
AccountType::Asset => asset_rows.push((label, money(balance))),
AccountType::Liability => liab_rows.push((label, money(balance))),
AccountType::Equity => equity_rows.push((label, money(balance))),
_ => {}
}
}
let bs =
ledger_core::reports::balance_sheet(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
let mut rows = vec![("ASSETS".to_string(), "".to_string())];
rows.extend(asset_rows);
rows.push(("".to_string(), "".to_string()));
rows.push(("LIABILITIES".to_string(), "".to_string()));
rows.extend(liab_rows);
rows.push(("".to_string(), "".to_string()));
rows.push(("EQUITY".to_string(), "".to_string()));
rows.extend(equity_rows);
rows.push((
"Current earnings (in equity)".to_string(),
money(bs.current_earnings),
));
let totals = vec![
("Total assets".to_string(), money(bs.assets)),
(
"Total liabilities + equity".to_string(),
money(bs.liabilities + bs.equity),
),
];
let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals);
save_pdf("Balance Sheet", ops)
}
/// Render a cash flow statement PDF (indirect method): operating (net
/// income, depreciation add-back, working-capital changes), investing, and
/// financing sections, then the net change reconciled to beginning and
/// ending cash. Takes the computed statement, so the period and cash
/// accounts are whatever the caller chose.
pub fn cash_flow_pdf(cf: &CashFlowStatement) -> Result<Vec<u8>, PdfError> {
let line = |l: &ledger_core::reports::CashFlowLine| (format!(" {}", l.label), money(l.amount));
let mut rows = vec![("OPERATING ACTIVITIES".to_string(), "".to_string())];
rows.push((" Net income".to_string(), money(cf.net_income)));
if !cf.depreciation.is_zero() {
rows.push((
" Depreciation (non-cash)".to_string(),
money(cf.depreciation),
));
}
rows.extend(cf.working_capital.iter().map(line));
rows.push((
"Net cash from operating activities".to_string(),
money(cf.cash_from_operations),
));
rows.push(("".to_string(), "".to_string()));
rows.push(("INVESTING ACTIVITIES".to_string(), "".to_string()));
rows.extend(cf.investing.iter().map(line));
rows.push((
"Net cash from investing activities".to_string(),
money(cf.cash_from_investing),
));
rows.push(("".to_string(), "".to_string()));
rows.push(("FINANCING ACTIVITIES".to_string(), "".to_string()));
rows.extend(cf.financing.iter().map(line));
rows.push((
"Net cash from financing activities".to_string(),
money(cf.cash_from_financing),
));
let totals = vec![
(
"Net change in cash".to_string(),
money(cf.net_change_in_cash),
),
(
"Cash at beginning of period".to_string(),
money(cf.cash_beginning),
),
("Cash at end of period".to_string(), money(cf.cash_ending)),
];
let ops = text_ops(
"Statement of Cash Flows",
&format!("{} to {}", cf.start, cf.end),
&rows,
&totals,
);
save_pdf("Statement of Cash Flows", ops)
save_pdf(&st.title, ops)
}