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

+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);