Files
wanderbooks/crates/ledger-import/src/xlsx.rs
T

274 lines
10 KiB
Rust

//! 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<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 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);
}
}