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
@@ -3,6 +3,7 @@
|
||||
//! - [`bank_csv`]: parse QBO bank/credit-card upload CSVs.
|
||||
//! - [`chart_csv`]: parse chart-of-accounts CSVs.
|
||||
//! - [`iif`]: generate IIF journal entries for QuickBooks Desktop.
|
||||
//! - [`report`]: the statement layout every renderer draws from.
|
||||
//! - [`xlsx`]: generate XLSX reports.
|
||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||
//!
|
||||
@@ -12,10 +13,12 @@ pub mod bank_csv;
|
||||
pub mod chart_csv;
|
||||
pub mod iif;
|
||||
pub mod pdf;
|
||||
pub mod report;
|
||||
pub mod xlsx;
|
||||
|
||||
pub use bank_csv::{BankTransaction, parse_bank_csv};
|
||||
pub use chart_csv::{ChartRow, parse_chart_csv};
|
||||
pub use iif::{IifEntry, journal_to_iif};
|
||||
pub use pdf::{PdfError, balance_sheet_pdf, cash_flow_pdf, profit_and_loss_pdf};
|
||||
pub use pdf::{PdfError, statement_pdf};
|
||||
pub use report::{ReportMeta, Row, RowKind, Statement, format_amount};
|
||||
pub use xlsx::{XlsxReport, report_to_xlsx};
|
||||
+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)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! The presentation model every statement renderer (XLSX, PDF) draws from.
|
||||
//!
|
||||
//! Core computes the statements; this module lays them out as rows —
|
||||
//! sections, account lines, subtotals, totals — once, so the formats can't
|
||||
//! drift apart. Amounts stay exact `Decimal`s here; [`format_amount`] is the
|
||||
//! one place they become text ("1,234.56", "(207.68)").
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{
|
||||
BalanceSheetStatement, CashFlowLine, CashFlowStatement, ProfitAndLossStatement, StatementLine,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Who the report is for and exactly what it was prepared from.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReportMeta {
|
||||
/// Company name for the header, e.g. "Wanderland Labs LLC".
|
||||
pub company: String,
|
||||
/// Sequence number of the last journal line the report reflects.
|
||||
pub journal_seq: u64,
|
||||
}
|
||||
|
||||
/// What a row is, which decides how renderers style it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RowKind {
|
||||
/// A section heading ("Revenue"); no amount.
|
||||
Section,
|
||||
/// One account or one cash-flow line, indented under its section.
|
||||
Line,
|
||||
/// A section total ("Total revenue").
|
||||
Subtotal,
|
||||
/// A statement total ("Net income").
|
||||
Total,
|
||||
/// Vertical space between sections.
|
||||
Spacer,
|
||||
}
|
||||
|
||||
/// One row of a statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Row {
|
||||
/// Row kind.
|
||||
pub kind: RowKind,
|
||||
/// Label text.
|
||||
pub label: String,
|
||||
/// Amount, when the row has one.
|
||||
pub amount: Option<Decimal>,
|
||||
}
|
||||
|
||||
/// A laid-out financial statement.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Statement {
|
||||
/// "Profit and Loss", "Balance Sheet", "Statement of Cash Flows".
|
||||
pub title: String,
|
||||
/// "October 4 – October 11, 2026" or "As of October 11, 2026".
|
||||
pub period: String,
|
||||
/// The rows, top to bottom.
|
||||
pub rows: Vec<Row>,
|
||||
}
|
||||
|
||||
fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row {
|
||||
Row {
|
||||
kind,
|
||||
label: label.into(),
|
||||
amount,
|
||||
}
|
||||
}
|
||||
|
||||
fn section(label: &str) -> Row {
|
||||
row(RowKind::Section, label, None)
|
||||
}
|
||||
|
||||
fn spacer() -> Row {
|
||||
row(RowKind::Spacer, "", None)
|
||||
}
|
||||
|
||||
fn account_lines(lines: &[StatementLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines.iter().map(|l| {
|
||||
row(
|
||||
RowKind::Line,
|
||||
format!("{} {}", l.code, l.name),
|
||||
Some(l.amount),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn flow_lines(lines: &[CashFlowLine]) -> impl Iterator<Item = Row> + '_ {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| row(RowKind::Line, l.label.clone(), Some(l.amount)))
|
||||
}
|
||||
|
||||
/// Lay out a profit and loss statement.
|
||||
pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
||||
let mut rows = vec![section("Revenue")];
|
||||
rows.extend(account_lines(&pl.revenue));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total revenue",
|
||||
Some(pl.total_revenue),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Expenses"));
|
||||
rows.extend(account_lines(&pl.expenses));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total expenses",
|
||||
Some(pl.total_expenses),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
||||
Statement {
|
||||
title: "Profit and Loss".to_string(),
|
||||
period: period_label(pl.start, pl.end),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out a balance sheet.
|
||||
pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
|
||||
let mut rows = vec![section("Assets")];
|
||||
rows.extend(account_lines(&bs.assets));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total assets",
|
||||
Some(bs.total_assets),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Liabilities"));
|
||||
rows.extend(account_lines(&bs.liabilities));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total liabilities",
|
||||
Some(bs.total_liabilities),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Equity"));
|
||||
rows.extend(account_lines(&bs.equity));
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Current earnings",
|
||||
Some(bs.current_earnings),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Total equity",
|
||||
Some(bs.total_equity),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Total liabilities and equity",
|
||||
Some(bs.total_liabilities + bs.total_equity),
|
||||
));
|
||||
Statement {
|
||||
title: "Balance Sheet".to_string(),
|
||||
period: format!("As of {}", long_date(bs.as_of)),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out a cash flow statement (indirect method).
|
||||
pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
||||
let mut rows = vec![section("Operating activities")];
|
||||
rows.push(row(RowKind::Line, "Net income", Some(cf.net_income)));
|
||||
if !cf.depreciation.is_zero() {
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Depreciation (non-cash)",
|
||||
Some(cf.depreciation),
|
||||
));
|
||||
}
|
||||
rows.extend(flow_lines(&cf.working_capital));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from operating activities",
|
||||
Some(cf.cash_from_operations),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Investing activities"));
|
||||
rows.extend(flow_lines(&cf.investing));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from investing activities",
|
||||
Some(cf.cash_from_investing),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(section("Financing activities"));
|
||||
rows.extend(flow_lines(&cf.financing));
|
||||
rows.push(row(
|
||||
RowKind::Subtotal,
|
||||
"Net cash from financing activities",
|
||||
Some(cf.cash_from_financing),
|
||||
));
|
||||
rows.push(spacer());
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Net change in cash",
|
||||
Some(cf.net_change_in_cash),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Line,
|
||||
"Cash at beginning of period",
|
||||
Some(cf.cash_beginning),
|
||||
));
|
||||
rows.push(row(
|
||||
RowKind::Total,
|
||||
"Cash at end of period",
|
||||
Some(cf.cash_ending),
|
||||
));
|
||||
Statement {
|
||||
title: "Statement of Cash Flows".to_string(),
|
||||
period: period_label(cf.start, cf.end),
|
||||
rows,
|
||||
}
|
||||
}
|
||||
|
||||
/// "October 11, 2026".
|
||||
pub fn long_date(d: NaiveDate) -> String {
|
||||
d.format("%B %-d, %Y").to_string()
|
||||
}
|
||||
|
||||
/// "October 4 – October 11, 2026", or with both years when they differ.
|
||||
pub fn period_label(start: NaiveDate, end: NaiveDate) -> String {
|
||||
use chrono::Datelike;
|
||||
if start.year() == end.year() {
|
||||
format!("{} – {}", start.format("%B %-d"), long_date(end))
|
||||
} else {
|
||||
format!("{} – {}", long_date(start), long_date(end))
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact accounting format: thousands separators, two decimals, negatives
|
||||
/// in parentheses. "1,234.56", "(207.68)", "0.00". Never a float.
|
||||
pub fn format_amount(d: Decimal) -> String {
|
||||
let negative = d.is_sign_negative() && !d.is_zero();
|
||||
let fixed = format!("{:.2}", d.abs());
|
||||
let (int, frac) = fixed.split_once('.').expect("two decimals");
|
||||
let mut grouped = String::new();
|
||||
for (i, c) in int.chars().enumerate() {
|
||||
if i > 0 && (int.len() - i) % 3 == 0 {
|
||||
grouped.push(',');
|
||||
}
|
||||
grouped.push(c);
|
||||
}
|
||||
if negative {
|
||||
format!("({grouped}.{frac})")
|
||||
} else {
|
||||
format!("{grouped}.{frac}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::str::FromStr;
|
||||
|
||||
fn dec(s: &str) -> Decimal {
|
||||
Decimal::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amounts_format_exactly() {
|
||||
assert_eq!(format_amount(dec("0")), "0.00");
|
||||
assert_eq!(format_amount(dec("-0")), "0.00");
|
||||
assert_eq!(format_amount(dec("5.8")), "5.80");
|
||||
assert_eq!(format_amount(dec("207.68")), "207.68");
|
||||
assert_eq!(format_amount(dec("-207.68")), "(207.68)");
|
||||
assert_eq!(format_amount(dec("1000")), "1,000.00");
|
||||
assert_eq!(format_amount(dec("1234567.891")), "1,234,567.89");
|
||||
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn periods_read_like_english() {
|
||||
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
|
||||
assert_eq!(
|
||||
period_label(d(2026, 10, 4), d(2026, 10, 11)),
|
||||
"October 4 – October 11, 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
period_label(d(2026, 12, 1), d(2027, 1, 31)),
|
||||
"December 1, 2026 – January 31, 2027"
|
||||
);
|
||||
}
|
||||
}
|
||||
+123
-161
@@ -2,12 +2,15 @@
|
||||
//!
|
||||
//! Money is converted to f64 ONLY at this presentation boundary — never in
|
||||
//! a money path. All accounting math stays `Decimal`; the conversion happens
|
||||
//! when writing a cell, rounded to cents.
|
||||
//! when writing a cell, rounded to cents. Cells stay numeric (so a CPA can
|
||||
//! sum them) with an accounting number format: thousands separators,
|
||||
//! negatives in parentheses.
|
||||
|
||||
use crate::report::{ReportMeta, RowKind, Statement};
|
||||
use ledger_core::Ledger;
|
||||
use ledger_core::reports::{BalanceSheet, CashFlowStatement, ProfitAndLoss, TrialBalance};
|
||||
use ledger_core::reports::TrialBalance;
|
||||
use rust_decimal::Decimal;
|
||||
use rust_xlsxwriter::{Format, Workbook};
|
||||
use rust_xlsxwriter::{Format, FormatBorder, Workbook, Worksheet};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Which report to render.
|
||||
@@ -15,12 +18,9 @@ use thiserror::Error;
|
||||
pub enum XlsxReport<'a> {
|
||||
/// Trial balance lines.
|
||||
TrialBalance(&'a TrialBalance),
|
||||
/// Profit and loss.
|
||||
ProfitAndLoss(&'a ProfitAndLoss, String, String),
|
||||
/// Balance sheet.
|
||||
BalanceSheet(&'a BalanceSheet, String),
|
||||
/// Cash flow statement.
|
||||
CashFlow(&'a CashFlowStatement),
|
||||
/// A laid-out financial statement (P&L, balance sheet, cash flow); see
|
||||
/// [`crate::report`].
|
||||
Statement(&'a Statement),
|
||||
/// General ledger: every posted journal line. Needs the ledger for
|
||||
/// account names and class names.
|
||||
GeneralLedger(&'a Ledger),
|
||||
@@ -34,155 +34,120 @@ pub enum XlsxError {
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
||||
fn from(e: rust_xlsxwriter::XlsxError) -> Self {
|
||||
XlsxError::Write(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
const MONEY: &str = "#,##0.00;(#,##0.00)";
|
||||
|
||||
/// Company name, title, and period across the top; returns the next row.
|
||||
fn header(
|
||||
sheet: &mut Worksheet,
|
||||
meta: &ReportMeta,
|
||||
title: &str,
|
||||
period: Option<&str>,
|
||||
) -> Result<u32, XlsxError> {
|
||||
let bold = Format::new().set_bold();
|
||||
sheet.write_string_with_format(0, 0, &meta.company, &bold.clone().set_font_size(14))?;
|
||||
sheet.write_string_with_format(1, 0, title, &bold)?;
|
||||
let mut row = 2;
|
||||
if let Some(period) = period {
|
||||
sheet.write_string(row, 0, period)?;
|
||||
row += 1;
|
||||
}
|
||||
Ok(row + 1)
|
||||
}
|
||||
|
||||
/// "Prepared from journal through seq N" under the last row.
|
||||
fn footer(sheet: &mut Worksheet, meta: &ReportMeta, row: u32) -> Result<(), XlsxError> {
|
||||
let note = Format::new().set_italic().set_font_color("#666666");
|
||||
sheet.write_string_with_format(
|
||||
row + 1,
|
||||
0,
|
||||
format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||
¬e,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Render a report to XLSX bytes.
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
||||
pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<u8>, XlsxError> {
|
||||
let mut workbook = Workbook::new();
|
||||
let sheet = workbook.add_worksheet();
|
||||
let bold = Format::new().set_bold();
|
||||
let money_fmt = Format::new().set_num_format("#,##0.00");
|
||||
let money = Format::new().set_num_format(MONEY);
|
||||
let money_bold = money.clone().set_bold();
|
||||
|
||||
let mut row: u32 = 0;
|
||||
match report {
|
||||
XlsxReport::TrialBalance(tb) => {
|
||||
sheet.write_string(row, 0, "Trial Balance").unwrap();
|
||||
row += 1;
|
||||
sheet.set_name("Trial Balance")?;
|
||||
let mut row = header(sheet, meta, "Trial Balance", None)?;
|
||||
for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() {
|
||||
sheet
|
||||
.write_string_with_format(row, i as u16, *h, &bold)
|
||||
.unwrap();
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
row += 1;
|
||||
for line in &tb.lines {
|
||||
sheet.write_string(row, 0, &line.code).unwrap();
|
||||
sheet.write_string(row, 1, &line.name).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 2, dec_to_f64(line.debit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 3, dec_to_f64(line.credit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet.write_string(row, 0, &line.code)?;
|
||||
sheet.write_string(row, 1, &line.name)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(line.debit), &money)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
let total = money_bold.clone().set_border_top(FormatBorder::Thin);
|
||||
sheet.write_string_with_format(row, 1, "Total", &bold)?;
|
||||
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
|
||||
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
|
||||
sheet.set_column_width(1, 40)?;
|
||||
sheet.set_column_width(2, 14)?;
|
||||
sheet.set_column_width(3, 14)?;
|
||||
footer(sheet, meta, row + 1)?;
|
||||
}
|
||||
XlsxReport::ProfitAndLoss(pl, start, end) => {
|
||||
sheet
|
||||
.write_string(row, 0, format!("Profit and Loss ({start} to {end})"))
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet.write_string(row, 0, "Revenue").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(pl.revenue), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet.write_string(row, 0, "Expenses").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(pl.expenses), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Net income", &bold)
|
||||
.unwrap();
|
||||
let net = pl.revenue - pl.expenses;
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(net), &money_fmt)
|
||||
.unwrap();
|
||||
}
|
||||
XlsxReport::BalanceSheet(bs, as_of) => {
|
||||
sheet
|
||||
.write_string(row, 0, format!("Balance Sheet (as of {as_of})"))
|
||||
.unwrap();
|
||||
row += 2;
|
||||
for (label, val) in [
|
||||
("Assets", bs.assets),
|
||||
("Liabilities", bs.liabilities),
|
||||
("Equity", bs.equity),
|
||||
] {
|
||||
sheet.write_string(row, 0, label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(val), &money_fmt)
|
||||
.unwrap();
|
||||
XlsxReport::Statement(st) => {
|
||||
sheet.set_name(&st.title)?;
|
||||
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||
let line_label = Format::new().set_indent(1);
|
||||
let subtotal = money_bold.clone().set_border_top(FormatBorder::Thin);
|
||||
let total = money_bold
|
||||
.clone()
|
||||
.set_border_top(FormatBorder::Thin)
|
||||
.set_border_bottom(FormatBorder::Double);
|
||||
for r in &st.rows {
|
||||
let amount = r.amount.map(dec_to_f64);
|
||||
match r.kind {
|
||||
RowKind::Spacer => {}
|
||||
RowKind::Section => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
}
|
||||
RowKind::Line => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||
if let Some(a) = amount {
|
||||
sheet.write_number_with_format(row, 1, a, &money)?;
|
||||
}
|
||||
}
|
||||
RowKind::Subtotal => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
sheet.write_number_with_format(row, 1, a, &subtotal)?;
|
||||
}
|
||||
}
|
||||
RowKind::Total => {
|
||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||
if let Some(a) = amount {
|
||||
sheet.write_number_with_format(row, 1, a, &total)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
XlsxReport::CashFlow(cf) => {
|
||||
sheet
|
||||
.write_string(row, 0, format!("Cash Flow ({} to {})", cf.start, cf.end))
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Operating activities", &bold)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet.write_string(row, 0, "Net income").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.net_income), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
sheet.write_string(row, 0, "Depreciation").unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.depreciation), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
for line in &cf.working_capital {
|
||||
sheet.write_string(row, 0, &line.label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
}
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Cash from operations", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_operations), &money_fmt)
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Investing activities", &bold)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
for line in &cf.investing {
|
||||
sheet.write_string(row, 0, &line.label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
}
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Cash from investing", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_investing), &money_fmt)
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Financing activities", &bold)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
for line in &cf.financing {
|
||||
sheet.write_string(row, 0, &line.label).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(line.amount), &money_fmt)
|
||||
.unwrap();
|
||||
row += 1;
|
||||
}
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Cash from financing", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.cash_from_financing), &money_fmt)
|
||||
.unwrap();
|
||||
row += 2;
|
||||
sheet
|
||||
.write_string_with_format(row, 0, "Net change in cash", &bold)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 1, dec_to_f64(cf.net_change_in_cash), &money_fmt)
|
||||
.unwrap();
|
||||
sheet.set_column_width(0, 60)?;
|
||||
sheet.set_column_width(1, 16)?;
|
||||
footer(sheet, meta, row)?;
|
||||
}
|
||||
XlsxReport::GeneralLedger(ledger) => {
|
||||
sheet.write_string(row, 0, "General Ledger").unwrap();
|
||||
row += 1;
|
||||
sheet.set_name("General Ledger")?;
|
||||
let mut row = header(sheet, meta, "General Ledger", None)?;
|
||||
for (i, h) in [
|
||||
"Date",
|
||||
"Entry",
|
||||
@@ -196,9 +161,7 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
sheet
|
||||
.write_string_with_format(row, i as u16, *h, &bold)
|
||||
.unwrap();
|
||||
sheet.write_string_with_format(row, i as u16, *h, &bold)?;
|
||||
}
|
||||
row += 1;
|
||||
for entry in ledger.posted_entries() {
|
||||
@@ -217,29 +180,24 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result<Vec<u8>, XlsxError> {
|
||||
ledger_core::Side::Debit => (line.amount.amount, Decimal::ZERO),
|
||||
ledger_core::Side::Credit => (Decimal::ZERO, line.amount.amount),
|
||||
};
|
||||
sheet
|
||||
.write_string(row, 0, entry.date().to_string())
|
||||
.unwrap();
|
||||
sheet.write_number(row, 1, entry.id() as f64).unwrap();
|
||||
sheet.write_string(row, 2, entry.description()).unwrap();
|
||||
sheet.write_string(row, 3, code).unwrap();
|
||||
sheet.write_string(row, 4, name).unwrap();
|
||||
sheet.write_string(row, 5, class_name).unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 6, dec_to_f64(debit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet
|
||||
.write_number_with_format(row, 7, dec_to_f64(credit), &money_fmt)
|
||||
.unwrap();
|
||||
sheet.write_string(row, 0, entry.date().to_string())?;
|
||||
sheet.write_number(row, 1, entry.id() as f64)?;
|
||||
sheet.write_string(row, 2, entry.description())?;
|
||||
sheet.write_string(row, 3, code)?;
|
||||
sheet.write_string(row, 4, name)?;
|
||||
sheet.write_string(row, 5, class_name)?;
|
||||
sheet.write_number_with_format(row, 6, dec_to_f64(debit), &money)?;
|
||||
sheet.write_number_with_format(row, 7, dec_to_f64(credit), &money)?;
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_column_width(2, 50)?;
|
||||
sheet.set_column_width(4, 32)?;
|
||||
footer(sheet, meta, row)?;
|
||||
}
|
||||
}
|
||||
|
||||
workbook
|
||||
.save_to_buffer()
|
||||
.map_err(|e| XlsxError::Write(e.to_string()))
|
||||
Ok(workbook.save_to_buffer()?)
|
||||
}
|
||||
|
||||
/// Decimal to f64 at the presentation boundary. Rounded to cents first.
|
||||
@@ -265,7 +223,11 @@ mod tests {
|
||||
total_debits: Decimal::new(10000, 2),
|
||||
total_credits: Decimal::new(10000, 2),
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).unwrap();
|
||||
let meta = ReportMeta {
|
||||
company: "Test Co".to_string(),
|
||||
journal_seq: 7,
|
||||
};
|
||||
let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb), &meta).unwrap();
|
||||
// XLSX is a ZIP: starts with PK.
|
||||
assert_eq!(&bytes[0..2], b"PK");
|
||||
assert!(bytes.len() > 1000);
|
||||
|
||||
Reference in new issue
Block a user