diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index c71333d..b382d93 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -25,6 +25,7 @@ pub mod labels; pub mod pdf; pub mod report; pub mod templates; +pub mod workpaper; pub mod xlsx; pub use asset_register::{asset_register_pdf, asset_register_xlsx}; @@ -43,4 +44,5 @@ pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf}; pub use pdf::{PdfError, statement_pdf}; pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount}; pub use templates::{ChartTemplate, EntityType, chart_template}; +pub use workpaper::{Workpaper, WorkpaperCheck, WorkpaperFinding, workpaper_pdf}; pub use xlsx::{XlsxReport, report_to_xlsx}; diff --git a/crates/ledger-import/src/workpaper.rs b/crates/ledger-import/src/workpaper.rs new file mode 100644 index 0000000..ea472ac --- /dev/null +++ b/crates/ledger-import/src/workpaper.rs @@ -0,0 +1,145 @@ +//! The audit workpaper as a PDF (plan P8; rules.md B-45). +//! +//! ```text +//! GOAL: one document a CPA or the owner can read that says what was +//! checked, through which date and state of the books, and every +//! finding; the same audit gives the same bytes. +//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit +//! trail); CONVENTION (the statements' type and page). +//! INVARIANTS: +//! 1. Deterministic bytes: no clock, no random id; every text is data, +//! never markup. +//! 2. The findings appear in the order given, each with severity, code, +//! subject and message; the checks table lists every check the +//! programme runs, found or not. +//! ``` + +use crate::pdf::PdfError; +use serde::Serialize; +use std::sync::OnceLock; +use typst::foundations::{Dict, IntoValue}; +use typst_as_lib::{TypstEngine, TypstTemplateMainFile}; + +/// One finding as the page shows it. +#[derive(Clone, Debug, Serialize)] +pub struct WorkpaperFinding { + /// failure, warning or info. + pub severity: String, + /// The finding code. + pub code: String, + /// What it is about, in words. + pub subject: String, + /// What was found. + pub message: String, +} + +/// One check the programme runs. +#[derive(Clone, Debug, Serialize)] +pub struct WorkpaperCheck { + /// The finding code. + pub code: String, + /// failure, warning or info. + pub severity: String, + /// When it is reported. + pub predicate: String, +} + +/// Everything the page shows. +#[derive(Clone, Debug, Serialize)] +pub struct Workpaper { + /// The company's name. + pub company: String, + /// The page's title. + pub title: String, + /// The audit's scope. + pub scope: String, + /// The date it covers. + pub through: String, + /// The books' figure head. + pub through_seq: u64, + /// The programme's version. + pub program_version: String, + /// The trial balance's hash. + pub tb_hash: String, + /// The findings' hash. + pub findings_sha256: String, + /// One line: how many of each severity. + pub summary: String, + /// The findings. + pub findings: Vec, + /// The checks. + pub checks: Vec, +} + +static TEMPLATE: &str = include_str!("../templates/workpaper.typ"); + +fn engine() -> &'static TypstEngine { + static ENGINE: OnceLock> = OnceLock::new(); + ENGINE.get_or_init(|| { + TypstEngine::builder() + .main_file(TEMPLATE) + .fonts(crate::fonts::typst_fonts()) + .build() + }) +} + +/// Render the workpaper as PDF bytes. +pub fn workpaper_pdf(w: &Workpaper) -> Result, PdfError> { + let data = serde_json::to_string(w).map_err(|e| PdfError::Compile(e.to_string()))?; + let mut inputs = Dict::new(); + inputs.insert("data".into(), data.into_value()); + let doc = engine() + .compile_with_input(inputs) + .output + .map_err(|e| PdfError::Compile(format!("{e:?}")))?; + typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default()) + .map_err(|e| PdfError::Save(format!("{e:?}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Workpaper { + Workpaper { + company: "Wanderland Labs LLC".into(), + title: "Audit workpaper".into(), + scope: "month".into(), + through: "2026-10-31".into(), + through_seq: 412, + program_version: "audit-0.1.0-1".into(), + tb_hash: "ab".repeat(32), + findings_sha256: "cd".repeat(32), + summary: "1 failure, 0 warnings".into(), + findings: vec![WorkpaperFinding { + severity: "failure".into(), + code: "evidence_missing".into(), + subject: "entry 7".into(), + message: "entry 7 (2026-10-02) has no receipt: *not markup* #strong[x]".into(), + }], + checks: vec![WorkpaperCheck { + code: "evidence_missing".into(), + severity: "failure".into(), + predicate: "a posted entry the policy covers has no receipt".into(), + }], + } + } + + /// The same workpaper renders to the same bytes, a PDF. + #[test] + fn workpaper_pdf_is_deterministic() { + let a = workpaper_pdf(&sample()).unwrap(); + let b = workpaper_pdf(&sample()).unwrap(); + assert!(a.starts_with(b"%PDF")); + assert_eq!(a, b); + } + + /// A different finding is a different document. + #[test] + fn a_different_finding_is_a_different_document() { + let mut w = sample(); + let a = workpaper_pdf(&w).unwrap(); + w.findings.clear(); + assert_ne!(a, workpaper_pdf(&w).unwrap()); + } +} diff --git a/crates/ledger-import/templates/workpaper.typ b/crates/ledger-import/templates/workpaper.typ new file mode 100644 index 0000000..974371e --- /dev/null +++ b/crates/ledger-import/templates/workpaper.typ @@ -0,0 +1,77 @@ +// An audit workpaper: what was checked, through which date and which +// books' state, and every finding with its code and severity. +// +// Everything arrives as data in sys.inputs.data (JSON), built by +// ledger-import::workpaper. The template only typesets: no arithmetic, and +// no text is ever parsed as markup. It holds no clock: the same data gives +// the same bytes. +// +// Shape: { company, title, scope, through, through_seq, program_version, +// tb_hash, findings_sha256, summary: str, +// findings: [{ severity, code, subject, message }], +// checks: [{ code, severity, predicate }] } + +#let d = json(bytes(sys.inputs.data)) +#let ink = luma(25) +#let quiet = luma(110) +#let hair = 0.5pt + luma(160) + +#set document(title: d.company + " — " + d.title, author: d.company) +#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 9pt, fill: ink, number-width: "tabular", hyphenate: false) +#set page( + paper: "us-letter", + margin: (x: 0.8in, top: 0.8in, bottom: 0.8in), + footer: context { + set text(7.5pt, fill: quiet) + grid( + columns: (1fr, auto), + [#d.program_version · head #d.through_seq], + align(right)[Page #counter(page).display() of #counter(page).final().first()], + ) + }, +) + +#align(center)[ + #text(15pt, fill: luma(45), d.company) \ + #v(4pt) + #text(10.5pt, weight: "bold", d.title) \ + #v(2pt) + Through #d.through · scope #d.scope +] +#v(12pt) + +#grid( + columns: (auto, 1fr), + row-gutter: 5pt, + column-gutter: 12pt, + text(fill: quiet)[Books' figure head], [#d.through_seq], + text(fill: quiet)[Trial balance hash], text(size: 7.5pt)[#d.tb_hash], + text(fill: quiet)[Findings hash], text(size: 7.5pt)[#d.findings_sha256], + text(fill: quiet)[Result], [#d.summary], +) +#v(12pt) + +#text(10pt, weight: "bold")[Findings] +#v(4pt) +#if d.findings.len() == 0 [ + No findings. +] else [ + #table( + columns: (auto, auto, auto, 1fr), + stroke: (x: none, y: hair), + inset: (x: 4pt, y: 4pt), + table.header([*Severity*], [*Code*], [*About*], [*Finding*]), + ..d.findings.map(f => (f.severity, f.code, f.subject, f.message)).flatten(), + ) +] +#v(14pt) + +#text(10pt, weight: "bold")[What was checked] +#v(4pt) +#table( + columns: (auto, auto, 1fr), + stroke: (x: none, y: hair), + inset: (x: 4pt, y: 4pt), + table.header([*Code*], [*Severity*], [*True when it is reported*]), + ..d.checks.map(c => (c.code, c.severity, c.predicate)).flatten(), +)