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