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

+4 -1
View File
@@ -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
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)
}
+287
View File
@@ -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
View File
@@ -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),
&note,
)?;
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);