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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
d9d63b16dc
commit
843bcc421f
4 files changed
+1350
No files matched your search
@@ -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<String>) -> 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<EntryId>,
|
||||
/// 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<crate::cashflow::CashFlowFooting, String>>,
|
||||
/// 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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
(!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<Finding> {
|
||||
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<Finding> {
|
||||
(!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<Finding> {
|
||||
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<Finding> {
|
||||
(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<crate::cashflow::CashFlowFooting, String>>,
|
||||
) -> Option<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Finding> {
|
||||
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<Scope> {
|
||||
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<Effort> {
|
||||
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<String>,
|
||||
/// How hard it thought.
|
||||
pub reasoning_effort: Option<Effort>,
|
||||
/// Whether the check was adversarial: a verifier set to find what is
|
||||
/// wrong, not to confirm.
|
||||
pub adversarial: Option<bool>,
|
||||
/// How many independent verifier agents checked.
|
||||
pub verifier_agents: Option<u32>,
|
||||
}
|
||||
|
||||
/// 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<AttestationBar, String> {
|
||||
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::<u32>()
|
||||
.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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Value, String> {
|
||||
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")
|
||||
|
||||
@@ -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<EntryId>, through: NaiveDate) -> Vec<Finding> {
|
||||
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<EntryId> {
|
||||
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<Finding>)> {
|
||||
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<CashFlowFooting, String> = Err("account 9 is not an asset".into());
|
||||
let off: Result<CashFlowFooting, String> = 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<bool>, agents: Option<u32>) -> 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));
|
||||
}
|
||||
Reference in new issue
Block a user