Payroll accounting, class tracking, CPA handoff package (48 MCP tools)

This commit is contained in:
swaits committed 2026-10-11 15:32:18 +00:00
1 parent 0a90a72962
commit 09d1537225
1 file changed
+205
+205
View File
@@ -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)
}