From d45e842873b8eb57d7e4ed9a30c00a5b08d44bd0 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 15:38:53 -0600 Subject: [PATCH] feat(workpaper): the audit workpaper as a deterministic PDF (P8) One page set a CPA can read: what was checked, through which day and state of the books, every finding with its code and severity. No clock and no random id, so the same findings give the same bytes. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-import/src/lib.rs | 2 + crates/ledger-import/src/workpaper.rs | 145 +++++++++++++++++++ crates/ledger-import/templates/workpaper.typ | 77 ++++++++++ 3 files changed, 224 insertions(+) create mode 100644 crates/ledger-import/src/workpaper.rs create mode 100644 crates/ledger-import/templates/workpaper.typ 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(), +)