//! Generate XLSX reports with `rust_xlsxwriter` (MIT). //! //! 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. use ledger_core::Ledger; use ledger_core::reports::{BalanceSheet, CashFlowStatement, ProfitAndLoss, TrialBalance}; use rust_decimal::Decimal; use rust_xlsxwriter::{Format, Workbook}; use thiserror::Error; /// Which report to render. #[derive(Clone, Debug)] 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), /// General ledger: every posted journal line. Needs the ledger for /// account names and class names. GeneralLedger(&'a Ledger), } /// XLSX errors. #[derive(Debug, Error)] pub enum XlsxError { /// The writer failed. #[error("xlsx write failed: {0}")] Write(String), } /// Render a report to XLSX bytes. pub fn report_to_xlsx(report: &XlsxReport<'_>) -> Result, 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 mut row: u32 = 0; match report { XlsxReport::TrialBalance(tb) => { sheet.write_string(row, 0, "Trial Balance").unwrap(); row += 1; for (i, h) in ["Code", "Account", "Debit", "Credit"].iter().enumerate() { sheet .write_string_with_format(row, i as u16, *h, &bold) .unwrap(); } 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(); 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(); 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(); } XlsxReport::GeneralLedger(ledger) => { sheet.write_string(row, 0, "General Ledger").unwrap(); row += 1; for (i, h) in [ "Date", "Entry", "Description", "Account Code", "Account Name", "Class", "Debit", "Credit", ] .iter() .enumerate() { sheet .write_string_with_format(row, i as u16, *h, &bold) .unwrap(); } row += 1; for entry in ledger.posted_entries() { for line in entry.lines() { let account = ledger.chart().get(line.account); let (code, name) = match account { Some(a) => (a.code.as_str(), a.name.as_str()), None => ("?", "?"), }; let class_name = line .class_id .and_then(|cid| ledger.get_class(cid)) .map(|c| c.name.as_str()) .unwrap_or(""); let (debit, credit) = match line.side { 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(); row += 1; } } } } workbook .save_to_buffer() .map_err(|e| XlsxError::Write(e.to_string())) } /// Decimal to f64 at the presentation boundary. Rounded to cents first. fn dec_to_f64(d: Decimal) -> f64 { use rust_decimal::prelude::ToPrimitive; d.round_dp(2).to_f64().unwrap_or(0.0) } #[cfg(test)] mod tests { use super::*; use ledger_core::reports::TrialBalanceLine; #[test] fn xlsx_is_a_zip() { let tb = TrialBalance { lines: vec![TrialBalanceLine { code: "1000".to_string(), name: "Cash".to_string(), debit: Decimal::new(10000, 2), credit: Decimal::ZERO, }], total_debits: Decimal::new(10000, 2), total_credits: Decimal::new(10000, 2), }; let bytes = report_to_xlsx(&XlsxReport::TrialBalance(&tb)).unwrap(); // XLSX is a ZIP: starts with PK. assert_eq!(&bytes[0..2], b"PK"); assert!(bytes.len() > 1000); } }