Payroll accounting, class tracking, CPA handoff package (48 MCP tools)
This commit is contained in:
1 parent
0a90a72962
commit
09d1537225
1 file changed
+205
@@ -0,0 +1,205 @@
|
||||
//! PDF financial statements for the CPA handoff package.
|
||||
//!
|
||||
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
|
||||
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
|
||||
|
||||
use ledger_core::{AccountType, Ledger};
|
||||
use printpdf::{
|
||||
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
|
||||
Rgb, TextItem,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use thiserror::Error;
|
||||
|
||||
/// PDF rendering errors.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PdfError {
|
||||
/// printpdf failed to save.
|
||||
#[error("pdf save failed: {0}")]
|
||||
Save(String),
|
||||
}
|
||||
|
||||
fn money(d: Decimal) -> String {
|
||||
// Two decimals, no float anywhere.
|
||||
format!("{:.2}", d)
|
||||
}
|
||||
|
||||
fn text_ops(
|
||||
title: &str,
|
||||
subtitle: &str,
|
||||
rows: &[(String, String)],
|
||||
totals: &[(String, String)],
|
||||
) -> Vec<Op> {
|
||||
let mut ops = vec![
|
||||
Op::StartTextSection,
|
||||
Op::SetTextCursor {
|
||||
pos: Point::new(Mm(20.0), Mm(280.0)),
|
||||
},
|
||||
Op::SetFont {
|
||||
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
|
||||
size: Pt(18.0),
|
||||
},
|
||||
Op::SetLineHeight { lh: Pt(22.0) },
|
||||
Op::SetFillColor {
|
||||
col: Color::Rgb(Rgb {
|
||||
r: 0.0,
|
||||
g: 0.0,
|
||||
b: 0.0,
|
||||
icc_profile: None,
|
||||
}),
|
||||
},
|
||||
Op::ShowText {
|
||||
items: vec![TextItem::Text(title.to_string())],
|
||||
},
|
||||
Op::AddLineBreak,
|
||||
Op::SetFont {
|
||||
font: PdfFontHandle::Builtin(BuiltinFont::Helvetica),
|
||||
size: Pt(11.0),
|
||||
},
|
||||
Op::SetLineHeight { lh: Pt(14.0) },
|
||||
Op::ShowText {
|
||||
items: vec![TextItem::Text(subtitle.to_string())],
|
||||
},
|
||||
Op::AddLineBreak,
|
||||
Op::AddLineBreak,
|
||||
];
|
||||
|
||||
for (label, amount) in rows {
|
||||
// Label left, amount right-ish via padding. Monospace would be
|
||||
// nicer; Helvetica with padding is readable enough for a CPA file.
|
||||
let line = format!("{:<50} {:>14}", label, amount);
|
||||
ops.push(Op::ShowText {
|
||||
items: vec![TextItem::Text(line)],
|
||||
});
|
||||
ops.push(Op::AddLineBreak);
|
||||
}
|
||||
|
||||
if !totals.is_empty() {
|
||||
ops.push(Op::AddLineBreak);
|
||||
ops.push(Op::SetFont {
|
||||
font: PdfFontHandle::Builtin(BuiltinFont::HelveticaBold),
|
||||
size: Pt(11.0),
|
||||
});
|
||||
for (label, amount) in totals {
|
||||
let line = format!("{:<50} {:>14}", label, amount);
|
||||
ops.push(Op::ShowText {
|
||||
items: vec![TextItem::Text(line)],
|
||||
});
|
||||
ops.push(Op::AddLineBreak);
|
||||
}
|
||||
}
|
||||
|
||||
ops.push(Op::EndTextSection);
|
||||
ops
|
||||
}
|
||||
|
||||
fn save_pdf(title: &str, ops: Vec<Op>) -> Result<Vec<u8>, PdfError> {
|
||||
let mut doc = PdfDocument::new(title);
|
||||
let page = PdfPage::new(Mm(210.0), Mm(297.0), ops);
|
||||
let bytes = doc
|
||||
.with_pages(vec![page])
|
||||
.save(&PdfSaveOptions::default(), &mut Vec::new());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Render a profit-and-loss statement PDF: revenue and expense accounts with
|
||||
/// balances, then totals and net income.
|
||||
pub fn profit_and_loss_pdf(ledger: &Ledger, start: &str, end: &str) -> Result<Vec<u8>, PdfError> {
|
||||
let mut revenue_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut expense_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let balance = ledger
|
||||
.account_balance(account.id)
|
||||
.map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
if balance.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let label = format!("{} {}", account.code, account.name);
|
||||
match account.account_type {
|
||||
AccountType::Revenue => revenue_rows.push((label, money(balance))),
|
||||
AccountType::Expense => expense_rows.push((label, money(balance))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let pl =
|
||||
ledger_core::reports::profit_and_loss(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
|
||||
let mut rows = vec![("REVENUE".to_string(), "".to_string())];
|
||||
rows.extend(revenue_rows);
|
||||
rows.push(("".to_string(), "".to_string()));
|
||||
rows.push(("EXPENSES".to_string(), "".to_string()));
|
||||
rows.extend(expense_rows);
|
||||
|
||||
let totals = vec![
|
||||
("Total revenue".to_string(), money(pl.revenue)),
|
||||
("Total expenses".to_string(), money(pl.expenses)),
|
||||
("Net income".to_string(), money(pl.net_income())),
|
||||
];
|
||||
|
||||
let ops = text_ops(
|
||||
"Profit and Loss",
|
||||
&format!("{} to {}", start, end),
|
||||
&rows,
|
||||
&totals,
|
||||
);
|
||||
save_pdf("Profit and Loss", ops)
|
||||
}
|
||||
|
||||
/// Render a balance-sheet PDF: assets, liabilities, equity with balances,
|
||||
/// then totals proving the accounting equation.
|
||||
pub fn balance_sheet_pdf(ledger: &Ledger, as_of: &str) -> Result<Vec<u8>, PdfError> {
|
||||
let mut asset_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut liab_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut equity_rows: Vec<(String, String)> = Vec::new();
|
||||
let mut accounts: Vec<_> = ledger.chart().all().collect();
|
||||
accounts.sort_by(|a, b| a.code.cmp(&b.code));
|
||||
for account in accounts {
|
||||
if account.placeholder {
|
||||
continue;
|
||||
}
|
||||
let balance = ledger
|
||||
.account_balance(account.id)
|
||||
.map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
if balance.is_zero() {
|
||||
continue;
|
||||
}
|
||||
let label = format!("{} {}", account.code, account.name);
|
||||
match account.account_type {
|
||||
AccountType::Asset => asset_rows.push((label, money(balance))),
|
||||
AccountType::Liability => liab_rows.push((label, money(balance))),
|
||||
AccountType::Equity => equity_rows.push((label, money(balance))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let bs =
|
||||
ledger_core::reports::balance_sheet(ledger).map_err(|e| PdfError::Save(e.to_string()))?;
|
||||
|
||||
let mut rows = vec![("ASSETS".to_string(), "".to_string())];
|
||||
rows.extend(asset_rows);
|
||||
rows.push(("".to_string(), "".to_string()));
|
||||
rows.push(("LIABILITIES".to_string(), "".to_string()));
|
||||
rows.extend(liab_rows);
|
||||
rows.push(("".to_string(), "".to_string()));
|
||||
rows.push(("EQUITY".to_string(), "".to_string()));
|
||||
rows.extend(equity_rows);
|
||||
rows.push((
|
||||
"Current earnings (in equity)".to_string(),
|
||||
money(bs.current_earnings),
|
||||
));
|
||||
|
||||
let totals = vec![
|
||||
("Total assets".to_string(), money(bs.assets)),
|
||||
(
|
||||
"Total liabilities + equity".to_string(),
|
||||
money(bs.liabilities + bs.equity),
|
||||
),
|
||||
];
|
||||
|
||||
let ops = text_ops("Balance Sheet", &format!("As of {}", as_of), &rows, &totals);
|
||||
save_pdf("Balance Sheet", ops)
|
||||
}
|
||||
Reference in new issue
Block a user