From 843bcc421f60ea0a133dac2889be94d16c7fe303 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 14:49:45 -0600 Subject: [PATCH] feat(audit): the statements checks, the bar and the audit settings (P8) An audit is the books read as an auditor reads them, through a day. The pure checks over a ledger are one function per finding code in ledger_core::audit: the trial balance balances, assets equal liabilities plus equity, the P&L articulates to equity (net income since the first entry is current earnings plus what the closes moved), the cash flow foots, the control accounts tie to their documents, a members close stays within a minor unit of each member's exact share, schedules and depreciation are posted, the register holds the cost accounts' cost, every posted entry meets evidence::requirement (a reversal resting on its original's receipt is listed as a note, not a failure), no draft is open, every revenue and expense account has a tax category, and the 1099 and settings problems the caller reads are findings. A check that cannot be made is itself a finding. Findings come in one fixed order, so the same books give the same list. The caller hands in what a ledger cannot know: the receipts filed, the cash flow's footing, the settings problems. The attestation bar (D-9) is a value, not a table: reasoning effort high, two verifier agents and an adversarial check, with counts() deciding whether a claim meets it. Every claim needs the effort; one on a year-level audit needs the whole bar; a reading that disagrees with the books never counts. policy.attestation_bar (a new Kind::AttestationBar, one canonical text) and policy.cpa_package_requires_audit are the two settings; both wait for the journal feature audits. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-core/src/audit.rs | 871 +++++++++++++++++++++++++++++ crates/ledger-core/src/lib.rs | 1 + crates/ledger-core/src/settings.rs | 39 ++ crates/ledger-core/tests/audit.rs | 439 +++++++++++++++ 4 files changed, 1350 insertions(+) create mode 100644 crates/ledger-core/src/audit.rs create mode 100644 crates/ledger-core/tests/audit.rs diff --git a/crates/ledger-core/src/audit.rs b/crates/ledger-core/src/audit.rs new file mode 100644 index 0000000..460c80d --- /dev/null +++ b/crates/ledger-core/src/audit.rs @@ -0,0 +1,871 @@ +//! The audit's statements checks (plan P8; rules.md B-45): the books, +//! read as an auditor would, through a date, as a list of findings. +//! +//! ```text +//! GOAL: one deterministic list of what is wrong or unfinished in the +//! books through a date, from the ledger alone, so an audit run is +//! reproducible and its workpaper hashes the same twice. +//! SOURCE: IRS Pub. 583 (records that support entries); Rev. Proc. 98-25 +//! ยง5.01 (the relationship between the records and the books must +//! be demonstrable); docs/spec/rules.md B-6 to B-14 (the +//! statements), B-38 (control accounts), B-43 (evidence); +//! DECISION D-9 (the audit and its bar), D-53 (a run, not a job). +//! INVARIANTS: +//! 1. Pure: reads the ledger and the facts [`AuditInput`] hands in, +//! changes nothing, reads no clock: the same books and input give +//! the same findings in the same order. +//! 2. One function per check, one finding code per check ([`CODES`]); +//! a check that finds nothing says nothing. +//! 3. Only entries dated on or before `through` count, drafts and +//! posted alike. +//! 4. A check that cannot be made is itself a finding, never a silent +//! pass (`cash_flow_unfooted` for a statement the engine refuses). +//! SCENARIOS: +//! 1. IF the books are whole: SHOULD give no Failure or Warning. +//! 2. IF a draft is dated on or before `through`: SHOULD give exactly +//! `draft_open`. +//! ``` + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use rust_decimal::Decimal; +use serde::Serialize; + +use crate::evidence::{Policy, Requirement}; +use crate::settings::Settings; +use crate::{AccountType, DocStatus, EntryId, EntryStatus, Ledger, PeriodStatus, Side}; + +/// How much a finding matters. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + /// The books are wrong or cannot be shown right: an audit with one is + /// not clean. + Failure, + /// Unfinished work or a policy not met. + Warning, + /// Listed so the workpaper is whole; not a defect. + Info, +} + +impl Severity { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + Severity::Failure => "failure", + Severity::Warning => "warning", + Severity::Info => "info", + } + } +} + +/// What a finding is about. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum Subject { + /// The books as a whole. + Books, + /// An account. + Account(u64), + /// An entry. + Entry(u64), + /// An invoice. + Invoice(u64), + /// A bill. + Bill(u64), + /// An accounting period. + Period(u64), + /// An amortization schedule. + Schedule(u64), + /// A filed attachment. + Attachment(u64), + /// A vendor, by name. + Vendor(String), + /// A stored file or the access log, by name. + Named(String), +} + +impl Subject { + /// In words: `entry 7`, `books`. + pub fn describe(&self) -> String { + match self { + Subject::Books => "books".into(), + Subject::Account(i) => format!("account {i}"), + Subject::Entry(i) => format!("entry {i}"), + Subject::Invoice(i) => format!("invoice {i}"), + Subject::Bill(i) => format!("bill {i}"), + Subject::Period(i) => format!("period {i}"), + Subject::Schedule(i) => format!("schedule {i}"), + Subject::Attachment(i) => format!("attachment {i}"), + Subject::Vendor(n) => format!("vendor {n}"), + Subject::Named(n) => n.clone(), + } + } +} + +/// One thing an audit found. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Finding { + /// Its stable code ([`CODES`]). + pub code: &'static str, + /// How much it matters. + pub severity: Severity, + /// What it is about. + pub subject: Subject, + /// In words, with the exact figures. + pub message: String, +} + +impl Finding { + /// A finding of the severity its code's row says. + pub fn new(code: &'static str, subject: Subject, message: impl Into) -> Finding { + let severity = CODES + .iter() + .find(|r| r.code == code) + .map_or(Severity::Failure, |r| r.severity); + Finding { + code, + severity, + subject, + message: message.into(), + } + } +} + +/// One row of the code table: a check's finding code, how much it matters +/// and the predicate it states. +#[derive(Clone, Copy, Debug)] +pub struct CodeRow { + /// The finding code. + pub code: &'static str, + /// Its severity. + pub severity: Severity, + /// The predicate: what is true when the code is reported. + pub predicate: &'static str, +} + +/// Every statements check, by code (the integrity checks of the store have +/// their own table, `ledger_store::audit::CODES`). `audit_detects_each_ +/// seeded_defect` seeds each row. +pub const CODES: &[CodeRow] = &[ + CodeRow { + code: "trial_balance_unbalanced", + severity: Severity::Failure, + predicate: "total debits differ from total credits in the trial balance through the date", + }, + CodeRow { + code: "equation_broken", + severity: Severity::Failure, + predicate: "assets differ from liabilities plus equity (current earnings included)", + }, + CodeRow { + code: "pnl_not_articulated", + severity: Severity::Failure, + predicate: "net income since the first entry differs from current earnings plus what closes moved to equity", + }, + CodeRow { + code: "cash_flow_unfooted", + severity: Severity::Failure, + predicate: "the statement of cash flows cannot be made, or its sections do not sum to the cash accounts' change", + }, + CodeRow { + code: "control_difference", + severity: Severity::Failure, + predicate: "a control account's balance differs from its documents plus its unattached lines", + }, + CodeRow { + code: "unattached_control_line", + severity: Severity::Warning, + predicate: "a line on a control account carries no document", + }, + CodeRow { + code: "document_overpaid", + severity: Severity::Warning, + predicate: "a document has more paid against it than it is for", + }, + CodeRow { + code: "close_split_drifts", + severity: Severity::Failure, + predicate: "a member's running share of the closes is more than a minor unit from its exact share", + }, + CodeRow { + code: "schedule_behind", + severity: Severity::Warning, + predicate: "an amortization schedule has a month due through the date that is not posted", + }, + CodeRow { + code: "depreciation_behind", + severity: Severity::Warning, + predicate: "the asset schedules say more depreciation is due than the books carry", + }, + CodeRow { + code: "register_differs", + severity: Severity::Failure, + predicate: "the fixed asset register's cost differs from the cost accounts' balances", + }, + CodeRow { + code: "evidence_missing", + severity: Severity::Failure, + predicate: "a posted entry the receipt policy covers has no receipt", + }, + CodeRow { + code: "legacy_reversal_exempt", + severity: Severity::Info, + predicate: "a reversal the policy would cover has no receipt of its own; its original's evidence stands for it (D-47)", + }, + CodeRow { + code: "draft_open", + severity: Severity::Warning, + predicate: "a draft is dated on or before the date", + }, + CodeRow { + code: "tax_category_missing", + severity: Severity::Warning, + predicate: "a revenue or expense account has no tax category", + }, + CodeRow { + code: "vendor_1099_undecided", + severity: Severity::Warning, + predicate: "a vendor paid in a way that could need a 1099 has no decision", + }, + CodeRow { + code: "settings_problem", + severity: Severity::Warning, + predicate: "the settings name an account or role the chart cannot honor", + }, +]; + +/// What the ledger cannot know by itself and the caller reads from the +/// books around it. +#[derive(Clone, Copy, Debug)] +pub struct AuditInput<'a> { + /// The books' settings (the receipt policy). + pub settings: &'a Settings, + /// The entries that have at least one receipt filed against them, not + /// withdrawn. + pub receipts: &'a BTreeSet, + /// The statement of cash flows tried from the first entry through the + /// date with the books' role settings: its footing, or why it could + /// not be made; `None` when the settings name no cash accounts or + /// there is nothing to state. + pub cash_flow: Option<&'a Result>, + /// What the settings check already says is wrong, in words. + pub settings_problems: &'a [String], + /// Vendors paid in a way that could need a 1099 with no decision, and + /// what they were paid. + pub undecided_1099: &'a [(String, Decimal)], +} + +/// All the statements checks through `through`, in a fixed order: +/// failures first, then by code and subject. +pub fn statements(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec { + let mut out = Vec::new(); + out.extend(trial_balance(ledger, through)); + out.extend(equation(ledger, through)); + out.extend(articulation(ledger, through)); + out.extend(check_cash_flow(input.cash_flow)); + out.extend(control(ledger, through)); + out.extend(closes(ledger, through)); + out.extend(schedules(ledger, through)); + out.extend(assets(ledger, through)); + out.extend(evidence(ledger, input, through)); + out.extend(drafts(ledger, through)); + out.extend(tax_categories(ledger)); + out.extend(vendors_1099(input.undecided_1099)); + out.extend(settings_problems(input.settings_problems)); + sort(&mut out); + out +} + +/// Findings in their reporting order. +pub fn sort(findings: &mut [Finding]) { + findings.sort_by(|a, b| { + (a.severity, a.code, &a.subject, &a.message) + .cmp(&(b.severity, b.code, &b.subject, &b.message)) + }); +} + +fn trial_balance(ledger: &Ledger, through: NaiveDate) -> Option { + let tb = crate::reports::trial_balance_as_of(ledger, through); + check_trial_balance(&tb) +} + +/// The trial balance check on a built trial balance. +pub fn check_trial_balance(tb: &crate::reports::TrialBalance) -> Option { + (!tb.balanced()).then(|| { + Finding::new( + "trial_balance_unbalanced", + Subject::Books, + format!( + "debits {} differ from credits {}", + tb.total_debits, tb.total_credits + ), + ) + }) +} + +fn equation(ledger: &Ledger, through: NaiveDate) -> Option { + match crate::reports::balance_sheet_as_of(ledger, through) { + Ok(bs) => check_equation(&bs), + Err(e) => Some(Finding::new( + "equation_broken", + Subject::Books, + format!("the balance sheet cannot be made: {e}"), + )), + } +} + +/// The accounting equation on a built balance sheet. +pub fn check_equation(bs: &crate::reports::BalanceSheetStatement) -> Option { + (!bs.balanced()).then(|| { + Finding::new( + "equation_broken", + Subject::Books, + format!( + "assets {} differ from liabilities {} plus equity {}", + bs.total_assets, bs.total_liabilities, bs.total_equity + ), + ) + }) +} + +/// Net income moved to equity by the closes dated through `through`: +/// revenue closed less expenses closed, read off the closing entries' +/// lines. +fn closed_net(ledger: &Ledger, through: NaiveDate) -> Decimal { + let mut net = Decimal::ZERO; + for id in ledger.closing_entry_ids() { + let Some(e) = ledger.get_entry(id) else { + continue; + }; + if e.status() != EntryStatus::Posted || e.date() > through { + continue; + } + for l in e.lines() { + let Some(a) = ledger.chart().get(l.account) else { + continue; + }; + let signed = match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }; + if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) { + net += signed; + } + } + } + net +} + +fn articulation(ledger: &Ledger, through: NaiveDate) -> Option { + let first = ledger + .posted_entries() + .filter(|e| e.date() <= through) + .map(|e| e.date()) + .min()?; + let pnl = crate::reports::profit_and_loss_for_period(ledger, first, through, None).ok()?; + let bs = crate::reports::balance_sheet_as_of(ledger, through).ok()?; + check_articulation( + pnl.net_income(), + bs.current_earnings, + closed_net(ledger, through), + ) +} + +/// The P&L ties to equity: net income since the first entry is current +/// earnings plus what the closes moved into equity. +pub fn check_articulation( + net_income: Decimal, + current_earnings: Decimal, + closed: Decimal, +) -> Option { + (net_income != current_earnings + closed).then(|| { + Finding::new( + "pnl_not_articulated", + Subject::Books, + format!( + "net income {net_income} differs from current earnings {current_earnings} plus closed {closed}" + ), + ) + }) +} + +/// The statement of cash flows, tried by the caller from the books' role +/// settings: its footing, or why it could not be made. +pub fn check_cash_flow( + made: Option<&Result>, +) -> Option { + match made? { + Ok(f) if f.difference.is_zero() => None, + Ok(f) => Some(Finding::new( + "cash_flow_unfooted", + Subject::Books, + format!( + "the sections sum to {} but the cash accounts changed by {}", + f.sections_sum, f.net_change + ), + )), + Err(e) => Some(Finding::new( + "cash_flow_unfooted", + Subject::Books, + format!("the statement of cash flows cannot be made: {e}"), + )), + } +} + +fn control(ledger: &Ledger, through: NaiveDate) -> Vec { + match ledger.control_check(through) { + Ok(checks) => check_control(&checks), + Err(e) => vec![Finding::new( + "control_difference", + Subject::Books, + format!("the control check cannot be made: {e}"), + )], + } +} + +/// The control accounts' tie-out, as the engine read it. +pub fn check_control(checks: &[crate::arap::ControlCheck]) -> Vec { + let mut out = Vec::new(); + for c in checks { + if !c.difference.is_zero() { + out.push(Finding::new( + "control_difference", + Subject::Account(c.account), + format!( + "the {} account's balance {} differs from its documents {} by {}", + c.role, c.balance, c.documents_total, c.difference + ), + )); + } + for u in &c.unattached { + out.push(Finding::new( + "unattached_control_line", + Subject::Entry(u.entry), + format!( + "line {} of entry {} ({}), {} on the {} account, carries no document", + u.line, u.entry, u.date, u.amount, c.role + ), + )); + } + for a in c + .attention + .iter() + .filter(|a| a.status == DocStatus::Overpaid) + { + let subject = match a.doc { + crate::DocRef::Invoice(i) => Subject::Invoice(i), + crate::DocRef::Bill(b) => Subject::Bill(b), + }; + out.push(Finding::new( + "document_overpaid", + subject, + format!("{} is overpaid: its total is {}", a.doc, a.total), + )); + } + } + out +} + +fn closes(ledger: &Ledger, through: NaiveDate) -> Vec { + let unit = Decimal::new(1, ledger.base_currency().minor_units().unwrap_or(2)); + let mut out = Vec::new(); + for p in ledger.list_periods() { + if p.status != PeriodStatus::Closed || p.end > through { + continue; + } + if let Some((_, split)) = ledger.member_close_split(p.id) { + out.extend(check_split(p.id, &p.name, &split, unit)); + } + } + out +} + +/// One close's members: each one's running total of the closes must be +/// within a minor unit of its exact share (the cumulative rule, +/// `kernel::apportion_cumulative`). +pub fn check_split( + period: u64, + name: &str, + split: &[crate::periods::MemberSplit], + unit: Decimal, +) -> Vec { + split + .iter() + .filter(|m| (m.running - m.exact).abs() > unit) + .map(|m| { + Finding::new( + "close_split_drifts", + Subject::Period(period), + format!( + "after the close of {name}, account {} holds {} of the closes where its exact share is {}", + m.capital, m.running, m.exact + ), + ) + }) + .collect() +} + +fn schedules(ledger: &Ledger, through: NaiveDate) -> Vec { + let mut out = Vec::new(); + for s in ledger.schedules() { + let months = match s.due(through) { + Ok(m) => m, + Err(e) => { + out.push(Finding::new( + "schedule_behind", + Subject::Schedule(s.id), + format!("\"{}\" cannot be planned: {e}", s.description), + )); + continue; + } + }; + if let Some((first, _)) = months.first() { + let total: Decimal = months.iter().map(|(_, a)| *a).sum(); + out.push(Finding::new( + "schedule_behind", + Subject::Schedule(s.id), + format!( + "\"{}\" has {} month(s) due and not posted, from {first}, {total} in all", + s.description, + months.len() + ), + )); + } + } + out +} + +fn assets(ledger: &Ledger, through: NaiveDate) -> Vec { + match crate::reports::fixed_asset_register(ledger, through) { + Ok(reg) => check_register(®), + Err(e) => vec![Finding::new( + "register_differs", + Subject::Books, + format!("the fixed asset register cannot be made: {e}"), + )], + } +} + +/// The register against the books' cost and accumulated depreciation. +pub fn check_register(reg: &crate::reports::AssetRegister) -> Vec { + let mut out = Vec::new(); + if reg.unposted_depreciation() > Decimal::ZERO { + out.push(Finding::new( + "depreciation_behind", + Subject::Books, + format!( + "the schedules say {} of depreciation is due and the books carry {}", + reg.total_accumulated_scheduled, reg.ledger_accumulated + ), + )); + } + if !reg.unregistered_cost().is_zero() { + out.push(Finding::new( + "register_differs", + Subject::Books, + format!( + "the register holds {} of cost and the cost accounts {}", + reg.total_cost, reg.ledger_cost + ), + )); + } + out +} + +fn evidence(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec { + let policy = Policy::from_settings(input.settings); + let mut out = Vec::new(); + for e in ledger.posted_entries().filter(|e| e.date() <= through) { + if input.receipts.contains(&e.id()) { + continue; + } + match crate::evidence::requirement(ledger, e, input.settings) { + Requirement::Required { reason } => out.push(Finding::new( + "evidence_missing", + Subject::Entry(e.id()), + format!("entry {} ({}) has no receipt: {reason}", e.id(), e.date()), + )), + Requirement::Exempt { .. } if e.reversal_of().is_some() => { + if policy.receipt_required(ledger, e) { + out.push(Finding::new( + "legacy_reversal_exempt", + Subject::Entry(e.id()), + format!( + "entry {} reverses entry {}: its evidence is the original's", + e.id(), + e.reversal_of().unwrap_or_default() + ), + )); + } + } + Requirement::Exempt { .. } => {} + } + } + out +} + +fn drafts(ledger: &Ledger, through: NaiveDate) -> Vec { + ledger + .drafts() + .into_iter() + .filter(|e| e.date() <= through) + .map(|e| { + Finding::new( + "draft_open", + Subject::Entry(e.id()), + format!( + "draft {} ({}) \"{}\" is not posted", + e.id(), + e.date(), + e.description() + ), + ) + }) + .collect() +} + +fn tax_categories(ledger: &Ledger) -> Vec { + crate::tax::uncategorized_accounts(ledger) + .into_iter() + .map(|a| { + Finding::new( + "tax_category_missing", + Subject::Account(a.id), + format!("account {} {} has no tax category", a.code, a.name), + ) + }) + .collect() +} + +fn vendors_1099(undecided: &[(String, Decimal)]) -> Vec { + undecided + .iter() + .map(|(name, paid)| { + Finding::new( + "vendor_1099_undecided", + Subject::Vendor(name.clone()), + format!( + "{name} was paid {paid} in a way that could need a 1099 and has no decision" + ), + ) + }) + .collect() +} + +fn settings_problems(problems: &[String]) -> Vec { + problems + .iter() + .map(|p| Finding::new("settings_problem", Subject::Books, p.clone())) + .collect() +} + +// --------------------------------------------------------------------------- +// Scope, method and the attestation bar (rules.md B-45; D-9) +// --------------------------------------------------------------------------- + +/// What an audit covers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Scope { + /// A month's books, for its month-end attestation. + Month, + /// A fiscal year. + Annual, + /// The year, for the CPA handoff. + CpaHandoff, + /// Everything since the first entry. + Full, +} + +impl Scope { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + Scope::Month => "month", + Scope::Annual => "annual", + Scope::CpaHandoff => "cpa_handoff", + Scope::Full => "full", + } + } + + /// The scope a word names. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "month" => Some(Scope::Month), + "annual" => Some(Scope::Annual), + "cpa_handoff" => Some(Scope::CpaHandoff), + "full" => Some(Scope::Full), + _ => None, + } + } + + /// Whether a period attested on this audit is held to the whole bar + /// (adversarial, several verifiers) and not the effort alone (D-9). + pub fn full_bar(self) -> bool { + !matches!(self, Scope::Month) + } + + /// Whether an audit of this scope is one the CPA package may rest on. + pub fn serves_handoff(self) -> bool { + matches!(self, Scope::CpaHandoff | Scope::Full) + } +} + +/// How hard the checker thought, as the client reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Effort { + /// low + Low, + /// medium + Medium, + /// high + High, + /// xhigh + Xhigh, + /// max + Max, +} + +impl Effort { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + Effort::Low => "low", + Effort::Medium => "medium", + Effort::High => "high", + Effort::Xhigh => "xhigh", + Effort::Max => "max", + } + } + + /// The effort a word names. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "low" => Some(Effort::Low), + "medium" => Some(Effort::Medium), + "high" => Some(Effort::High), + "xhigh" => Some(Effort::Xhigh), + "max" => Some(Effort::Max), + _ => None, + } + } +} + +/// How an attestation was made, as the client says (self-reported: the +/// books record the claim, never prove it; rules.md B-45). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Method { + /// The model that checked, as it names itself. + pub model: Option, + /// How hard it thought. + pub reasoning_effort: Option, + /// Whether the check was adversarial: a verifier set to find what is + /// wrong, not to confirm. + pub adversarial: Option, + /// How many independent verifier agents checked. + pub verifier_agents: Option, +} + +/// What counts as an attestation (`policy.attestation_bar`; D-9). The +/// default: reasoning effort high, two independent verifiers, adversarial. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct AttestationBar { + /// The least effort that counts, for every attestation. + pub reasoning_effort: Effort, + /// The fewest independent verifier agents a year-level attestation + /// needs. + pub verifier_agents: u32, + /// Whether a year-level attestation must be adversarial. + pub adversarial: bool, +} + +impl Default for AttestationBar { + fn default() -> Self { + AttestationBar { + reasoning_effort: Effort::High, + verifier_agents: 2, + adversarial: true, + } + } +} + +impl AttestationBar { + /// The bar a setting's text names: `key=value` words in any order, + /// separated by spaces or commas (`effort=high verifiers=2 + /// adversarial=true`); a word left out keeps its default. + pub fn parse(s: &str) -> Result { + let mut bar = AttestationBar::default(); + for word in s.split(|c: char| c.is_whitespace() || c == ',') { + if word.is_empty() { + continue; + } + let (k, v) = word.split_once('=').ok_or_else(|| { + format!("{word:?}: expected key=value (effort, verifiers, adversarial)") + })?; + match k { + "effort" | "reasoning_effort" => { + bar.reasoning_effort = Effort::parse(v).ok_or_else(|| { + format!("effort {v:?} is not low, medium, high, xhigh or max") + })?; + } + "verifiers" | "verifier_agents" => { + bar.verifier_agents = v + .parse::() + .ok() + .filter(|n| (1..=64).contains(n)) + .ok_or_else(|| format!("verifiers {v:?} is not a count from 1 to 64"))?; + } + "adversarial" => { + bar.adversarial = match v { + "true" => true, + "false" => false, + _ => return Err(format!("adversarial {v:?} is not true or false")), + }; + } + other => { + return Err(format!( + "{other:?} is not a part of the bar: effort, verifiers, adversarial" + )); + } + } + } + Ok(bar) + } + + /// The bar in its one canonical spelling. + pub fn describe(&self) -> String { + format!( + "effort={} verifiers={} adversarial={}", + self.reasoning_effort.key(), + self.verifier_agents, + self.adversarial + ) + } + + /// Whether an attestation made `method` counts. A claim that + /// disagrees with the books never does. Every attestation needs the + /// effort; one given on a year-level audit (`full`) needs the whole + /// bar. Evaluated when asked, against the bar as it stands (D-9: no + /// bar versioning). + pub fn counts(&self, method: &Method, full: bool, disagrees: bool) -> bool { + if disagrees { + return false; + } + if !method + .reasoning_effort + .is_some_and(|e| e >= self.reasoning_effort) + { + return false; + } + if !full { + return true; + } + (!self.adversarial || method.adversarial == Some(true)) + && method + .verifier_agents + .is_some_and(|n| n >= self.verifier_agents) + } +} diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 6bea26e..ec6579b 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod account; pub mod analysis; pub mod arap; pub mod assets; +pub mod audit; pub mod calendar; pub mod cashflow; pub mod classes; diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index 7387878..568a21d 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -146,6 +146,9 @@ pub enum Kind { Terms, /// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`. PaymentMethods, + /// What counts as an attestation ([`crate::audit::AttestationBar`]), + /// in its one canonical spelling. + AttestationBar, } /// One setting the books know. @@ -170,6 +173,7 @@ const PARTY_POLICY: &[(&str, &[&str])] = const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])]; const TAX_CATEGORY_POLICY: &[(&str, &[&str])] = &[("warn", &[]), ("refuse", &["required", "require"])]; +const AUDIT_REQUIRED: &[(&str, &[&str])] = &[("refuse", &["required", "require"]), ("warn", &[])]; const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])]; /// A return's key as a setting keeps it. @@ -401,6 +405,21 @@ pub static KEYS: &[Key] = &[ sensitivity: Sensitivity::Public, doc: "off: approval_rule conventions are recorded but never block a post. enforce: an entry a rule covers posts only once its approver approves it (books.approve_entry). Unset: off.", }, + // P8: audits and attestations. + Key { + name: "policy.attestation_bar", + toml: ("books", "attestation_bar"), + kind: Kind::AttestationBar, + sensitivity: Sensitivity::Public, + doc: "What counts as an attestation (plan D-9): the least reasoning effort a checker reports (low, medium, high, xhigh, max), and for a year-level audit the fewest independent verifier agents and whether the check was adversarial, written like effort=high verifiers=2 adversarial=true (a word left out keeps its default). The client reports how it checked; the books record that claim and say whether it meets this bar when asked, so a change here changes what counts, earlier attestations too. Unset: effort=high verifiers=2 adversarial=true.", + }, + Key { + name: "policy.cpa_package_requires_audit", + toml: ("books", "cpa_package_requires_audit"), + kind: Kind::Choice(AUDIT_REQUIRED), + sensitivity: Sensitivity::Public, + doc: "Whether books.export_cpa_package waits for an audit: refuse (the package is refused unless an audit for the handoff or a full audit at the current books has its period attested; a reason given for the export overrides it and is recorded as a decision) or warn (the package is made and says so). Unset: refuse.", + }, Key { name: "backup.git_remote", toml: ("backup", "git_remote"), @@ -1103,6 +1122,11 @@ pub fn check(key: &Key, v: &Value) -> Result { Ok(json!(terms.describe())) } Kind::PaymentMethods => payment_methods(name, want_list(name, v)?), + Kind::AttestationBar => { + let bar = crate::audit::AttestationBar::parse(want_str(name, v)?) + .map_err(|e| format!("{name}: {e}"))?; + Ok(json!(bar.describe())) + } Kind::TimeZone => { let s = want_str(name, v)?.trim(); let zone = jiff::tz::TimeZone::get(s).map_err(|_| { @@ -1230,6 +1254,9 @@ pub fn shape(key: &Key, v: &Value) -> Result<(), String> { .ok_or_else(|| format!("{name} {s:?} is not a decimal")) } Kind::Count => count_of(name, v).map(|_| ()), + Kind::AttestationBar => crate::audit::AttestationBar::parse(want_str(name, v)?) + .map(|_| ()) + .map_err(|e| format!("{name}: {e}")), Kind::PaymentMethods => { for (i, m) in want_list(name, v)?.iter().enumerate() { let n = format!("{name} method {}", i + 1); @@ -1605,6 +1632,18 @@ impl Settings { } } + /// `policy.attestation_bar` (unset: the default bar, D-9). + pub fn attestation_bar(&self) -> crate::audit::AttestationBar { + self.str("policy.attestation_bar") + .and_then(|s| crate::audit::AttestationBar::parse(s).ok()) + .unwrap_or_default() + } + + /// `policy.cpa_package_requires_audit` (unset: refuse). + pub fn cpa_package_requires_audit(&self) -> bool { + self.str("policy.cpa_package_requires_audit") != Some("warn") + } + /// `backup.git_remote`, if set. pub fn backup_git_remote(&self) -> Option<&str> { self.str("backup.git_remote") diff --git a/crates/ledger-core/tests/audit.rs b/crates/ledger-core/tests/audit.rs new file mode 100644 index 0000000..8be2111 --- /dev/null +++ b/crates/ledger-core/tests/audit.rs @@ -0,0 +1,439 @@ +//! The audit's statements checks (plan P8; rules.md B-45): one test per +//! finding code, each seeding exactly the defect the code names. + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use ledger_core::arap::{ControlCheck, DocAttention, UnattachedLine}; +use ledger_core::audit::{ + self, AttestationBar, AuditInput, CODES, Effort, Finding, Method, Severity, +}; +use ledger_core::cashflow::CashFlowFooting; +use ledger_core::periods::MemberSplit; +use ledger_core::reports::{AssetRegister, TrialBalance}; +use ledger_core::settings::Settings; +use ledger_core::{ + AccountType, Currency, DocRef, DocStatus, EntryId, Ledger, Money, NewAsset, NewSchedule, + ScheduleConvention, ScheduleKind, Side, +}; +use rust_decimal::Decimal; + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn dec(s: &str) -> Decimal { + s.parse().unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +struct Books { + l: Ledger, + cash: u64, + capital: u64, + expense: u64, + revenue: u64, +} + +/// A bank, capital, an expense and a revenue account, all categorized. +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let cash = a("1000", "Bank", AccountType::Asset); + let capital = a("3000", "Capital", AccountType::Equity); + let revenue = a("4000", "Sales", AccountType::Revenue); + let expense = a("6000", "Supplies", AccountType::Expense); + l.set_account_tax(revenue, Some("gross_receipts"), None) + .unwrap(); + l.set_account_tax(expense, Some("supplies"), None).unwrap(); + Books { + l, + cash, + capital, + expense, + revenue, + } +} + +fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) -> EntryId { + let e = l.create_entry(date, "x"); + l.add_line(e, dr, Side::Debit, usd(amt)).unwrap(); + l.add_line(e, cr, Side::Credit, usd(amt)).unwrap(); + l.post_entry(e).unwrap(); + e +} + +fn run(l: &Ledger, receipts: &BTreeSet, through: NaiveDate) -> Vec { + let settings = Settings::default(); + audit::statements( + l, + &AuditInput { + settings: &settings, + receipts, + cash_flow: None, + settings_problems: &[], + undecided_1099: &[], + }, + through, + ) +} + +/// Every posted entry has its receipt. +fn receipted(l: &Ledger) -> BTreeSet { + l.posted_entries().map(|e| e.id()).collect() +} + +fn codes(f: &[Finding]) -> Vec<&'static str> { + f.iter().map(|f| f.code).collect() +} + +fn control(difference: &str) -> ControlCheck { + ControlCheck { + role: "receivables".into(), + account: 7, + balance: dec("100.00"), + documents_total: dec("100.00"), + unattached: vec![], + difference: dec(difference), + attention: vec![], + } +} + +/// Every code's seed: the defect, run, and the codes it yields. +fn seeds() -> Vec<(&'static str, Vec)> { + let mut out = Vec::new(); + let none = BTreeSet::new(); + + out.push(( + "trial_balance_unbalanced", + audit::check_trial_balance(&TrialBalance { + lines: vec![], + total_debits: dec("10.00"), + total_credits: dec("9.99"), + }) + .into_iter() + .collect(), + )); + + let b = books(); + let mut bs = ledger_core::reports::balance_sheet_as_of(&b.l, d(12, 31)).unwrap(); + bs.total_assets = dec("1.00"); + out.push(( + "equation_broken", + audit::check_equation(&bs).into_iter().collect(), + )); + + out.push(( + "pnl_not_articulated", + audit::check_articulation(dec("100.00"), dec("40.00"), dec("50.00")) + .into_iter() + .collect(), + )); + + // A statement the engine refuses (a liability named as cash) and one + // that does not foot are both unfooted. + let settings = Settings::default(); + let refused: Result = Err("account 9 is not an asset".into()); + let off: Result = Ok(CashFlowFooting { + sections_sum: dec("10.00"), + net_change: dec("9.00"), + difference: dec("1.00"), + }); + let b = books(); + for made in [&refused, &off] { + let f = audit::statements( + &b.l, + &AuditInput { + settings: &settings, + receipts: &none, + cash_flow: Some(made), + settings_problems: &[], + undecided_1099: &[], + }, + d(12, 31), + ); + assert_eq!(codes(&f), vec!["cash_flow_unfooted"]); + } + out.push(( + "cash_flow_unfooted", + audit::check_cash_flow(Some(&off)).into_iter().collect(), + )); + + out.push(( + "control_difference", + audit::check_control(&[control("0.01")]), + )); + let mut c = control("0.00"); + c.unattached = vec![UnattachedLine { + entry: 3, + line: 0, + date: d(2, 1), + amount: dec("5.00"), + }]; + out.push(("unattached_control_line", audit::check_control(&[c]))); + let mut c = control("0.00"); + c.attention = vec![DocAttention { + doc: DocRef::Invoice(1), + status: DocStatus::Overpaid, + total: dec("10.00"), + outstanding: dec("-5.00"), + }]; + out.push(("document_overpaid", audit::check_control(&[c]))); + + out.push(( + "close_split_drifts", + audit::check_split( + 2, + "2026-10", + &[MemberSplit { + capital: 9, + share: dec("0.5"), + part: dec("1.00"), + running: dec("1.00"), + exact: dec("1.05"), + }], + dec("0.01"), + ), + )); + + // A prepaid plan with a month due and nothing posted. + let mut b = books(); + let prepaid = + b.l.add_account("1300", "Prepaid", AccountType::Asset, None, false) + .unwrap(); + b.l.register_schedule(NewSchedule { + description: "Insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd("1200.00"), + start: d(1, 1), + months: 12, + balance_account: prepaid, + pl_account: b.expense, + convention: ScheduleConvention::MonthlyEven, + }) + .unwrap(); + out.push(("schedule_behind", run(&b.l, &receipted(&b.l), d(3, 31)))); + + // A registered asset, no depreciation run. + let mut b = books(); + let (cost, accum, dep) = asset_accounts(&mut b); + post(&mut b.l, d(1, 2), cost, b.capital, "6000.00"); + b.l.register_asset(NewAsset { + description: "Van".into(), + in_service_date: d(1, 15), + cost: usd("6000.00"), + salvage_value: usd("0"), + useful_life_months: 60, + cost_account: cost, + accum_depr_account: accum, + expense_account: dep, + }) + .unwrap(); + out.push(("depreciation_behind", run(&b.l, &receipted(&b.l), d(6, 30)))); + + // Cost posted to the asset's account that the register doesn't hold. + post(&mut b.l, d(2, 1), cost, b.capital, "100.00"); + let f = run(&b.l, &receipted(&b.l), d(6, 30)); + out.push(( + "register_differs", + f.into_iter() + .filter(|f| f.code == "register_differs") + .collect(), + )); + + // A 40.00 hand entry (threshold 25.00) with no receipt, then reversed. + let mut b = books(); + let e = post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00"); + let f = run(&b.l, &none, d(12, 31)); + out.push(("evidence_missing", f)); + let r = b.l.reverse_entry(e, d(2, 2), "undo").unwrap(); + let mut with_original = BTreeSet::new(); + with_original.insert(e); + let f = run(&b.l, &with_original, d(12, 31)); + assert!(f.iter().any(|f| f.subject == audit::Subject::Entry(r))); + out.push(("legacy_reversal_exempt", f)); + + let mut b = books(); + let e = b.l.create_entry(d(3, 3), "draft"); + b.l.add_line(e, b.expense, Side::Debit, usd("1.00")) + .unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap(); + out.push(("draft_open", run(&b.l, &none, d(12, 31)))); + + let mut b = books(); + b.l.add_account("4100", "Other sales", AccountType::Revenue, None, false) + .unwrap(); + out.push(("tax_category_missing", run(&b.l, &none, d(12, 31)))); + + let b = books(); + let settings = Settings::default(); + out.push(( + "vendor_1099_undecided", + audit::statements( + &b.l, + &AuditInput { + settings: &settings, + receipts: &none, + cash_flow: None, + settings_problems: &[], + undecided_1099: &[("Tobin Harlow Supply".into(), dec("900.00"))], + }, + d(12, 31), + ), + )); + out.push(( + "settings_problem", + audit::statements( + &b.l, + &AuditInput { + settings: &settings, + receipts: &none, + cash_flow: None, + settings_problems: &["books.close_into names 9999".into()], + undecided_1099: &[], + }, + d(12, 31), + ), + )); + let _ = (b.revenue, AccountType::Asset, Side::Debit); + out +} + +fn asset_accounts(b: &mut Books) -> (u64, u64, u64) { + let cost = + b.l.add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let accum = + b.l.add_account( + "1590", + "Accumulated depreciation", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let dep = + b.l.add_account("6500", "Depreciation", AccountType::Expense, None, false) + .unwrap(); + b.l.set_account_tax(dep, Some("supplies"), None).unwrap(); + (cost, accum, dep) +} + +#[test] +fn audit_detects_each_seeded_defect() { + let seeds = seeds(); + for row in CODES { + let (_, found) = seeds + .iter() + .find(|(c, _)| *c == row.code) + .unwrap_or_else(|| panic!("no seeding fixture for {}", row.code)); + let mine: Vec<&Finding> = found.iter().filter(|f| f.code == row.code).collect(); + assert!(!mine.is_empty(), "{} was not found", row.code); + assert!( + mine.iter().all(|f| f.severity == row.severity), + "{}", + row.code + ); + // Exactly that finding: nothing else but what the seed implies. + let others: Vec<_> = found.iter().filter(|f| f.code != row.code).collect(); + let implied = match row.code { + // A 40.00 reversal chain, or a draft's unbalanced side, leaves + // the neighbouring checks quiet. + "legacy_reversal_exempt" => vec!["legacy_reversal_exempt", "evidence_missing"], + _ => vec![], + }; + for o in others { + assert!( + implied.contains(&o.code), + "{}: also found {} ({})", + row.code, + o.code, + o.message + ); + } + } + for (c, _) in &seeds { + assert!(CODES.iter().any(|r| r.code == *c), "{c} is not in CODES"); + } +} + +#[test] +fn a_whole_book_gives_no_failure_or_warning() { + let mut b = books(); + post(&mut b.l, d(1, 5), b.cash, b.capital, "1000.00"); + post(&mut b.l, d(1, 6), b.cash, b.revenue, "20.00"); + let f = run(&b.l, &receipted(&b.l), d(12, 31)); + assert!( + f.iter().all(|f| f.severity == Severity::Info), + "{:?}", + codes(&f) + ); +} + +#[test] +fn findings_come_in_one_order() { + let mut b = books(); + post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00"); + let e = b.l.create_entry(d(3, 3), "draft"); + b.l.add_line(e, b.expense, Side::Debit, usd("1.00")) + .unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap(); + let a = run(&b.l, &BTreeSet::new(), d(12, 31)); + let c = run(&b.l, &BTreeSet::new(), d(12, 31)); + assert_eq!(a, c); + assert_eq!(codes(&a), vec!["evidence_missing", "draft_open"]); + let _ = AssetRegister::unposted_depreciation; +} + +fn method(effort: Option<&str>, adversarial: Option, agents: Option) -> Method { + Method { + model: None, + reasoning_effort: effort.map(|e| Effort::parse(e).unwrap()), + adversarial, + verifier_agents: agents, + } +} + +/// D-9: effort at the bar counts for a receipt or a month; a year-level +/// attestation also needs the verifiers and the adversarial check; less +/// than the bar, or no claim at all, never counts. +#[test] +fn below_bar_not_counted() { + let bar = AttestationBar::default(); + assert_eq!(bar.describe(), "effort=high verifiers=2 adversarial=true"); + assert!(bar.counts(&method(Some("high"), None, None), false, false)); + assert!(bar.counts(&method(Some("max"), None, None), false, false)); + assert!(!bar.counts(&method(Some("medium"), None, None), false, false)); + assert!(!bar.counts(&method(None, Some(true), Some(3)), false, false)); + assert!(bar.counts(&method(Some("high"), Some(true), Some(2)), true, false)); + assert!(!bar.counts(&method(Some("high"), Some(true), Some(1)), true, false)); + assert!(!bar.counts(&method(Some("high"), Some(false), Some(2)), true, false)); + assert!(!bar.counts(&method(Some("high"), None, Some(2)), true, false)); + // A bar change changes what counts, earlier claims too (no + // versioning, D-9). + let lax = AttestationBar::parse("effort=low verifiers=1 adversarial=false").unwrap(); + assert!(lax.counts(&method(Some("low"), Some(false), Some(1)), true, false)); + assert!(AttestationBar::parse("effort=wild").is_err()); + assert!(AttestationBar::parse("verifiers=0").is_err()); + assert!(AttestationBar::parse("nonsense").is_err()); + assert_eq!( + AttestationBar::parse("adversarial=false, effort=xhigh") + .unwrap() + .describe(), + "effort=xhigh verifiers=2 adversarial=false" + ); +} + +/// A reading that disagrees with the books never counts, however hard the +/// checker says it thought. +#[test] +fn amount_mismatch_never_counts() { + let bar = AttestationBar::default(); + let top = method(Some("max"), Some(true), Some(9)); + assert!(bar.counts(&top, true, false)); + assert!(!bar.counts(&top, false, true)); + assert!(!bar.counts(&top, true, true)); +}