Next steps 5-10: resources, cash flow, QB interop, backfill, HTTP, parallel-run plan
This commit is contained in:
1 parent
42088e06d5
commit
d6de58bb41
1 file changed
+214
@@ -0,0 +1,214 @@
|
|||||||
|
//! 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::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),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user