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:
1 parent
c6faeeb827
commit
b5af21bb0e
14 files changed
+1051
-613
No files matched your search
+19
-158
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user