feat: Typst-typeset statements in US accounting format
The printpdf statements padded columns with spaces in a proportional font (amounts didn't line up), had no company name, printed negatives as "-207.68", and put everything on one page (a long statement was silently cut off past ~70 lines). Now: - PDFs are typeset by Typst from one template, ledger-import/templates/statement.typ. The laid-out statement goes in as JSON data (sys.inputs), so account names are never parsed as markup. Fonts are bundled (typst-assets: Libertinus Serif), so output doesn't depend on the machine and is byte-for-byte deterministic. Statements paginate, with the company/title repeated on later pages and "Page n of m" plus "Prepared from journal through seq N" in the footer. - US accounting presentation, decided once in ledger-import::report and applied by both PDF and XLSX: tabular figures; "$" on each section's first amount and on every subtotal and total, pinned to the column's left edge; negatives in parentheses with the closing paren hanging (its width reserved on every row, so decimal points align); zero as a dash; a single rule above sums (none over an empty section) and a double rule under statement totals; amounts never bold, because bold figures are wider and would leave the decimal column. XLSX uses Excel's own Accounting formats for the same result. - Cash flow: "Net change in cash" is a subtotal; only "Cash at end of period" takes the double rule. - Core fix: cash_flow walked the chart in HashMap order, so the order of working-capital and financing lines could change between runs. Lines now follow account code. Test: five owners, twenty runs, always 3000/3001/3003/3005/3009; fails without the sort. New dependencies (ledger-import): typst, typst-layout, typst-pdf, typst-assets (fonts), typst-as-lib (World/font plumbing), serde_json (the template's data). Removed: printpdf. Release ledger-mcp grows by the bundled fonts. Tests: exact template data for a hand-built P&L (like a golden); same statement renders to identical bytes; 200 rows of Typst markup characters render literally and span several pages; currency-sign and rule placement; amount splitting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
1 parent
b5af21bb0e
commit
03212265ce
10 files changed
+2856
-545
No files matched your search
Generated
+2367
-418
File diff suppressed because it is too large.
Load diff
@@ -669,7 +669,11 @@ pub fn cash_flow(
|
|||||||
format!("{} {}", a.code, a.name)
|
format!("{} {}", a.code, a.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
for account in ledger.chart().all() {
|
// By code: the chart iterates in hash order, and a statement's lines
|
||||||
|
// must come out in the same order every time.
|
||||||
|
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 {
|
if account.placeholder {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,3 +221,47 @@ fn a_closed_period_still_reports_its_earnings() {
|
|||||||
assert_eq!(cf.cash_from_financing, dec(10_000));
|
assert_eq!(cf.cash_from_financing, dec(10_000));
|
||||||
assert_eq!(cf.net_change_in_cash, dec(11_700));
|
assert_eq!(cf.net_change_in_cash, dec(11_700));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cash flow lines come out in account-code order, every time. (The chart
|
||||||
|
/// iterates in hash order; the statement must not.)
|
||||||
|
#[test]
|
||||||
|
fn cash_flow_lines_are_in_code_order() {
|
||||||
|
for _ in 0..20 {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let cash = l
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let mut owners = Vec::new();
|
||||||
|
for code in ["3005", "3001", "3009", "3000", "3003"] {
|
||||||
|
owners.push(
|
||||||
|
l.add_account(code, code, AccountType::Equity, None, false)
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for o in &owners {
|
||||||
|
post(&mut l, d(3, 1), "Contribution", cash, *o, 100);
|
||||||
|
}
|
||||||
|
let cf = cash_flow(
|
||||||
|
&l,
|
||||||
|
&CashFlowParams {
|
||||||
|
start: d(1, 1),
|
||||||
|
end: d(12, 31),
|
||||||
|
cash_accounts: vec![cash],
|
||||||
|
financing_accounts: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Labels read "Owner contributions — 3000 3000" (code, then name).
|
||||||
|
let codes: Vec<&str> = cf
|
||||||
|
.financing
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.label.rsplit(' ').next().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
codes,
|
||||||
|
["3000", "3001", "3003", "3005", "3009"],
|
||||||
|
"{:?}",
|
||||||
|
cf.financing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,4 +12,9 @@ chrono = "0.4"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
rust_xlsxwriter = "0.90"
|
rust_xlsxwriter = "0.90"
|
||||||
printpdf = "0.12.8"
|
typst-as-lib = { version = "0.17", default-features = false }
|
||||||
|
typst-pdf = "0.15.1"
|
||||||
|
typst-assets = { version = "0.15.1", features = ["fonts"] }
|
||||||
|
typst = "0.15.1"
|
||||||
|
serde_json = "1"
|
||||||
|
typst-layout = "0.15.1"
|
||||||
+163
-102
@@ -1,123 +1,184 @@
|
|||||||
//! PDF financial statements for the CPA handoff package.
|
//! PDF financial statements, typeset with Typst.
|
||||||
//!
|
//!
|
||||||
//! Simple, correct, readable — not pretty. Uses printpdf (MIT) with built-in
|
//! [`crate::report`] lays a statement out as rows; this module hands those
|
||||||
//! Helvetica. Amounts are exact decimals rendered as strings; no floats.
|
//! rows to `templates/statement.typ` as JSON data (`sys.inputs.data`) and
|
||||||
|
//! renders the PDF. Amounts arrive already split by
|
||||||
|
//! [`crate::report::amount_parts`] — Typst never does arithmetic on money,
|
||||||
|
//! and labels are data, never markup. Fonts are bundled (`typst-assets`),
|
||||||
|
//! so output doesn't depend on what's installed, and it is deterministic:
|
||||||
|
//! the same statement renders to the same bytes.
|
||||||
|
|
||||||
use crate::report::{ReportMeta, RowKind, Statement, format_amount};
|
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||||||
use printpdf::{
|
use std::sync::OnceLock;
|
||||||
BuiltinFont, Color, Mm, Op, PdfDocument, PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt,
|
|
||||||
Rgb, TextItem,
|
|
||||||
};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
use typst::foundations::{Dict, IntoValue};
|
||||||
|
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
|
||||||
|
use typst_layout::PagedDocument;
|
||||||
|
|
||||||
/// PDF rendering errors.
|
/// PDF rendering errors.
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum PdfError {
|
pub enum PdfError {
|
||||||
/// printpdf failed to save.
|
/// Typst failed to compile the statement.
|
||||||
|
#[error("pdf typesetting failed: {0}")]
|
||||||
|
Compile(String),
|
||||||
|
/// The PDF writer failed.
|
||||||
#[error("pdf save failed: {0}")]
|
#[error("pdf save failed: {0}")]
|
||||||
Save(String),
|
Save(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn text_ops(
|
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||||||
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 {
|
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||||||
// Label left, amount right-ish via padding. Monospace would be
|
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||||
// nicer; Helvetica with padding is readable enough for a CPA file.
|
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
|
||||||
let line = format!("{:<50} {:>14}", label, amount);
|
ENGINE.get_or_init(|| {
|
||||||
ops.push(Op::ShowText {
|
TypstEngine::builder()
|
||||||
items: vec![TextItem::Text(line)],
|
.main_file(TEMPLATE)
|
||||||
});
|
.fonts(typst_assets::fonts())
|
||||||
ops.push(Op::AddLineBreak);
|
.build()
|
||||||
}
|
})
|
||||||
|
|
||||||
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> {
|
/// The template's input: the statement with every amount formatted.
|
||||||
let mut doc = PdfDocument::new(title);
|
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||||||
let page = PdfPage::new(Mm(210.0), Mm(297.0), ops);
|
let kind = |k: RowKind| match k {
|
||||||
let bytes = doc
|
RowKind::Section => "section",
|
||||||
.with_pages(vec![page])
|
RowKind::Line => "line",
|
||||||
.save(&PdfSaveOptions::default(), &mut Vec::new());
|
RowKind::Subtotal => "subtotal",
|
||||||
Ok(bytes)
|
RowKind::Total => "total",
|
||||||
|
RowKind::Spacer => "spacer",
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"company": meta.company,
|
||||||
|
"title": st.title,
|
||||||
|
"period": st.period,
|
||||||
|
"footer": format!("Prepared from journal through seq {}", meta.journal_seq),
|
||||||
|
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||||||
|
"kind": kind(r.kind),
|
||||||
|
"label": r.label,
|
||||||
|
"rule": r.rule_above,
|
||||||
|
"amount": r.amount.map(|a| {
|
||||||
|
let p = amount_parts(a);
|
||||||
|
serde_json::json!({
|
||||||
|
"digits": p.digits,
|
||||||
|
"negative": p.negative,
|
||||||
|
"zero": p.zero,
|
||||||
|
"currency": r.currency,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typeset a statement into a document (pages, before PDF encoding).
|
||||||
|
fn typeset(st: &Statement, meta: &ReportMeta) -> Result<PagedDocument, PdfError> {
|
||||||
|
let mut inputs = Dict::new();
|
||||||
|
inputs.insert(
|
||||||
|
"data".into(),
|
||||||
|
template_data(st, meta).to_string().into_value(),
|
||||||
|
);
|
||||||
|
engine()
|
||||||
|
.compile_with_input(inputs)
|
||||||
|
.output
|
||||||
|
.map_err(|e| PdfError::Compile(format!("{e:?}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
|
/// Render a laid-out statement (see [`crate::report`]) as a PDF.
|
||||||
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||||||
let mut rows = Vec::new();
|
let doc = typeset(st, meta)?;
|
||||||
let mut totals = Vec::new();
|
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
|
||||||
for r in &st.rows {
|
.map_err(|e| PdfError::Save(format!("{e:?}")))
|
||||||
let amount = r.amount.map(format_amount).unwrap_or_default();
|
}
|
||||||
match r.kind {
|
|
||||||
RowKind::Spacer => rows.push((String::new(), String::new())),
|
#[cfg(test)]
|
||||||
RowKind::Section => rows.push((r.label.to_uppercase(), String::new())),
|
mod tests {
|
||||||
RowKind::Line => rows.push((format!(" {}", r.label), amount)),
|
use super::*;
|
||||||
RowKind::Subtotal | RowKind::Total => rows.push((r.label.clone(), amount)),
|
use crate::report::{Row, profit_and_loss};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::reports::{ProfitAndLossStatement, StatementLine};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
fn meta() -> ReportMeta {
|
||||||
|
ReportMeta {
|
||||||
|
company: "Test Co".to_string(),
|
||||||
|
journal_seq: 42,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
totals.push((
|
|
||||||
format!("Prepared from journal through seq {}", meta.journal_seq),
|
fn pl() -> ProfitAndLossStatement {
|
||||||
String::new(),
|
let line = |code: &str, name: &str, cents| StatementLine {
|
||||||
));
|
code: code.to_string(),
|
||||||
let ops = text_ops(
|
name: name.to_string(),
|
||||||
&format!("{} — {}", meta.company, st.title),
|
amount: Decimal::new(cents, 2),
|
||||||
&st.period,
|
};
|
||||||
&rows,
|
ProfitAndLossStatement {
|
||||||
&totals,
|
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
|
||||||
);
|
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||||||
save_pdf(&st.title, ops)
|
revenue: vec![],
|
||||||
|
expenses: vec![
|
||||||
|
line("6000", "Organizational Costs", 5900),
|
||||||
|
line("6400", "Memberships & Licenses", 10638),
|
||||||
|
],
|
||||||
|
total_revenue: Decimal::ZERO,
|
||||||
|
total_expenses: Decimal::new(16538, 2),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The exact data the template typesets: hand-checked, like a golden.
|
||||||
|
#[test]
|
||||||
|
fn template_data_is_exact() {
|
||||||
|
let data = template_data(&profit_and_loss(&pl()), &meta());
|
||||||
|
assert_eq!(
|
||||||
|
data,
|
||||||
|
serde_json::json!({
|
||||||
|
"company": "Test Co",
|
||||||
|
"title": "Profit and Loss",
|
||||||
|
"period": "October 4 – October 11, 2026",
|
||||||
|
"footer": "Prepared from journal through seq 42",
|
||||||
|
"rows": [
|
||||||
|
{"kind": "section", "label": "Revenue", "rule": false, "amount": null},
|
||||||
|
{"kind": "subtotal", "label": "Total revenue", "rule": false, "amount":
|
||||||
|
{"digits": "", "negative": false, "zero": true, "currency": true}},
|
||||||
|
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||||
|
{"kind": "section", "label": "Expenses", "rule": false, "amount": null},
|
||||||
|
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "amount":
|
||||||
|
{"digits": "59.00", "negative": false, "zero": false, "currency": true}},
|
||||||
|
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "amount":
|
||||||
|
{"digits": "106.38", "negative": false, "zero": false, "currency": false}},
|
||||||
|
{"kind": "subtotal", "label": "Total expenses", "rule": true, "amount":
|
||||||
|
{"digits": "165.38", "negative": false, "zero": false, "currency": true}},
|
||||||
|
{"kind": "spacer", "label": "", "rule": false, "amount": null},
|
||||||
|
{"kind": "total", "label": "Net income", "rule": true, "amount":
|
||||||
|
{"digits": "165.38", "negative": true, "zero": false, "currency": true}},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_a_pdf_deterministically() {
|
||||||
|
let st = profit_and_loss(&pl());
|
||||||
|
let a = statement_pdf(&st, &meta()).unwrap();
|
||||||
|
assert!(a.starts_with(b"%PDF"));
|
||||||
|
assert_eq!(a, statement_pdf(&st, &meta()).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Labels are data: Typst markup characters print literally, and a
|
||||||
|
/// statement too long for one page flows onto more instead of being cut.
|
||||||
|
#[test]
|
||||||
|
fn markup_is_inert_and_long_statements_paginate() {
|
||||||
|
let mut st = profit_and_loss(&pl());
|
||||||
|
st.rows = (0..200)
|
||||||
|
.map(|i| Row {
|
||||||
|
kind: RowKind::Line,
|
||||||
|
label: format!("#{i} *not bold* $x$ <label> @ref [link] \\ #panic()"),
|
||||||
|
amount: Some(Decimal::new(i, 0)),
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let doc = typeset(&st, &meta()).unwrap();
|
||||||
|
assert!(doc.pages().len() > 1, "200 rows should span pages");
|
||||||
|
assert!(statement_pdf(&st, &meta()).unwrap().starts_with(b"%PDF"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,14 @@ pub struct Row {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
/// Amount, when the row has one.
|
/// Amount, when the row has one.
|
||||||
pub amount: Option<Decimal>,
|
pub amount: Option<Decimal>,
|
||||||
|
/// Print a currency sign with the amount: US accounting style puts "$"
|
||||||
|
/// on the first amount under each section heading and on every
|
||||||
|
/// subtotal and total.
|
||||||
|
pub currency: bool,
|
||||||
|
/// Draw a single rule above the amount: the "sum of the above" line.
|
||||||
|
/// Totals always have one; a subtotal only when amounts sit above it
|
||||||
|
/// (an empty section gets no rule over nothing).
|
||||||
|
pub rule_above: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A laid-out financial statement.
|
/// A laid-out financial statement.
|
||||||
@@ -64,6 +72,71 @@ fn row(kind: RowKind, label: impl Into<String>, amount: Option<Decimal>) -> Row
|
|||||||
kind,
|
kind,
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
amount,
|
amount,
|
||||||
|
currency: false,
|
||||||
|
rule_above: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish a statement: mark which amounts carry the currency sign and which
|
||||||
|
/// get a rule above.
|
||||||
|
fn statement(title: &str, period: String, mut rows: Vec<Row>) -> Statement {
|
||||||
|
let mut first_in_section = true;
|
||||||
|
let mut above: Option<RowKind> = None;
|
||||||
|
for r in &mut rows {
|
||||||
|
r.rule_above = match r.kind {
|
||||||
|
RowKind::Total => true,
|
||||||
|
RowKind::Subtotal => matches!(above, Some(RowKind::Line | RowKind::Subtotal)),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if r.kind != RowKind::Spacer {
|
||||||
|
above = Some(r.kind);
|
||||||
|
}
|
||||||
|
match r.kind {
|
||||||
|
RowKind::Section => first_in_section = true,
|
||||||
|
RowKind::Subtotal | RowKind::Total => r.currency = true,
|
||||||
|
RowKind::Line if r.amount.is_some() && first_in_section => {
|
||||||
|
r.currency = true;
|
||||||
|
first_in_section = false;
|
||||||
|
}
|
||||||
|
RowKind::Line | RowKind::Spacer => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Statement {
|
||||||
|
title: title.to_string(),
|
||||||
|
period,
|
||||||
|
rows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An amount split for column alignment: the digits, and whether it is a
|
||||||
|
/// negative (printed in parentheses) or zero (printed as a dash). Renderers
|
||||||
|
/// right-align `digits` and reserve the closing parenthesis's width on every
|
||||||
|
/// row, so decimal points line up whether or not a value is negative.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct AmountParts {
|
||||||
|
/// "1,234.56" — absolute value, grouped, two decimals. Empty for zero.
|
||||||
|
pub digits: String,
|
||||||
|
/// Below zero.
|
||||||
|
pub negative: bool,
|
||||||
|
/// Exactly zero (after rounding to cents).
|
||||||
|
pub zero: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split an amount for aligned accounting display. Exact: no floats.
|
||||||
|
pub fn amount_parts(d: Decimal) -> AmountParts {
|
||||||
|
let rounded = d.round_dp(2);
|
||||||
|
if rounded.is_zero() {
|
||||||
|
return AmountParts {
|
||||||
|
digits: String::new(),
|
||||||
|
negative: false,
|
||||||
|
zero: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let text = format_amount(rounded.abs());
|
||||||
|
AmountParts {
|
||||||
|
digits: text,
|
||||||
|
negative: rounded.is_sign_negative(),
|
||||||
|
zero: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +183,7 @@ pub fn profit_and_loss(pl: &ProfitAndLossStatement) -> Statement {
|
|||||||
));
|
));
|
||||||
rows.push(spacer());
|
rows.push(spacer());
|
||||||
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
rows.push(row(RowKind::Total, "Net income", Some(pl.net_income())));
|
||||||
Statement {
|
statement("Profit and Loss", period_label(pl.start, pl.end), rows)
|
||||||
title: "Profit and Loss".to_string(),
|
|
||||||
period: period_label(pl.start, pl.end),
|
|
||||||
rows,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lay out a balance sheet.
|
/// Lay out a balance sheet.
|
||||||
@@ -153,11 +222,11 @@ pub fn balance_sheet(bs: &BalanceSheetStatement) -> Statement {
|
|||||||
"Total liabilities and equity",
|
"Total liabilities and equity",
|
||||||
Some(bs.total_liabilities + bs.total_equity),
|
Some(bs.total_liabilities + bs.total_equity),
|
||||||
));
|
));
|
||||||
Statement {
|
statement(
|
||||||
title: "Balance Sheet".to_string(),
|
"Balance Sheet",
|
||||||
period: format!("As of {}", long_date(bs.as_of)),
|
format!("As of {}", long_date(bs.as_of)),
|
||||||
rows,
|
rows,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lay out a cash flow statement (indirect method).
|
/// Lay out a cash flow statement (indirect method).
|
||||||
@@ -195,7 +264,7 @@ pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
|||||||
));
|
));
|
||||||
rows.push(spacer());
|
rows.push(spacer());
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
RowKind::Total,
|
RowKind::Subtotal,
|
||||||
"Net change in cash",
|
"Net change in cash",
|
||||||
Some(cf.net_change_in_cash),
|
Some(cf.net_change_in_cash),
|
||||||
));
|
));
|
||||||
@@ -209,11 +278,11 @@ pub fn cash_flow(cf: &CashFlowStatement) -> Statement {
|
|||||||
"Cash at end of period",
|
"Cash at end of period",
|
||||||
Some(cf.cash_ending),
|
Some(cf.cash_ending),
|
||||||
));
|
));
|
||||||
Statement {
|
statement(
|
||||||
title: "Statement of Cash Flows".to_string(),
|
"Statement of Cash Flows",
|
||||||
period: period_label(cf.start, cf.end),
|
period_label(cf.start, cf.end),
|
||||||
rows,
|
rows,
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// "October 11, 2026".
|
/// "October 11, 2026".
|
||||||
@@ -272,6 +341,49 @@ mod tests {
|
|||||||
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
|
assert_eq!(format_amount(dec("-100000")), "(100,000.00)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn amounts_split_for_alignment() {
|
||||||
|
let p = amount_parts(dec("-1234.5"));
|
||||||
|
assert_eq!(
|
||||||
|
(p.digits.as_str(), p.negative, p.zero),
|
||||||
|
("1,234.50", true, false)
|
||||||
|
);
|
||||||
|
assert!(amount_parts(dec("0.004")).zero);
|
||||||
|
assert_eq!(amount_parts(dec("207.68")).digits, "207.68");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn currency_signs_go_on_first_lines_and_totals() {
|
||||||
|
let rows = vec![
|
||||||
|
section("Assets"),
|
||||||
|
row(RowKind::Line, "a", Some(dec("1"))),
|
||||||
|
row(RowKind::Line, "b", Some(dec("2"))),
|
||||||
|
row(RowKind::Subtotal, "t", Some(dec("3"))),
|
||||||
|
spacer(),
|
||||||
|
section("Equity"),
|
||||||
|
row(RowKind::Line, "c", Some(dec("3"))),
|
||||||
|
row(RowKind::Total, "T", Some(dec("3"))),
|
||||||
|
];
|
||||||
|
let st = statement("X", String::new(), rows);
|
||||||
|
let signs: Vec<bool> = st.rows.iter().map(|r| r.currency).collect();
|
||||||
|
assert_eq!(signs, [false, true, false, true, false, false, true, true]);
|
||||||
|
let rules: Vec<bool> = st.rows.iter().map(|r| r.rule_above).collect();
|
||||||
|
assert_eq!(
|
||||||
|
rules,
|
||||||
|
[false, false, false, true, false, false, false, true]
|
||||||
|
);
|
||||||
|
// A subtotal straight under its heading (empty section): no rule.
|
||||||
|
let empty = statement(
|
||||||
|
"Y",
|
||||||
|
String::new(),
|
||||||
|
vec![
|
||||||
|
section("Investing"),
|
||||||
|
row(RowKind::Subtotal, "t", Some(dec("0"))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(!empty.rows[1].rule_above);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn periods_read_like_english() {
|
fn periods_read_like_english() {
|
||||||
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
|
let d = |y, m, dd| NaiveDate::from_ymd_opt(y, m, dd).unwrap();
|
||||||
|
|||||||
@@ -40,7 +40,12 @@ impl From<rust_xlsxwriter::XlsxError> for XlsxError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const MONEY: &str = "#,##0.00;(#,##0.00)";
|
/// Excel's Accounting format, with and without the currency symbol: "$"
|
||||||
|
/// pinned left, digits right, negatives in parentheses with the closing
|
||||||
|
/// parenthesis hanging (`_)` reserves its width on positives, so decimal
|
||||||
|
/// points line up), zero as a dash.
|
||||||
|
const ACCOUNTING: &str = r#"_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)"#;
|
||||||
|
const ACCOUNTING_USD: &str = r#"_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)"#;
|
||||||
|
|
||||||
/// Company name, title, and period across the top; returns the next row.
|
/// Company name, title, and period across the top; returns the next row.
|
||||||
fn header(
|
fn header(
|
||||||
@@ -77,8 +82,11 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
|
|||||||
let mut workbook = Workbook::new();
|
let mut workbook = Workbook::new();
|
||||||
let sheet = workbook.add_worksheet();
|
let sheet = workbook.add_worksheet();
|
||||||
let bold = Format::new().set_bold();
|
let bold = Format::new().set_bold();
|
||||||
let money = Format::new().set_num_format(MONEY);
|
let money = Format::new().set_num_format(ACCOUNTING);
|
||||||
let money_bold = money.clone().set_bold();
|
let usd = Format::new().set_num_format(ACCOUNTING_USD);
|
||||||
|
// Totals are marked by rules, not bold: bold figures are wider and would
|
||||||
|
// break the decimal column.
|
||||||
|
let usd_bold = usd.clone();
|
||||||
|
|
||||||
match report {
|
match report {
|
||||||
XlsxReport::TrialBalance(tb) => {
|
XlsxReport::TrialBalance(tb) => {
|
||||||
@@ -95,7 +103,10 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
|
|||||||
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
sheet.write_number_with_format(row, 3, dec_to_f64(line.credit), &money)?;
|
||||||
row += 1;
|
row += 1;
|
||||||
}
|
}
|
||||||
let total = money_bold.clone().set_border_top(FormatBorder::Thin);
|
let total = usd_bold
|
||||||
|
.clone()
|
||||||
|
.set_border_top(FormatBorder::Thin)
|
||||||
|
.set_border_bottom(FormatBorder::Double);
|
||||||
sheet.write_string_with_format(row, 1, "Total", &bold)?;
|
sheet.write_string_with_format(row, 1, "Total", &bold)?;
|
||||||
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
|
sheet.write_number_with_format(row, 2, dec_to_f64(tb.total_debits), &total)?;
|
||||||
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
|
sheet.write_number_with_format(row, 3, dec_to_f64(tb.total_credits), &total)?;
|
||||||
@@ -108,8 +119,8 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
|
|||||||
sheet.set_name(&st.title)?;
|
sheet.set_name(&st.title)?;
|
||||||
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
let mut row = header(sheet, meta, &st.title, Some(&st.period))?;
|
||||||
let line_label = Format::new().set_indent(1);
|
let line_label = Format::new().set_indent(1);
|
||||||
let subtotal = money_bold.clone().set_border_top(FormatBorder::Thin);
|
let subtotal = usd_bold.clone().set_border_top(FormatBorder::Thin);
|
||||||
let total = money_bold
|
let total = usd_bold
|
||||||
.clone()
|
.clone()
|
||||||
.set_border_top(FormatBorder::Thin)
|
.set_border_top(FormatBorder::Thin)
|
||||||
.set_border_bottom(FormatBorder::Double);
|
.set_border_bottom(FormatBorder::Double);
|
||||||
@@ -123,13 +134,15 @@ pub fn report_to_xlsx(report: &XlsxReport<'_>, meta: &ReportMeta) -> Result<Vec<
|
|||||||
RowKind::Line => {
|
RowKind::Line => {
|
||||||
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
sheet.write_string_with_format(row, 0, &r.label, &line_label)?;
|
||||||
if let Some(a) = amount {
|
if let Some(a) = amount {
|
||||||
sheet.write_number_with_format(row, 1, a, &money)?;
|
let fmt = if r.currency { &usd } else { &money };
|
||||||
|
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RowKind::Subtotal => {
|
RowKind::Subtotal => {
|
||||||
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
sheet.write_string_with_format(row, 0, &r.label, &bold)?;
|
||||||
if let Some(a) = amount {
|
if let Some(a) = amount {
|
||||||
sheet.write_number_with_format(row, 1, a, &subtotal)?;
|
let fmt = if r.rule_above { &subtotal } else { &usd_bold };
|
||||||
|
sheet.write_number_with_format(row, 1, a, fmt)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RowKind::Total => {
|
RowKind::Total => {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// A financial statement: P&L, balance sheet, or cash flow.
|
||||||
|
//
|
||||||
|
// Everything arrives as data in sys.inputs.data (JSON), already laid out by
|
||||||
|
// ledger-import::report, with amounts already split into exact parts. The
|
||||||
|
// template only typesets: no arithmetic happens here, and no label is ever
|
||||||
|
// parsed as markup.
|
||||||
|
//
|
||||||
|
// Shape: { company, title, period, footer,
|
||||||
|
// rows: [{ kind: "section" | "line" | "subtotal" | "total"
|
||||||
|
// | "spacer", label, rule (single rule above),
|
||||||
|
// amount: null | { digits: "1,234.56", negative, zero,
|
||||||
|
// currency } }] }
|
||||||
|
//
|
||||||
|
// Amounts follow US accounting presentation: tabular figures, "$" pinned
|
||||||
|
// to the left of the amount column on the first line of each section and on
|
||||||
|
// totals, negatives in parentheses with the closing parenthesis hanging
|
||||||
|
// (its width is reserved on every row, so decimal points always line up),
|
||||||
|
// and zero as a dash in the ones position. Amounts are never bold: bold
|
||||||
|
// figures are wider, which would knock totals out of the decimal column —
|
||||||
|
// the rules mark subtotals and totals instead.
|
||||||
|
|
||||||
|
#let d = json(bytes(sys.inputs.data))
|
||||||
|
|
||||||
|
#set document(title: d.company + " — " + d.title, author: d.company)
|
||||||
|
#set text(
|
||||||
|
font: "Libertinus Serif",
|
||||||
|
size: 10.5pt,
|
||||||
|
number-type: "lining",
|
||||||
|
number-width: "tabular",
|
||||||
|
)
|
||||||
|
#set page(
|
||||||
|
paper: "us-letter",
|
||||||
|
margin: (x: 1in, top: 0.9in, bottom: 0.8in),
|
||||||
|
header: context {
|
||||||
|
if counter(page).get().first() > 1 {
|
||||||
|
set text(8.5pt, fill: luma(110))
|
||||||
|
[#d.company — #d.title #h(1fr) #d.period]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
footer: context {
|
||||||
|
set text(8pt, fill: luma(110))
|
||||||
|
[#d.footer #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
#align(center)[
|
||||||
|
#text(15pt, weight: "bold", d.company) \
|
||||||
|
#v(2pt)
|
||||||
|
#text(12.5pt, d.title) \
|
||||||
|
#v(1pt)
|
||||||
|
#text(10pt, style: "italic", d.period)
|
||||||
|
]
|
||||||
|
#v(16pt)
|
||||||
|
|
||||||
|
// Two lines under a statement total, the accounting double rule, set
|
||||||
|
// clear of the descenders.
|
||||||
|
#let double-rule(body) = stack(
|
||||||
|
body,
|
||||||
|
v(2.4pt),
|
||||||
|
line(length: 100%, stroke: 0.5pt),
|
||||||
|
v(1.4pt),
|
||||||
|
line(length: 100%, stroke: 0.5pt),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let amount(r) = {
|
||||||
|
let a = r.amount
|
||||||
|
if a == none { return [] }
|
||||||
|
let open = if a.negative { [(] } else { [] }
|
||||||
|
let close = if a.negative { [)] } else { hide[)] }
|
||||||
|
let body = if a.zero { [–#hide[.00]] } else { [#open#a.digits] }
|
||||||
|
grid(
|
||||||
|
columns: (auto, 1fr),
|
||||||
|
align: (left, right),
|
||||||
|
if a.currency [\$] else [],
|
||||||
|
[#body#close],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#table(
|
||||||
|
columns: (1fr, 9em),
|
||||||
|
stroke: none,
|
||||||
|
inset: (x: 0pt, y: 3.2pt),
|
||||||
|
align: (left + horizon, right + horizon),
|
||||||
|
..d
|
||||||
|
.rows
|
||||||
|
.map(r => {
|
||||||
|
if r.kind == "spacer" {
|
||||||
|
(table.cell(colspan: 2, v(3pt)),)
|
||||||
|
} else if r.kind == "section" {
|
||||||
|
(table.cell(colspan: 2, text(weight: "bold", r.label)),)
|
||||||
|
} else if r.kind == "line" {
|
||||||
|
(pad(left: 1.4em, r.label), amount(r))
|
||||||
|
} else if r.kind == "subtotal" {
|
||||||
|
(
|
||||||
|
text(weight: "bold", r.label),
|
||||||
|
table.cell(
|
||||||
|
stroke: if r.rule { (top: 0.5pt) } else { none },
|
||||||
|
amount(r),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
align(top, text(weight: "bold", r.label)),
|
||||||
|
table.cell(
|
||||||
|
stroke: (top: 0.5pt),
|
||||||
|
double-rule(amount(r)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flatten(),
|
||||||
|
)
|
||||||
@@ -913,7 +913,7 @@ fn mcp_period_statements_over_stdio() {
|
|||||||
"books.import_chart_csv",
|
"books.import_chart_csv",
|
||||||
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
|
json!({"csv_text": "code,name,type,parent_code\n1000,Cash,asset,\n3000,Capital,equity,\n4000,Sales,revenue,\n6000,Software,expense,\n"}),
|
||||||
);
|
);
|
||||||
let mut post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
|
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
|
||||||
let e = mcp.call(
|
let e = mcp.call(
|
||||||
"books.draft_entry",
|
"books.draft_entry",
|
||||||
json!({"date": date, "description": "x"}),
|
json!({"date": date, "description": "x"}),
|
||||||
|
|||||||
@@ -178,3 +178,14 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
|||||||
report headers, `fiscal_year_start` for fiscal YTD (end derived), and
|
report headers, `fiscal_year_start` for fiscal YTD (end derived), and
|
||||||
`receipt_threshold` (it was silently ignored before). Reports carry
|
`receipt_threshold` (it was silently ignored before). Reports carry
|
||||||
"Prepared from journal through seq N".
|
"Prepared from journal through seq N".
|
||||||
|
- 2026-10-11: PDFs are typeset with Typst (typst 0.15 via typst-as-lib,
|
||||||
|
fonts bundled by typst-assets; printpdf removed). One template,
|
||||||
|
`ledger-import/templates/statement.typ`, takes the laid-out statement as
|
||||||
|
JSON data (labels are never markup). US accounting presentation in PDF
|
||||||
|
and XLSX alike: tabular figures, "$" on each section's first amount and
|
||||||
|
on totals, negatives in parentheses with the closing paren hanging so
|
||||||
|
decimal points align, zero as a dash, single rule over sums, double rule
|
||||||
|
under totals, amounts never bold (bold figures are wider). Pages flow
|
||||||
|
with a repeated header and "Page n of m". Core's cash flow now orders
|
||||||
|
lines by account code (it followed HashMap order, so line order could
|
||||||
|
change between runs).
|
||||||
Reference in new issue
Block a user