Every report a CPA expects, laid out the way US small-business books read (QuickBooks as the reference): - Exports: books.export gains general_ledger (by account, running balances, splits), general_journal (the entry-ordered listing, which the CPA package used to mislabel "general ledger"; alias journal), members_equity, and trial_balance as of a date, in xlsx/csv/json, and PDF for all five statements (P&L, balance sheet, cash flow, trial balance, members' equity). books.trial_balance takes as_of. - CPA package: every statement as PDF and XLSX, plus the general ledger and general journal, all for the fiscal year to date. - Layout (ledger-import::report): multi-column statements (Total; Debit/Credit; one column per member plus Total), "$" on subtotals and totals, a rule above every sum (none over an empty section), a double rule under statement totals, zero as 0.00, negatives in parentheses with the closing paren hanging so decimal points align. - Terms: "Net income (loss)"; for a multi-member LLC, "Members' equity", member contributions and distributions named in the cash flow, and the balance sheet splitting retained earnings (the Retained Earnings account plus earnings not yet closed from earlier years) from the fiscal year's net income (loss); the cash flow's "Adjustments to reconcile net income (loss) to net cash from operating activities" and "Net increase (decrease) in cash". - PDF: Inter replaces the typst-assets serif fonts (bundled, SIL OFL 1.1, license in fonts/; from the official v4.1 release), centered title block, column-header row, full-width rules, footer "Accrual basis · Prepared <date> · Journal seq N" with page numbers. Amounts on total rows stay regular weight so bold figures can't leave the decimal column. typst-assets dependency removed. - XLSX: centered title block, header row, Excel accounting formats with "$" attached on sums, general ledger and general journal workbooks. - Config: wanderbooks.toml [[company.members]] (name, share "50%" or "0.5", capital_account; shares must total 100%) and [books] accounting_basis (accrual | cash). - Core: RETAINED_EARNINGS_CODE names the "3999" that period close uses. - Fix: "today" is local time, not UTC; in US evenings UTC is tomorrow, so reports defaulting to "through today" included the next day and footers carried the wrong date. Tests: layout (currency and rule placement, retained earnings vs. this year's net income for a member LLC, footer text); exact template data; deterministic PDFs; config parsing (members, shares, basis); stdio: trial balance as of, February general ledger (beginning 1,004.00, splits, ending 1,003.50), members' equity (contributions 1,000.00, net income 3.50, ending 1,003.50), members' equity PDF, the CPA package's ten files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
189 lines
7.4 KiB
Rust
189 lines
7.4 KiB
Rust
//! PDF financial statements, typeset with Typst.
|
||
//!
|
||
//! [`crate::report`] lays a statement out as rows; this module hands those
|
||
//! 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. The font (Inter, SIL OFL 1.1; see
|
||
//! `fonts/Inter-LICENSE.txt`) is bundled, so output doesn't depend on
|
||
//! what's installed, and it is deterministic: the same statement and
|
||
//! report metadata render to the same bytes.
|
||
|
||
use crate::report::{ReportMeta, RowKind, Statement, amount_parts};
|
||
use std::sync::OnceLock;
|
||
use thiserror::Error;
|
||
use typst::foundations::{Dict, IntoValue};
|
||
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
|
||
use typst_layout::PagedDocument;
|
||
|
||
/// PDF rendering errors.
|
||
#[derive(Debug, Error)]
|
||
pub enum PdfError {
|
||
/// Typst failed to compile the statement.
|
||
#[error("pdf typesetting failed: {0}")]
|
||
Compile(String),
|
||
/// The PDF writer failed.
|
||
#[error("pdf save failed: {0}")]
|
||
Save(String),
|
||
}
|
||
|
||
static TEMPLATE: &str = include_str!("../templates/statement.typ");
|
||
static INTER: [&[u8]; 3] = [
|
||
include_bytes!("../fonts/Inter-Regular.ttf"),
|
||
include_bytes!("../fonts/Inter-SemiBold.ttf"),
|
||
include_bytes!("../fonts/Inter-Bold.ttf"),
|
||
];
|
||
|
||
/// The engine, built once: parsing the bundled fonts is the expensive part.
|
||
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
|
||
ENGINE.get_or_init(|| {
|
||
TypstEngine::builder()
|
||
.main_file(TEMPLATE)
|
||
.fonts(INTER)
|
||
.build()
|
||
})
|
||
}
|
||
|
||
/// The template's input: the statement with every amount split for
|
||
/// alignment.
|
||
pub fn template_data(st: &Statement, meta: &ReportMeta) -> serde_json::Value {
|
||
let kind = |k: RowKind| match k {
|
||
RowKind::Section => "section",
|
||
RowKind::Subheading => "subheading",
|
||
RowKind::Line => "line",
|
||
RowKind::Subtotal => "subtotal",
|
||
RowKind::Total => "total",
|
||
RowKind::Spacer => "spacer",
|
||
};
|
||
serde_json::json!({
|
||
"company": meta.company,
|
||
"title": st.title,
|
||
"period": st.period,
|
||
"footer": meta.footer(),
|
||
"columns": st.columns,
|
||
"rows": st.rows.iter().map(|r| serde_json::json!({
|
||
"kind": kind(r.kind),
|
||
"label": r.label,
|
||
"rule": r.rule_above,
|
||
"currency": r.currency,
|
||
"amounts": r.amounts.iter().map(|a| a.map(|a| {
|
||
let p = amount_parts(a);
|
||
serde_json::json!({ "digits": p.digits, "negative": p.negative })
|
||
})).collect::<Vec<_>>(),
|
||
})).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.
|
||
pub fn statement_pdf(st: &Statement, meta: &ReportMeta) -> Result<Vec<u8>, PdfError> {
|
||
let doc = typeset(st, meta)?;
|
||
typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default())
|
||
.map_err(|e| PdfError::Save(format!("{e:?}")))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
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(),
|
||
basis: "Accrual".to_string(),
|
||
prepared_on: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||
journal_seq: 42,
|
||
}
|
||
}
|
||
|
||
fn pl() -> ProfitAndLossStatement {
|
||
let line = |code: &str, name: &str, cents| StatementLine {
|
||
code: code.to_string(),
|
||
name: name.to_string(),
|
||
amount: Decimal::new(cents, 2),
|
||
};
|
||
ProfitAndLossStatement {
|
||
start: NaiveDate::from_ymd_opt(2026, 10, 4).unwrap(),
|
||
end: NaiveDate::from_ymd_opt(2026, 10, 11).unwrap(),
|
||
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 amt = |digits: &str, negative: bool| serde_json::json!([{"digits": digits, "negative": negative}]);
|
||
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": "Accrual basis · Prepared Sunday, October 11, 2026 · Journal seq 42",
|
||
"columns": ["Total"],
|
||
"rows": [
|
||
{"kind": "section", "label": "Revenue", "rule": false, "currency": false, "amounts": []},
|
||
{"kind": "subtotal", "label": "Total revenue", "rule": false, "currency": true, "amounts": amt("0.00", false)},
|
||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||
{"kind": "section", "label": "Expenses", "rule": false, "currency": false, "amounts": []},
|
||
{"kind": "line", "label": "6000 Organizational Costs", "rule": false, "currency": false, "amounts": amt("59.00", false)},
|
||
{"kind": "line", "label": "6400 Memberships & Licenses", "rule": false, "currency": false, "amounts": amt("106.38", false)},
|
||
{"kind": "subtotal", "label": "Total expenses", "rule": true, "currency": true, "amounts": amt("165.38", false)},
|
||
{"kind": "spacer", "label": "", "rule": false, "currency": false, "amounts": []},
|
||
{"kind": "total", "label": "Net income (loss)", "rule": true, "currency": true, "amounts": amt("165.38", 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()"),
|
||
amounts: vec![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"));
|
||
}
|
||
}
|