feat(audit): books.audit, the attestation tools and the CPA gate (P8)
The whole re-audit through the tools: books.audit previews its checks, then with the preview's digest runs them, files the workpaper and journals the run; the same books, day and scope return the run that exists. books.attest_receipt, books.review_entry and books.attest_period record a named client's claim with its self-reported method; books.attestations weighs each against policy.attestation_bar as it stands. Under policy.cpa_package_requires_audit = refuse, books that declared audits cannot export the CPA package without a counted audit for the handoff at the current head unless a reason is given, which is journaled as a decision (D-9). Needs attention asks for the month-end attestation, the annual audit and the audit before the handoff. Rules B-45, the handbook section, the interface, parity, receipt-store and testing records, and the As-built block. Merge needs no go-ahead: the live books refuse the new writes until the owner gives it. 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
d45e842873
commit
45bcce679b
23 files changed
+2128
-22
No files matched your search
@@ -9,7 +9,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_gui::{Source, data};
|
||||
use rust_decimal::Decimal;
|
||||
@@ -350,3 +350,79 @@ fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() {
|
||||
"{titles:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end
|
||||
/// attestation due" on the viewer's list and in `books.start`'s next steps,
|
||||
/// from the one engine; the fiscal year that ended closed with no audit
|
||||
/// for the handoff is "Full audit due before the CPA handoff"; an
|
||||
/// attestation clears the month. Books that never declared `audits` get
|
||||
/// none of them (they cannot act on them).
|
||||
#[test]
|
||||
fn audit_items_are_on_both_lists_once_audits_are_declared() {
|
||||
let today = ledger_core::settings::today_in(None);
|
||||
let place = common::Place::new("attention-audit-parity");
|
||||
let dir = place.books_data();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let year = today.year() - 1;
|
||||
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap();
|
||||
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||
let cash = st
|
||||
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = st
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let e = st.create_entry(day(1, 5), "Capital").unwrap();
|
||||
st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap();
|
||||
st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap();
|
||||
st.post_entry(e).unwrap();
|
||||
let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap();
|
||||
st.close_period(jan).unwrap();
|
||||
let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap();
|
||||
st.close_period(dec).unwrap();
|
||||
drop(st);
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("attention-audit-parity");
|
||||
let titles = compare(&place, &mut wire, today);
|
||||
assert!(
|
||||
titles
|
||||
.iter()
|
||||
.any(|t| t.starts_with("Month-end attestation due")),
|
||||
"{titles:?}"
|
||||
);
|
||||
assert!(
|
||||
titles
|
||||
.iter()
|
||||
.any(|t| t.starts_with("Full audit due before the CPA handoff")),
|
||||
"{titles:?}"
|
||||
);
|
||||
// Audit and attest January: that month's item goes, the year's stays.
|
||||
let run = common::answer_of(&wire.call(
|
||||
"books.audit",
|
||||
json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(run["status"], "audited", "{run}");
|
||||
common::answer_of(&wire.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "Jan", "method": {"reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = compare(&place, &mut wire, today);
|
||||
let due: Vec<&String> = after
|
||||
.iter()
|
||||
.filter(|t| t.starts_with("Month-end attestation due"))
|
||||
.collect();
|
||||
assert_eq!(due.len(), 1, "{after:?}");
|
||||
assert!(due[0].contains("Dec"), "{after:?}");
|
||||
// The step offers the audit.
|
||||
let answer = start(&mut wire);
|
||||
let step = answer["next_steps"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s["why"] == json!(due[0].as_str()))
|
||||
.unwrap_or_else(|| panic!("no step: {answer}"));
|
||||
assert_eq!(step["tool"], json!("books.audit"), "{answer}");
|
||||
assert_eq!(step["args"]["scope"], json!("month"), "{answer}");
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9):
|
||||
//! a deterministic run and workpaper, claims weighed against the bar when
|
||||
//! asked, the CPA package that waits for a counted audit, and books that
|
||||
//! have not declared `audits`.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit
|
||||
//! and attestations") and rules.md B-45. Every call goes through a real
|
||||
//! books server as an agent reaches it (`common.rs`).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{Books, answer_of};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Cash at 1000, the default 25.00 receipt threshold.
|
||||
const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
fn chart(w: &mut common::Wire) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
("4000", "Sales", "revenue"),
|
||||
("6000", "Supplies", "expense"),
|
||||
] {
|
||||
answer_of(&w.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": ty}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A posted entry of `amount` supplies paid from cash on `date`.
|
||||
fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 {
|
||||
let r = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": date, "description": what, "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap();
|
||||
let id = r["entry_id"].as_u64().unwrap();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 {
|
||||
let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt");
|
||||
answer_of(&w.call(
|
||||
"books.attach_receipt",
|
||||
json!({"entry_id": entry, "upload_id": upload}),
|
||||
))
|
||||
.unwrap()["attachment_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn confirm_post(w: &mut common::Wire, id: u64) {
|
||||
let r = answer_of(&w.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": id, "confirmed": true}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(r["status"], "posted", "{r}");
|
||||
}
|
||||
|
||||
/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with
|
||||
/// none. Returns (receipted entry, its receipt, the bare entry).
|
||||
fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) {
|
||||
let books = Books::with(tag, "USD", Some(SETTINGS));
|
||||
let mut w = books.session("p8");
|
||||
chart(&mut w);
|
||||
let cap = answer_of(&w.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}),
|
||||
))
|
||||
.unwrap()["entry_id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
for (account, side) in [("1000", "debit"), ("3000", "credit")] {
|
||||
answer_of(&w.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01");
|
||||
confirm_post(&mut w, cap);
|
||||
let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00");
|
||||
let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05");
|
||||
confirm_post(&mut w, paper);
|
||||
// The 60.00 purchase is posted while the books' receipt threshold was
|
||||
// higher, as it could have been before the rule was tightened.
|
||||
set_threshold(&mut w, "1000.00");
|
||||
let bare = post(&mut w, "2026-03-07", "Toner", "60.00");
|
||||
confirm_post(&mut w, bare);
|
||||
set_threshold(&mut w, "25.00");
|
||||
(books, w, paper, rid, bare)
|
||||
}
|
||||
|
||||
fn set_threshold(w: &mut common::Wire, amount: &str) {
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.evidence.threshold", "value": amount}]}),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value {
|
||||
answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": scope, "through": through, "confirmed": true}),
|
||||
))
|
||||
.unwrap_or_else(|e| panic!("books.audit: {e}"))
|
||||
}
|
||||
|
||||
fn codes(out: &Value) -> Vec<String> {
|
||||
out["findings"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|f| f["code"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The same books, day and scope give the same run, findings and
|
||||
/// workpaper; asking again files and journals nothing; an entry makes it a
|
||||
/// new audit.
|
||||
#[test]
|
||||
fn audit_is_deterministic() {
|
||||
let (books, mut w, _, _, bare) = setup("p8-deterministic");
|
||||
let preview = answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert!(preview["run"].is_null());
|
||||
assert!(
|
||||
preview["checks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c["code"] == "evidence_missing")
|
||||
);
|
||||
let first = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(first["status"], "audited", "{first}");
|
||||
assert!(
|
||||
codes(&first).contains(&"evidence_missing".to_string()),
|
||||
"the bare 60.00 entry has no receipt: {first}"
|
||||
);
|
||||
let run = first["run"].clone();
|
||||
assert_eq!(run["at_current_head"], true);
|
||||
let journal = books.journal();
|
||||
let again = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(again["status"], "already_run");
|
||||
assert_eq!(again["run"]["id"], run["id"]);
|
||||
assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]);
|
||||
assert_eq!(again["findings"], first["findings"]);
|
||||
assert_eq!(books.journal(), journal, "asking again journals nothing");
|
||||
// The workpaper is the same twice, JSON and PDF.
|
||||
let id = run["id"].clone();
|
||||
let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
|
||||
let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap();
|
||||
assert_eq!(one["workpaper"], two["workpaper"]);
|
||||
assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]);
|
||||
assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]);
|
||||
let pdf = w.fetch(&one["pdf"]);
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
// The audit fixes nothing and moves no figure; an entry makes it stale.
|
||||
let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap();
|
||||
assert_eq!(tb["balanced"], true);
|
||||
let extra = post(&mut w, "2026-03-09", "Stamps", "10.00");
|
||||
confirm_post(&mut w, extra);
|
||||
let third = audit(&mut w, "full", "2026-03-31");
|
||||
assert_eq!(third["status"], "audited");
|
||||
assert_ne!(third["run"]["id"], run["id"]);
|
||||
let _ = bare;
|
||||
}
|
||||
|
||||
/// A reading that differs from the entry never counts; one that agrees
|
||||
/// counts only at the bar, and the bar is read when asked.
|
||||
#[test]
|
||||
fn attestations_count_against_the_bar_when_asked() {
|
||||
let (_books, mut w, paper, rid, _) = setup("p8-bar");
|
||||
let good = answer_of(&w.call(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05",
|
||||
"method": {"model": "opus", "reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(good["attestation"]["counted"], true, "{good}");
|
||||
assert_eq!(good["attestation"]["by"], "test");
|
||||
let wrong = answer_of(&w.call(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05",
|
||||
"method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(wrong["attestation"]["disagrees"], true);
|
||||
assert_eq!(wrong["attestation"]["counted"], false);
|
||||
assert!(
|
||||
wrong["why_not_counted"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("differs")
|
||||
);
|
||||
let low = answer_of(&w.call(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(low["attestation"]["counted"], false);
|
||||
assert!(low["why_not_counted"].as_str().unwrap().contains("high"));
|
||||
let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap();
|
||||
assert_eq!(none["attestation"]["counted"], false, "no claim, no count");
|
||||
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
|
||||
assert_eq!(listed["attestations"].as_array().unwrap().len(), 4);
|
||||
assert_eq!(listed["counted"], 1);
|
||||
assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true");
|
||||
let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
|
||||
assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2);
|
||||
// The bar is read when asked: lowered, the medium review counts.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap();
|
||||
assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true");
|
||||
assert_eq!(after["attestations"][0]["counted"], true);
|
||||
assert_eq!(after["attestations"][1]["counted"], false);
|
||||
// Refused: bad method words, an unposted entry.
|
||||
let bad = w.call(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}),
|
||||
);
|
||||
assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort"));
|
||||
}
|
||||
|
||||
/// A period is attested only on an audit that still stands.
|
||||
#[test]
|
||||
fn a_period_is_attested_on_a_standing_audit() {
|
||||
let (_books, mut w, paper, _, _) = setup("p8-period");
|
||||
answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
let method = json!({"reasoning_effort": "high"});
|
||||
let none = w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
);
|
||||
assert!(answer_of(&none).unwrap_err().contains("books.audit"));
|
||||
let run = audit(&mut w, "month", "2026-03-31");
|
||||
let ok = answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]);
|
||||
assert_eq!(
|
||||
ok["attestation"]["counted"], true,
|
||||
"a month needs the effort only"
|
||||
);
|
||||
// A change since the audit: refused until it is run again.
|
||||
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
|
||||
confirm_post(&mut w, extra);
|
||||
let stale = w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": method}),
|
||||
);
|
||||
assert!(answer_of(&stale).unwrap_err().contains("books.audit"));
|
||||
let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap();
|
||||
assert_eq!(listing["run"]["at_current_head"], false);
|
||||
let _ = paper;
|
||||
}
|
||||
|
||||
/// D-9: the CPA package waits for a counted audit for the handoff; a
|
||||
/// reason overrides it and is journaled as a decision; the bar is the
|
||||
/// whole bar for a year.
|
||||
#[test]
|
||||
fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() {
|
||||
let (_books, mut w, paper, rid, bare) = setup("p8-cpa");
|
||||
let export = |w: &mut common::Wire, extra: Value| {
|
||||
let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"],
|
||||
"include_receipts": false, "include_tins": false});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
w.call("books.export_cpa_package", args)
|
||||
};
|
||||
let refused = export(&mut w, json!({}));
|
||||
let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap();
|
||||
assert_eq!(err["code"], "audit_required", "{err}");
|
||||
assert_eq!(err["remedy_tool"], "books.audit");
|
||||
// A reason lets it go, and is a decision in the journal.
|
||||
let before = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
answer_of(&export(
|
||||
&mut w,
|
||||
json!({"without_audit_reason": " the CPA asked for it early "}),
|
||||
))
|
||||
.unwrap();
|
||||
let after = answer_of(&w.call("books.decisions", json!({}))).unwrap();
|
||||
assert_eq!(
|
||||
after["decisions"].as_array().unwrap().len(),
|
||||
before["decisions"].as_array().unwrap().len() + 1
|
||||
);
|
||||
assert!(after.to_string().contains("without a counted audit"));
|
||||
// An audit alone does not open it; nor one attested below the bar.
|
||||
answer_of(&w.call(
|
||||
"books.create_period",
|
||||
json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}),
|
||||
))
|
||||
.unwrap();
|
||||
audit(&mut w, "cpa_handoff", "2026-03-31");
|
||||
assert!(answer_of(&export(&mut w, json!({}))).is_err());
|
||||
answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(
|
||||
answer_of(&export(&mut w, json!({}))).is_err(),
|
||||
"a year-level audit needs the adversarial check and the verifiers"
|
||||
);
|
||||
answer_of(&w.call(
|
||||
"books.attest_period",
|
||||
json!({"period": "2026-03", "method": {"reasoning_effort": "high",
|
||||
"adversarial": true, "verifier_agents": 2}}),
|
||||
))
|
||||
.unwrap();
|
||||
let made = answer_of(&export(&mut w, json!({}))).unwrap();
|
||||
assert!(made["file"]["sha256"].is_string(), "{made}");
|
||||
// A change since: the audit no longer stands, and the package waits again.
|
||||
let extra = post(&mut w, "2026-03-20", "Stamps", "5.00");
|
||||
confirm_post(&mut w, extra);
|
||||
assert!(answer_of(&export(&mut w, json!({}))).is_err());
|
||||
// Under warn the package is made and says so.
|
||||
answer_of(&w.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}),
|
||||
))
|
||||
.unwrap();
|
||||
let warned = answer_of(&export(&mut w, json!({}))).unwrap();
|
||||
assert!(
|
||||
warned["warnings"]
|
||||
.to_string()
|
||||
.contains("No counted audit stands behind this package"),
|
||||
"{warned}"
|
||||
);
|
||||
let _ = (paper, rid, bare);
|
||||
}
|
||||
|
||||
/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`)
|
||||
/// open with no upgrade; the audit's preview works, its run and every
|
||||
/// attestation are refused with format_upgrade_pending, nothing is
|
||||
/// journaled, and the CPA package is not held up by an audit it cannot ask
|
||||
/// for.
|
||||
#[test]
|
||||
fn audits_wait_for_the_upgrade() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/p8_before");
|
||||
let only = tempfile::tempdir().unwrap();
|
||||
std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap();
|
||||
common::copy_private(&src.join("blobs"), &only.path().join("blobs"));
|
||||
let books = Books::placed("p8-waits", only.path());
|
||||
let mut w = books.session("p8");
|
||||
let preview = answer_of(&w.call(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28"}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(preview["status"], "preview");
|
||||
let journal = books.journal();
|
||||
for (tool, args) in [
|
||||
(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
|
||||
),
|
||||
(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00",
|
||||
"date": "2026-01-01"}),
|
||||
),
|
||||
] {
|
||||
let e = answer_of(&w.call(tool, args)).unwrap_err();
|
||||
assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}");
|
||||
}
|
||||
assert_eq!(books.journal(), journal, "a refusal journals nothing");
|
||||
// The package is held up only by what it always was: an open draft.
|
||||
let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"],
|
||||
"include_receipts": false, "include_tins": false});
|
||||
let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap();
|
||||
assert!(made["file"]["sha256"].is_string(), "{made}");
|
||||
let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap();
|
||||
assert!(listed["attestations"].as_array().unwrap().is_empty());
|
||||
}
|
||||
@@ -909,6 +909,16 @@ fn books_p2_served_cut_over_whole() {
|
||||
json!({"attachment_id": 1, "fields": {"payee": "Harbor Design LLC",
|
||||
"amount": "12.34", "date": "2026-02-01"}}),
|
||||
),
|
||||
// An audit run and an attestation (audits, P8): an older version
|
||||
// doesn't know the events.
|
||||
(
|
||||
"books.audit",
|
||||
json!({"scope": "full", "through": "2026-02-28", "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}),
|
||||
),
|
||||
];
|
||||
for (tool, args) in &refused {
|
||||
let e = a.call(tool, args.clone()).unwrap_err();
|
||||
|
||||
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 134, "expected 134 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 140, "expected 140 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
|
||||
@@ -1146,6 +1146,28 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"period_id": p, "confirmed": true}),
|
||||
);
|
||||
|
||||
// P8: an audit of the month, its workpaper, and the claims that stand
|
||||
// behind it.
|
||||
let audit_run = m.ok(
|
||||
"books.audit",
|
||||
json!({"scope": "month", "through": "2026-01-31", "confirmed": true}),
|
||||
)["run"]["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
m.ok("books.workpaper", json!({"id": audit_run}));
|
||||
let audited = m.attach(Some(e1), b"audit receipt");
|
||||
m.ok(
|
||||
"books.attest_receipt",
|
||||
json!({"attachment_id": audited, "entry_id": e1, "amount": "10000.00",
|
||||
"date": "2026-01-02", "method": {"reasoning_effort": "high"}}),
|
||||
);
|
||||
m.ok(
|
||||
"books.review_entry",
|
||||
json!({"entry_id": e1, "method": {"reasoning_effort": "high"}}),
|
||||
);
|
||||
m.ok("books.attestations", json!({}));
|
||||
m.ok("books.attest_period", json!({"period": "2026-01"}));
|
||||
|
||||
// How the books are kept: start, conventions, decisions, open items,
|
||||
// a template and an approval, and the settings file moving in.
|
||||
m.ok("books.start", json!({}));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Attestations and audit
|
||||
|
||||
An audit is the books read as an auditor reads them, through a day, in one run that gives the same findings for the same books. An attestation is a claim by a checker that it looked at something and found it right. The audit finds; the attestations stand behind it. The CPA package waits for both.
|
||||
|
||||
The receipt rule in force: {evidence_rule}
|
||||
|
||||
## Running an audit
|
||||
|
||||
- books.audit { through, scope } without confirmed lists the checks and changes nothing; show it to the human. Then call it again with confirmed: true and the preview. It files the workpaper (JSON and PDF, category workpaper) and journals the run. books.workpaper { id } gives it back: the JSON, and the PDF handed over (run its download command to save it).
|
||||
- scope: month (a month-end), annual (a fiscal year), cpa_handoff (the year, for the CPA package), full (everything since the first entry).
|
||||
- It checks the trial balance, the accounting equation, the profit and loss against equity, the cash flow's footing, the A/R and A/P control accounts, closes, schedules and depreciation, the asset register, every posted entry against the receipt rule, open drafts, tax categories, 1099 decisions and the settings; then the journal's chain, the stored files, the access log and the backups. A finding is a failure (the books are wrong or cannot be shown right), a warning (unfinished or a rule not met) or a note (listed so the workpaper is whole: a reversal rests on its original's receipt).
|
||||
- Fix findings with journaled corrections (a reversal, a receipt attached, a decision recorded), never by editing: posted entries stay as they are. Then audit again.
|
||||
- The same books, day and scope give the run that exists: asking again files nothing. The books are "the same" while no entry, document, close, settings or role change has been made; filing a receipt, an audit or an attestation does not change them.
|
||||
- Books that have not declared audits refuse a confirmed audit and every attestation with format_upgrade_pending until the owner's upgrade (books.start says so). Nothing else about them changes.
|
||||
|
||||
## Attesting
|
||||
|
||||
Use separate verifier agents; none of them should be the one that made the entry. For each receipt:
|
||||
|
||||
1. books.get_receipt for the extracted text, then its page image, and read the total and the date off the receipt itself.
|
||||
2. books.attest_receipt { attachment_id, entry_id, amount, date, method, note }. The books compare what you read with the entry's amounts and date. A difference is recorded as disagrees and never counts: do not adjust your reading to fit.
|
||||
3. books.review_entry { entry_id, method } for each posted entry, once its lines, accounts, party and evidence have been read whole.
|
||||
4. books.attest_period { period, method } last, on a stand-still: it is refused unless an audit through the period's end still stands, which it does only while nothing has been posted, created, closed or set since the audit. If it is refused, audit again.
|
||||
|
||||
method is your own claim: model, reasoning_effort (low, medium, high, xhigh, max), adversarial (true when the verifier was set to find what is wrong), verifier_agents (how many independent ones checked). The books record it and cannot verify it; say what is true.
|
||||
|
||||
## What counts
|
||||
|
||||
books.attestations lists every claim with counted: whether it meets policy.attestation_bar as the bar stands when you ask. The default bar is reasoning effort high; a year-level audit (annual, cpa_handoff, full) also needs the check to be adversarial and at least two independent verifier agents. A bar change changes what counts, earlier claims too: nothing is versioned. A claim whose receipt reading disagrees with the entry never counts.
|
||||
|
||||
## The CPA package
|
||||
|
||||
Under policy.cpa_package_requires_audit = refuse (the default once audits are declared), books.export_cpa_package is refused unless an audit for the handoff (scope cpa_handoff or full) stands at the current books with a period attested on it at the bar. Pass without_audit_reason only when the human says to hand it over anyway; the reason is journaled as a decision. Under warn the package is made and says so.
|
||||
@@ -87,6 +87,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
),
|
||||
("1099", include_str!("../handbook/1099.md")),
|
||||
("cpa-handoff", include_str!("../handbook/cpa-handoff.md")),
|
||||
(
|
||||
"attestations-and-audit",
|
||||
include_str!("../handbook/attestations-and-audit.md"),
|
||||
),
|
||||
(
|
||||
"settings-and-conventions",
|
||||
include_str!("../handbook/settings-and-conventions.md"),
|
||||
|
||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 134] = [
|
||||
pub const TABLE: [(&str, Needs); 140] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -203,6 +203,14 @@ pub const TABLE: [(&str, Needs); 134] = [
|
||||
// P5
|
||||
("books.journal_history", R),
|
||||
("books.preview_entry", R),
|
||||
// P8: audits and attestations (an audit previews, then runs; a claim
|
||||
// that someone checked is a write, not a post).
|
||||
("books.audit", C),
|
||||
("books.workpaper", R),
|
||||
("books.attest_receipt", W),
|
||||
("books.review_entry", W),
|
||||
("books.attest_period", W),
|
||||
("books.attestations", R),
|
||||
];
|
||||
|
||||
/// What `tool` needs, given its arguments; None for a tool this server
|
||||
|
||||
@@ -585,6 +585,7 @@ impl BooksServer {
|
||||
+ Self::write_offs_router()
|
||||
+ Self::drafts_router()
|
||||
+ Self::journal_router()
|
||||
+ Self::audit_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request: the books' own, taken from
|
||||
@@ -1496,6 +1497,8 @@ pub use kept::SettingKeyOut;
|
||||
mod assets;
|
||||
#[path = "tools/attachments.rs"]
|
||||
mod attachments;
|
||||
#[path = "tools/audit.rs"]
|
||||
mod audit_tools;
|
||||
#[path = "tools/chart.rs"]
|
||||
mod chart;
|
||||
#[path = "tools/documents.rs"]
|
||||
|
||||
@@ -61,7 +61,7 @@ fn annotations_complete() {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 134] = [
|
||||
let table: [(&str, Hints); 140] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -199,6 +199,13 @@ fn annotations_complete() {
|
||||
("books.discard_draft", D),
|
||||
("books.journal_history", R),
|
||||
("books.preview_entry", R),
|
||||
// P8: audits and attestations.
|
||||
("books.audit", W),
|
||||
("books.workpaper", R),
|
||||
("books.attest_receipt", W),
|
||||
("books.review_entry", W),
|
||||
("books.attest_period", W),
|
||||
("books.attestations", R),
|
||||
];
|
||||
let tools = BooksServer::tool_router().list_all();
|
||||
assert_eq!(tools.len(), table.len());
|
||||
|
||||
@@ -0,0 +1,772 @@
|
||||
//! The audit group (plan P8; rules.md B-45): an audit run over the books,
|
||||
//! its workpaper, and the claims that someone checked.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the whole re-audit through the tools: one programme of checks
|
||||
//! that gives the same findings and the same workpaper for the same
|
||||
//! books, and attestations that rest on a head that has not moved.
|
||||
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
|
||||
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
|
||||
//! (an audit is a run, not a job), IZ-1k (the head).
|
||||
//! INVARIANTS:
|
||||
//! 1. `books.audit` previews (what it will check; nothing changes),
|
||||
//! then with the preview's digest runs the checks, files the
|
||||
//! workpaper (JSON and PDF) as documents and journals `audit.run`.
|
||||
//! 2. The same books (the figure head), day, scope and programme give
|
||||
//! the run that exists: nothing is filed or journaled twice.
|
||||
//! 3. An attestation is a claim by the caller named by its token; the
|
||||
//! method it gives is self-reported and the books say so.
|
||||
//! 4. `counted` is read against `policy.attestation_bar` as it stands
|
||||
//! when asked, never stored.
|
||||
//! 5. A period is attested only on an audit at the current head.
|
||||
//! 6. Nothing here moves a figure.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use ledger_core::audit::{self, AttestationBar, AuditInput, Effort, Finding, Method, Scope};
|
||||
use ledger_store::audit::{self as saudit, Attested, AuditRun, BackupStanding, Claim};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn sha_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// The caller's token name, else the default token's.
|
||||
fn caller_name() -> String {
|
||||
CALLER
|
||||
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
|
||||
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string())
|
||||
}
|
||||
|
||||
fn method_of(m: Option<AttestMethodParam>) -> Result<Method, String> {
|
||||
let m = m.unwrap_or_default();
|
||||
let reasoning_effort = match m.reasoning_effort.as_deref() {
|
||||
None => None,
|
||||
Some(e) => Some(Effort::parse(e).ok_or_else(|| {
|
||||
format!("reasoning_effort {e:?} is not low, medium, high, xhigh or max")
|
||||
})?),
|
||||
};
|
||||
if m.verifier_agents == Some(0) {
|
||||
return Err("verifier_agents is at least 1".into());
|
||||
}
|
||||
Ok(Method {
|
||||
model: m
|
||||
.model
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
reasoning_effort,
|
||||
adversarial: m.adversarial,
|
||||
verifier_agents: m.verifier_agents,
|
||||
})
|
||||
}
|
||||
|
||||
fn method_out(m: &Method) -> AttestMethodParam {
|
||||
AttestMethodParam {
|
||||
model: m.model.clone(),
|
||||
reasoning_effort: m.reasoning_effort.map(|e| e.key().to_string()),
|
||||
adversarial: m.adversarial,
|
||||
verifier_agents: m.verifier_agents,
|
||||
}
|
||||
}
|
||||
|
||||
fn finding_out(f: &Finding) -> AuditFindingOut {
|
||||
AuditFindingOut {
|
||||
severity: f.severity.key().into(),
|
||||
code: f.code.into(),
|
||||
subject: f.subject.describe(),
|
||||
message: f.message.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every check the programme runs.
|
||||
fn checks() -> Vec<AuditCheckOut> {
|
||||
audit::CODES
|
||||
.iter()
|
||||
.chain(saudit::CODES)
|
||||
.map(|r| AuditCheckOut {
|
||||
code: r.code.into(),
|
||||
severity: r.severity.key().into(),
|
||||
predicate: r.predicate.into(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_out(store: &Store, run: &AuditRun) -> Result<AuditRunOut, String> {
|
||||
Ok(AuditRunOut {
|
||||
id: run.id,
|
||||
through: run.through.to_string(),
|
||||
through_seq: run.through_seq,
|
||||
scope: run.scope.key().into(),
|
||||
program_version: run.program_version.clone(),
|
||||
tb_hash: run.tb_hash.clone(),
|
||||
findings_sha256: run.findings_sha256.clone(),
|
||||
workpaper_attachment: run.workpaper_attachment,
|
||||
workpaper_pdf: run.workpaper_pdf,
|
||||
at_current_head: store.audit_is_current(run).map_err(err)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn summary(findings: &[Finding]) -> String {
|
||||
let n = |s: audit::Severity| findings.iter().filter(|f| f.severity == s).count();
|
||||
format!(
|
||||
"{} failure(s), {} warning(s), {} note(s)",
|
||||
n(audit::Severity::Failure),
|
||||
n(audit::Severity::Warning),
|
||||
n(audit::Severity::Info)
|
||||
)
|
||||
}
|
||||
|
||||
/// Why an attestation does not count at this bar, in words.
|
||||
fn why_not(bar: &AttestationBar, m: &Method, full: bool, disagrees: bool) -> Option<String> {
|
||||
if bar.counts(m, full, disagrees) {
|
||||
return None;
|
||||
}
|
||||
if disagrees {
|
||||
return Some(
|
||||
"the amount or date read off the receipt differs from the entry's: it is recorded and never counts"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let mut why = Vec::new();
|
||||
if !m
|
||||
.reasoning_effort
|
||||
.is_some_and(|e| e >= bar.reasoning_effort)
|
||||
{
|
||||
why.push(format!(
|
||||
"reasoning_effort must be at least {}",
|
||||
bar.reasoning_effort.key()
|
||||
));
|
||||
}
|
||||
if full {
|
||||
if bar.adversarial && m.adversarial != Some(true) {
|
||||
why.push("the check must be adversarial".into());
|
||||
}
|
||||
if !m.verifier_agents.is_some_and(|n| n >= bar.verifier_agents) {
|
||||
why.push(format!(
|
||||
"at least {} independent verifier agents",
|
||||
bar.verifier_agents
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(format!(
|
||||
"below policy.attestation_bar ({}): {}",
|
||||
bar.describe(),
|
||||
why.join("; ")
|
||||
))
|
||||
}
|
||||
|
||||
/// An attestation as the tools list it.
|
||||
fn attestation_out(
|
||||
store: &Store,
|
||||
a: &saudit::Attestation,
|
||||
bar: &AttestationBar,
|
||||
) -> Result<AttestationOut, String> {
|
||||
let mut out = AttestationOut {
|
||||
seq: a.seq,
|
||||
kind: a.kind().into(),
|
||||
by: a.by.clone(),
|
||||
at: a.at.clone(),
|
||||
attachment_id: None,
|
||||
entry_id: None,
|
||||
period_id: None,
|
||||
audit_run_id: None,
|
||||
amount_checked: None,
|
||||
date_checked: None,
|
||||
disagrees: false,
|
||||
method: method_out(&a.method),
|
||||
note: a.note.clone(),
|
||||
counted: false,
|
||||
};
|
||||
let (full, disagrees) = match &a.what {
|
||||
Attested::Receipt {
|
||||
attachment_id,
|
||||
entry_id,
|
||||
amount_checked,
|
||||
date_checked,
|
||||
disagrees,
|
||||
} => {
|
||||
out.attachment_id = Some(*attachment_id);
|
||||
out.entry_id = Some(*entry_id);
|
||||
out.amount_checked = Some(amount_checked.to_string());
|
||||
out.date_checked = Some(date_checked.to_string());
|
||||
out.disagrees = *disagrees;
|
||||
(false, *disagrees)
|
||||
}
|
||||
Attested::Entry { entry_id } => {
|
||||
out.entry_id = Some(*entry_id);
|
||||
(false, false)
|
||||
}
|
||||
Attested::Period {
|
||||
period_id,
|
||||
audit_run_id,
|
||||
} => {
|
||||
out.period_id = Some(*period_id);
|
||||
out.audit_run_id = Some(*audit_run_id);
|
||||
let scope = store
|
||||
.audit_run(*audit_run_id)
|
||||
.map_err(err)?
|
||||
.map(|r| r.scope);
|
||||
(scope.is_some_and(Scope::full_bar), false)
|
||||
}
|
||||
};
|
||||
out.counted = bar.counts(&a.method, full, disagrees);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What an attestation's tool answers.
|
||||
fn attest_out(store: &Store, a: &saudit::Attestation) -> Result<AttestOut, String> {
|
||||
let bar = store.ledger().settings().attestation_bar();
|
||||
let o = attestation_out(store, a, &bar)?;
|
||||
let full = o
|
||||
.audit_run_id
|
||||
.and_then(|id| store.audit_run(id).ok().flatten())
|
||||
.is_some_and(|r| r.scope.full_bar());
|
||||
let why_not_counted = why_not(&bar, &a.method, full, o.disagrees);
|
||||
Ok(AttestOut {
|
||||
attestation: o,
|
||||
bar: bar.describe(),
|
||||
why_not_counted,
|
||||
})
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// Whether the CPA package may be made (plan P8 step 4, D-9): on books
|
||||
/// that declared `audits` and a policy of refuse, an audit for the
|
||||
/// handoff (scope cpa_handoff or full) at the current head must have a
|
||||
/// period attested on it at the bar. `Ok(None)` when it may; the
|
||||
/// refusal's words otherwise.
|
||||
pub(crate) fn cpa_audit_standing(&self, store: &Store) -> Result<Option<String>, String> {
|
||||
if !store
|
||||
.journal_format()
|
||||
.has_feature(ledger_store::format::AUDITS)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let settings = store.ledger().settings();
|
||||
let bar = settings.attestation_bar();
|
||||
let attestations = store.attestations().map_err(err)?;
|
||||
for run in store.audit_runs().map_err(err)?.iter().rev() {
|
||||
if !run.scope.serves_handoff() || !store.audit_is_current(run).map_err(err)? {
|
||||
continue;
|
||||
}
|
||||
let counted = attestations.iter().any(|a| {
|
||||
matches!(a.what, Attested::Period { audit_run_id, .. } if audit_run_id == run.id)
|
||||
&& bar.counts(&a.method, run.scope.full_bar(), false)
|
||||
});
|
||||
if counted {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
Ok(Some(format!(
|
||||
"no audit for the handoff (books.audit with scope cpa_handoff or full) stands at the books' current head with a period attested at the bar ({}); run the audit, have it verified (books.attest_receipt, books.review_entry, books.attest_period), or pass without_audit_reason",
|
||||
bar.describe()
|
||||
)))
|
||||
}
|
||||
|
||||
/// The findings the programme gives the books through `through`: the
|
||||
/// statements, then what only the store, the backups and the settings
|
||||
/// can see. Reads only.
|
||||
fn audit_findings(
|
||||
&self,
|
||||
store: &Store,
|
||||
through: NaiveDate,
|
||||
) -> Result<(Vec<Finding>, String), String> {
|
||||
let ledger = store.ledger();
|
||||
let cfg = self.settings();
|
||||
let ps = package_settings(&cfg);
|
||||
let receipts = store.entries_with_receipts().map_err(err)?;
|
||||
// The statement of cash flows, from the first entry, with the
|
||||
// books' role settings; none when they name no cash accounts.
|
||||
let first = ledger
|
||||
.posted_entries()
|
||||
.filter(|e| e.date() <= through)
|
||||
.map(|e| e.date())
|
||||
.min();
|
||||
let cash = match first {
|
||||
Some(start) if !ps.cash_accounts.is_empty() => Some(
|
||||
ledger_package::cash_flow_for(
|
||||
ledger,
|
||||
&ps,
|
||||
Some(&start.to_string()),
|
||||
Some(&through.to_string()),
|
||||
&[],
|
||||
&[],
|
||||
&[],
|
||||
today(),
|
||||
)
|
||||
.map(|s| s.reconciles),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
// Vendors paid in a way that could need a 1099 and undecided; a
|
||||
// year whose threshold isn't known is the stale-rule item's.
|
||||
use chrono::Datelike;
|
||||
let undecided: Vec<(String, rust_decimal::Decimal)> =
|
||||
ledger_package::vendor_1099_report(ledger, &ps, through.year(), None, Some(through))
|
||||
.map(|r| {
|
||||
r.vendors
|
||||
.iter()
|
||||
.filter(|v| v.decision_needed)
|
||||
.map(|v| (v.name.clone(), v.paid))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let problems = settings_problems(ledger, &cfg);
|
||||
let mut findings = audit::statements(
|
||||
ledger,
|
||||
&AuditInput {
|
||||
settings: ledger.settings(),
|
||||
receipts: &receipts,
|
||||
cash_flow: cash.as_ref(),
|
||||
settings_problems: &problems,
|
||||
undecided_1099: &undecided,
|
||||
},
|
||||
through,
|
||||
);
|
||||
findings.extend(store.integrity_findings());
|
||||
let standing = self.backups.standing(store);
|
||||
findings.extend(saudit::backup_findings(
|
||||
&standing
|
||||
.targets
|
||||
.iter()
|
||||
.map(|t| BackupStanding {
|
||||
target: format!("{} {}", t.target, t.at),
|
||||
stale: t.stale,
|
||||
last_error: t.last_error.clone(),
|
||||
drill_error: t.drill_error.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
));
|
||||
audit::sort(&mut findings);
|
||||
let tb = ledger_core::reports::trial_balance_as_of(ledger, through);
|
||||
let tb_hash = sha_hex(&serde_json::to_vec(&tb).map_err(err)?);
|
||||
Ok((findings, tb_hash))
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = audit_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
/// ```text
|
||||
/// GOAL: the whole programme of checks over the books through a day,
|
||||
/// its workpaper filed, the run journaled.
|
||||
/// SOURCE: plan/served.md P8; B-45; D-9, D-53.
|
||||
/// INVARIANTS:
|
||||
/// 1. Without `confirmed`, a preview that lists the checks and
|
||||
/// changes nothing; with it, the preview's digest is required.
|
||||
/// 2. Idempotent on the figure head, day, scope and programme.
|
||||
/// 3. The workpaper is JSON and PDF, filed as documents (category
|
||||
/// workpaper), the PDF with deterministic bytes.
|
||||
/// SCENARIOS:
|
||||
/// 1. IF the books haven't declared audits: the run is refused with
|
||||
/// format_upgrade_pending; the preview still lists the checks.
|
||||
/// 2. IF the same audit is asked at the same head: SHOULD return
|
||||
/// the first run, status already_run.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.audit",
|
||||
title = "Audit the books",
|
||||
description = "Audit the books through a day (through, default today) at a scope: month, annual, cpa_handoff (the year, for the CPA package) or full. It checks the statements, the A/R and A/P control accounts, closes, schedules, assets, each posted entry against the receipt policy, open drafts, tax categories, 1099 decisions and settings, then the journal, stored files, access log and backups. Without confirmed, a preview of the checks, changing nothing. With confirmed and the preview, it files the workpaper (books.workpaper) and journals the run; the same books, day and scope give the run that exists. Attestations rest on a run only while no entry, document, close, settings or role change follows it",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn audit(&self, params: Parameters<AuditParams>) -> Result<Json<AuditOut>, String> {
|
||||
let p = params.0;
|
||||
let scope = Scope::parse(&p.scope).ok_or_else(|| {
|
||||
format!(
|
||||
"bad scope {:?}: expected month, annual, cpa_handoff or full",
|
||||
p.scope
|
||||
)
|
||||
})?;
|
||||
let through = self.as_of(p.through.as_deref())?;
|
||||
let confirmed = p.confirmed.unwrap_or(false);
|
||||
let mut store = self.lock()?;
|
||||
let head = store.figure_head().map_err(err)?;
|
||||
let digest = self.preview_of(&store, "books.audit", confirmed, p.preview.as_deref())?;
|
||||
if let Some(run) = store.find_audit_run(through, head, scope).map_err(err)? {
|
||||
let findings = self.stored_findings(&store, &run)?;
|
||||
return Ok(Json(AuditOut {
|
||||
status: "already_run".into(),
|
||||
preview: Some(digest),
|
||||
head,
|
||||
checks: checks(),
|
||||
summary: Some(summary_of(&findings)),
|
||||
run: Some(run_out(&store, &run)?),
|
||||
findings,
|
||||
}));
|
||||
}
|
||||
if !confirmed {
|
||||
return Ok(Json(AuditOut {
|
||||
status: "preview".into(),
|
||||
preview: Some(digest),
|
||||
head,
|
||||
checks: checks(),
|
||||
run: None,
|
||||
findings: Vec::new(),
|
||||
summary: None,
|
||||
}));
|
||||
}
|
||||
store
|
||||
.ensure_feature(ledger_store::format::AUDITS)
|
||||
.map_err(err)?;
|
||||
let (findings, tb_hash) = self.audit_findings(&store, through)?;
|
||||
let findings_sha256 = sha_hex(&serde_json::to_vec(&findings).map_err(err)?);
|
||||
let summary = summary(&findings);
|
||||
let company = self.settings().company.name.clone();
|
||||
let rows: Vec<AuditFindingOut> = findings.iter().map(finding_out).collect();
|
||||
let check_rows = checks();
|
||||
let paper = serde_json::json!({
|
||||
"company": company,
|
||||
"title": "Audit workpaper",
|
||||
"scope": scope.key(),
|
||||
"through": through.to_string(),
|
||||
"through_seq": head,
|
||||
"program_version": saudit::PROGRAM_VERSION,
|
||||
"tb_hash": tb_hash,
|
||||
"findings_sha256": findings_sha256,
|
||||
"summary": summary,
|
||||
"findings": rows,
|
||||
"checks": check_rows,
|
||||
});
|
||||
let json_bytes = serde_json::to_vec_pretty(&paper).map_err(err)?;
|
||||
let pdf_bytes = ledger_import::workpaper_pdf(&ledger_import::Workpaper {
|
||||
company,
|
||||
title: "Audit workpaper".into(),
|
||||
scope: scope.key().into(),
|
||||
through: through.to_string(),
|
||||
through_seq: head,
|
||||
program_version: saudit::PROGRAM_VERSION.into(),
|
||||
tb_hash: tb_hash.clone(),
|
||||
findings_sha256: findings_sha256.clone(),
|
||||
summary: summary.clone(),
|
||||
findings: rows
|
||||
.iter()
|
||||
.map(|f| ledger_import::WorkpaperFinding {
|
||||
severity: f.severity.clone(),
|
||||
code: f.code.clone(),
|
||||
subject: f.subject.clone(),
|
||||
message: f.message.clone(),
|
||||
})
|
||||
.collect(),
|
||||
checks: check_rows
|
||||
.iter()
|
||||
.map(|c| ledger_import::WorkpaperCheck {
|
||||
code: c.code.clone(),
|
||||
severity: c.severity.clone(),
|
||||
predicate: c.predicate.clone(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.map_err(err)?;
|
||||
let stem = format!("workpaper-{}-{through}-{head}", scope.key());
|
||||
let describe = format!(
|
||||
"Audit workpaper, scope {}, through {through}, books at figure head {head} ({}).",
|
||||
scope.key(),
|
||||
saudit::PROGRAM_VERSION
|
||||
);
|
||||
let (wp_json, _, _) = store
|
||||
.file_document(
|
||||
"workpaper",
|
||||
&format!("{stem}.json"),
|
||||
"application/json",
|
||||
&json_bytes,
|
||||
Some(&describe),
|
||||
)
|
||||
.map_err(err)?;
|
||||
let (wp_pdf, _, _) = store
|
||||
.file_document(
|
||||
"workpaper",
|
||||
&format!("{stem}.pdf"),
|
||||
"application/pdf",
|
||||
&pdf_bytes,
|
||||
Some(&describe),
|
||||
)
|
||||
.map_err(err)?;
|
||||
// A month's or a year's workpaper belongs with its period.
|
||||
if matches!(scope, Scope::Month | Scope::Annual)
|
||||
&& let Some(period) = store
|
||||
.ledger()
|
||||
.list_periods()
|
||||
.into_iter()
|
||||
.filter(|q| q.start <= through && through <= q.end)
|
||||
.min_by_key(|q| (q.end - q.start).num_days())
|
||||
.map(|q| q.id)
|
||||
{
|
||||
for a in [wp_json, wp_pdf] {
|
||||
let _ = store.link_attachment(a, ledger_store::Subject::Period(period));
|
||||
}
|
||||
}
|
||||
let run = store
|
||||
.record_audit_run(saudit::NewAuditRun {
|
||||
through,
|
||||
through_seq: head,
|
||||
scope,
|
||||
tb_hash,
|
||||
findings_sha256,
|
||||
workpaper_attachment: Some(wp_json),
|
||||
workpaper_pdf: Some(wp_pdf),
|
||||
})
|
||||
.map_err(err)?;
|
||||
Ok(Json(AuditOut {
|
||||
status: "audited".into(),
|
||||
preview: Some(digest),
|
||||
head,
|
||||
checks: check_rows,
|
||||
summary: Some(summary),
|
||||
run: Some(run_out(&store, &run)?),
|
||||
findings: rows,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: an audit run's workpaper, whole.
|
||||
/// SOURCE: plan/served.md P8 step 3; B-45.
|
||||
/// INVARIANTS:
|
||||
/// 1. Read-only; the JSON is the one filed, the PDF is handed over.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.workpaper",
|
||||
title = "An audit's workpaper",
|
||||
description = "The workpaper of an audit run (books.audit's run.id): what was checked, through which day and state of the books, and every finding, as the JSON that was filed, with the PDF handed over (run its download command to save it in a folder). Changes nothing.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn workpaper(
|
||||
&self,
|
||||
params: Parameters<WorkpaperParams>,
|
||||
) -> Result<Json<WorkpaperOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let run = store.audit_run(params.0.id).map_err(err)?.ok_or_else(|| {
|
||||
format!(
|
||||
"no audit run {}: books.attestations lists the ones attested",
|
||||
params.0.id
|
||||
)
|
||||
})?;
|
||||
let json_id = run
|
||||
.workpaper_attachment
|
||||
.ok_or("this audit run filed no workpaper")?;
|
||||
let bytes = store.attachment_bytes(json_id).map_err(err)?;
|
||||
let workpaper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
|
||||
let pdf = match run.workpaper_pdf {
|
||||
Some(id) => {
|
||||
let bytes = store.attachment_bytes(id).map_err(err)?;
|
||||
Some(self.hand_over(
|
||||
&book_name(&store),
|
||||
&format!(
|
||||
"workpaper-{}-{}-{}.pdf",
|
||||
run.scope.key(),
|
||||
run.through,
|
||||
run.through_seq
|
||||
),
|
||||
"application/pdf",
|
||||
bytes,
|
||||
)?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
Ok(Json(WorkpaperOut {
|
||||
run: run_out(&store, &run)?,
|
||||
workpaper,
|
||||
pdf,
|
||||
}))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a checker's claim that a receipt supports an entry.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
|
||||
/// INVARIANTS:
|
||||
/// 1. The reading is compared with the entry's amount and date; a
|
||||
/// difference is recorded, marked, and never counts.
|
||||
/// 2. The method is the client's claim, self-reported.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.attest_receipt",
|
||||
title = "Attest a receipt",
|
||||
description = "Record that you read a receipt and it supports a posted entry: amount and date as you read them off it (get_receipt text, then page_image), and how you checked (method: model, reasoning_effort, adversarial, verifier_agents; your own claim, which the books record but cannot verify). The books compare the reading with the entry's amounts and date; a difference is recorded as disagrees and never counts. Whether it counts is weighed against policy.attestation_bar when asked.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn attest_receipt(
|
||||
&self,
|
||||
params: Parameters<AttestReceiptParams>,
|
||||
) -> Result<Json<AttestOut>, String> {
|
||||
let p = params.0;
|
||||
let amount = parse_amount(&p.amount)?;
|
||||
let date = NaiveDate::parse_from_str(p.date.trim(), "%Y-%m-%d")
|
||||
.map_err(|_| format!("{:?} isn't a date: give YYYY-MM-DD", p.date))?;
|
||||
let claim = Claim {
|
||||
by: caller_name(),
|
||||
method: method_of(p.method)?,
|
||||
note: p.note,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let a = store
|
||||
.attest_receipt(p.attachment_id, p.entry_id, amount, date, claim)
|
||||
.map_err(err)?;
|
||||
Ok(Json(attest_out(&store, &a)?))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a checker's claim that it reviewed an entry whole.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.review_entry",
|
||||
title = "Review an entry",
|
||||
description = "Record that you reviewed a posted entry whole: its lines, accounts, party and evidence, and how you checked (method, as for books.attest_receipt; your own claim). Changes no figure.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn review_entry(
|
||||
&self,
|
||||
params: Parameters<ReviewEntryParams>,
|
||||
) -> Result<Json<AttestOut>, String> {
|
||||
let p = params.0;
|
||||
let claim = Claim {
|
||||
by: caller_name(),
|
||||
method: method_of(p.method)?,
|
||||
note: p.note,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let a = store.review_entry(p.entry_id, claim).map_err(err)?;
|
||||
Ok(Json(attest_out(&store, &a)?))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: a checker's claim that a period is right, resting on an
|
||||
/// audit run at the books' current head.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45; D-9; IZ-1k.
|
||||
/// INVARIANTS:
|
||||
/// 1. Refused unless an audit through the period's end stands at
|
||||
/// the current head.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.attest_period",
|
||||
title = "Attest a period",
|
||||
description = "Record that you checked a period (its id or name from books.list_periods), resting on an audit (books.audit) through its end that still stands: refused when an entry, document, close, settings or role change has been made since the audit, or no audit covers it. Say how you checked (method, as for books.attest_receipt; your own claim). A year-level audit (annual, cpa_handoff, full) is held to the whole policy.attestation_bar: reasoning effort, adversarial, and the number of independent verifier agents.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn attest_period(
|
||||
&self,
|
||||
params: Parameters<AttestPeriodParams>,
|
||||
) -> Result<Json<AttestOut>, String> {
|
||||
let p = params.0;
|
||||
let claim = Claim {
|
||||
by: caller_name(),
|
||||
method: method_of(p.method)?,
|
||||
note: p.note,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let named = p.period.trim();
|
||||
let period = store
|
||||
.ledger()
|
||||
.list_periods()
|
||||
.into_iter()
|
||||
.find(|q| q.name == named || q.id.to_string() == named)
|
||||
.map(|q| q.id)
|
||||
.ok_or_else(|| format!("no period {named:?}: books.list_periods lists them"))?;
|
||||
let (a, _) = store.attest_period(period, claim).map_err(err)?;
|
||||
Ok(Json(attest_out(&store, &a)?))
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: every claim that someone checked, and whether it counts.
|
||||
/// SOURCE: plan/served.md P8 step 4; B-45; D-9.
|
||||
/// INVARIANTS:
|
||||
/// 1. `counted` is read against the bar as it stands now.
|
||||
/// ```
|
||||
#[tool(
|
||||
name = "books.attestations",
|
||||
title = "Attestations",
|
||||
description = "List the attestations (receipts read, entries reviewed, periods attested), oldest first, each with who made it, how they say they checked, and counted: whether it meets policy.attestation_bar as the bar stands now (a change to the bar changes what counts, earlier claims too). through limits to entries dated, and periods ending, on or before a day; kind to receipt, entry or period. Changes nothing.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn attestations(
|
||||
&self,
|
||||
params: Parameters<AttestationsParams>,
|
||||
) -> Result<Json<AttestationsOut>, String> {
|
||||
let p = params.0;
|
||||
let through = p
|
||||
.through
|
||||
.as_deref()
|
||||
.map(|t| self.as_of(Some(t)))
|
||||
.transpose()?;
|
||||
if let Some(k) = p.kind.as_deref()
|
||||
&& !["receipt", "entry", "period"].contains(&k)
|
||||
{
|
||||
return Err("kind is receipt, entry or period".into());
|
||||
}
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let bar = ledger.settings().attestation_bar();
|
||||
let mut out = Vec::new();
|
||||
for a in store.attestations().map_err(err)? {
|
||||
if p.kind.as_deref().is_some_and(|k| k != a.kind()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = through {
|
||||
let on = match a.what {
|
||||
Attested::Receipt { entry_id, .. } | Attested::Entry { entry_id } => {
|
||||
ledger.get_entry(entry_id).map(|e| e.date())
|
||||
}
|
||||
Attested::Period { period_id, .. } => {
|
||||
ledger.get_period(period_id).map(|q| q.end)
|
||||
}
|
||||
};
|
||||
if on.is_none_or(|d| d > t) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(attestation_out(&store, &a, &bar)?);
|
||||
}
|
||||
Ok(Json(AttestationsOut {
|
||||
bar: bar.describe(),
|
||||
counted: out.iter().filter(|a| a.counted).count(),
|
||||
attestations: out,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The findings of a run, as its filed workpaper holds them.
|
||||
fn stored_findings(
|
||||
&self,
|
||||
store: &Store,
|
||||
run: &AuditRun,
|
||||
) -> Result<Vec<AuditFindingOut>, String> {
|
||||
let Some(id) = run.workpaper_attachment else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let bytes = store.attachment_bytes(id).map_err(err)?;
|
||||
let paper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?;
|
||||
serde_json::from_value(paper["findings"].clone()).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_of(findings: &[AuditFindingOut]) -> String {
|
||||
let n = |s: &str| findings.iter().filter(|f| f.severity == s).count();
|
||||
format!(
|
||||
"{} failure(s), {} warning(s), {} note(s)",
|
||||
n("failure"),
|
||||
n("warning"),
|
||||
n("info")
|
||||
)
|
||||
}
|
||||
@@ -1147,6 +1147,51 @@ impl BooksServer {
|
||||
let p = params.0;
|
||||
let as_of = self.as_of(p.as_of.as_deref())?;
|
||||
let mut store = self.lock()?;
|
||||
// A package no counted audit stands behind is not final either
|
||||
// (plan P8, D-9): on books that declared `audits`, refused under
|
||||
// policy.cpa_package_requires_audit = refuse unless a reason is
|
||||
// given, which is journaled as a decision; under warn it goes and
|
||||
// says so. Books that haven't declared the feature are as they
|
||||
// were: there is no audit to ask for.
|
||||
let mut audit_warning: Option<String> = None;
|
||||
if let Some(why) = self.cpa_audit_standing(&store)? {
|
||||
if store.ledger().settings().cpa_package_requires_audit() {
|
||||
let Some(reason) = p
|
||||
.without_audit_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|r| !r.is_empty())
|
||||
else {
|
||||
return Err(serde_json::json!({
|
||||
"code": "audit_required",
|
||||
"message": why,
|
||||
"remedy_tool": "books.audit",
|
||||
})
|
||||
.to_string());
|
||||
};
|
||||
let by = CALLER
|
||||
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
|
||||
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string());
|
||||
store
|
||||
.record_decision(&ledger_core::conventions::NewDecision {
|
||||
subject: format!("CPA package as of {as_of} without a counted audit"),
|
||||
decision_md: format!(
|
||||
"The CPA package as of {as_of} was exported without an audit at the books' current head attested at the bar. Reason: {reason}"
|
||||
),
|
||||
decided_by: vec![by],
|
||||
decided_on: today(),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: Vec::new(),
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
audit_warning = Some(format!(
|
||||
"No counted audit stands behind this package: {why}."
|
||||
));
|
||||
}
|
||||
}
|
||||
// A package with a draft missing from its figures is not final
|
||||
// (plan P4, 4.8): refused unless the human says why it goes anyway,
|
||||
// and that is journaled as a decision.
|
||||
@@ -1245,7 +1290,8 @@ impl BooksServer {
|
||||
"The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again."
|
||||
),
|
||||
};
|
||||
let warnings = package.warnings;
|
||||
let mut warnings = package.warnings;
|
||||
warnings.extend(audit_warning);
|
||||
if package.iif_changes > 0 {
|
||||
note.push_str(&format!(
|
||||
" {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.",
|
||||
|
||||
@@ -4323,6 +4323,13 @@ pub struct ExportCpaPackageParams {
|
||||
/// missing from its figures is not the CPA's final.
|
||||
#[serde(default)]
|
||||
pub allow_open_drafts: Option<AllowOpenDraftsParam>,
|
||||
/// Hand the package over although no audit for the handoff (scope
|
||||
/// cpa_handoff or full) at the books' current head has its period
|
||||
/// attested at the bar, and say why (journaled as a decision). Without
|
||||
/// it the export is refused while `policy.cpa_package_requires_audit`
|
||||
/// is refuse and books have declared audits.
|
||||
#[serde(default)]
|
||||
pub without_audit_reason: Option<String>,
|
||||
/// As of this day ([`AS_OF_DOC`]); the period reports cover the
|
||||
/// fiscal year that holds it, through it.
|
||||
#[schemars(description = AS_OF_DOC)]
|
||||
@@ -5856,3 +5863,275 @@ pub struct AllowOpenDraftsParam {
|
||||
/// before the Figma invoice arrives"); journaled as a decision.
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
// P8
|
||||
|
||||
/// How an attestation was made, as the client says. The books record the
|
||||
/// claim; they cannot check it.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestMethodParam {
|
||||
/// The model that checked, as it names itself ("claude-opus-4").
|
||||
#[serde(default)]
|
||||
pub model: Option<String>,
|
||||
/// How hard it thought: low, medium, high, xhigh or max.
|
||||
#[serde(default)]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// True when the check was adversarial: a verifier set to find what
|
||||
/// is wrong, not to confirm it.
|
||||
#[serde(default)]
|
||||
pub adversarial: Option<bool>,
|
||||
/// How many independent verifier agents checked.
|
||||
#[serde(default)]
|
||||
pub verifier_agents: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.audit`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuditParams {
|
||||
/// The audit covers the books through this day ([`AS_OF_DOC`] for the
|
||||
/// forms a day takes). Defaults to today.
|
||||
#[serde(default)]
|
||||
pub through: Option<String>,
|
||||
/// month (a month-end attestation), annual (a fiscal year),
|
||||
/// cpa_handoff (the year, for the CPA package) or full (everything
|
||||
/// since the first entry).
|
||||
pub scope: String,
|
||||
/// True: run the audit and file its workpaper. Without it, a preview
|
||||
/// that lists what will be checked and changes nothing.
|
||||
#[serde(default)]
|
||||
pub confirmed: Option<bool>,
|
||||
/// The `preview` the call without `confirmed` returned. Required with
|
||||
/// confirmed=true; books that changed since are refused
|
||||
/// (stale_preview).
|
||||
#[serde(default)]
|
||||
pub preview: Option<String>,
|
||||
}
|
||||
|
||||
/// One check the audit runs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AuditCheckOut {
|
||||
/// The finding code the check reports.
|
||||
pub code: String,
|
||||
/// failure, warning or info.
|
||||
pub severity: String,
|
||||
/// What is true when it is reported.
|
||||
pub predicate: String,
|
||||
}
|
||||
|
||||
/// One thing an audit found.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AuditFindingOut {
|
||||
/// failure, warning or info.
|
||||
pub severity: String,
|
||||
/// The finding code.
|
||||
pub code: String,
|
||||
/// What it is about: `entry 7`, `account 12`, `books`.
|
||||
pub subject: String,
|
||||
/// What was found, with the exact figures.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// An audit run as the journal records it.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AuditRunOut {
|
||||
/// Its id.
|
||||
pub id: u64,
|
||||
/// The books it covers through this day.
|
||||
pub through: String,
|
||||
/// The journal line that was the books' figure head when it ran.
|
||||
pub through_seq: u64,
|
||||
/// month, annual, cpa_handoff or full.
|
||||
pub scope: String,
|
||||
/// The audit programme's version.
|
||||
pub program_version: String,
|
||||
/// SHA-256 of the trial balance through the day.
|
||||
pub tb_hash: String,
|
||||
/// SHA-256 of the findings.
|
||||
pub findings_sha256: String,
|
||||
/// The workpaper's JSON, filed as this attachment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workpaper_attachment: Option<u64>,
|
||||
/// The workpaper's PDF, filed as this attachment.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workpaper_pdf: Option<u64>,
|
||||
/// True while no entry, document, close, settings or role change has
|
||||
/// been made since: attestations rest on a run only while it is.
|
||||
pub at_current_head: bool,
|
||||
}
|
||||
|
||||
/// Result of `books.audit`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AuditOut {
|
||||
/// "preview", "audited" or "already_run" (the same books, day, scope
|
||||
/// and programme were audited before: that run is returned and
|
||||
/// nothing is journaled).
|
||||
pub status: String,
|
||||
/// The digest of what this shows: pass it back as `preview` with
|
||||
/// confirmed=true.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview: Option<String>,
|
||||
/// The books' figure head the audit rests on.
|
||||
pub head: u64,
|
||||
/// The checks it runs (a preview lists them; so does a run).
|
||||
pub checks: Vec<AuditCheckOut>,
|
||||
/// The run, once made.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub run: Option<AuditRunOut>,
|
||||
/// What it found; empty when the books are whole. Not in a preview.
|
||||
#[serde(default)]
|
||||
pub findings: Vec<AuditFindingOut>,
|
||||
/// How many failures, warnings and notes.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.workpaper`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkpaperParams {
|
||||
/// The audit run's id (`books.audit`'s run.id).
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
/// Result of `books.workpaper`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct WorkpaperOut {
|
||||
/// The run.
|
||||
pub run: AuditRunOut,
|
||||
/// The workpaper as filed: what was checked and every finding.
|
||||
pub workpaper: serde_json::Value,
|
||||
/// The workpaper's PDF, handed over.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pdf: Option<HandedFile>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.attest_receipt`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestReceiptParams {
|
||||
/// The filed receipt.
|
||||
pub attachment_id: u64,
|
||||
/// The posted entry it supports.
|
||||
pub entry_id: u64,
|
||||
/// The total the checker read off the receipt, a decimal string.
|
||||
pub amount: String,
|
||||
/// The date the checker read off the receipt, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// How the check was made.
|
||||
#[serde(default)]
|
||||
pub method: Option<AttestMethodParam>,
|
||||
/// Anything the next reader should know.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.review_entry`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReviewEntryParams {
|
||||
/// The posted entry reviewed.
|
||||
pub entry_id: u64,
|
||||
/// How the review was made.
|
||||
#[serde(default)]
|
||||
pub method: Option<AttestMethodParam>,
|
||||
/// Anything the next reader should know.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.attest_period`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestPeriodParams {
|
||||
/// The period: its id or its name ("2026-10"), from
|
||||
/// `books.list_periods`.
|
||||
pub period: String,
|
||||
/// How the attestation was made.
|
||||
#[serde(default)]
|
||||
pub method: Option<AttestMethodParam>,
|
||||
/// Anything the next reader should know.
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// One attestation, and whether it counts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct AttestationOut {
|
||||
/// Its journal line.
|
||||
pub seq: u64,
|
||||
/// receipt, entry or period.
|
||||
pub kind: String,
|
||||
/// Who made it: the token's name.
|
||||
pub by: String,
|
||||
/// When, UTC.
|
||||
pub at: String,
|
||||
/// The attachment, for a receipt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub attachment_id: Option<u64>,
|
||||
/// The entry, for a receipt or an entry review.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub entry_id: Option<u64>,
|
||||
/// The period, for a period attestation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub period_id: Option<u64>,
|
||||
/// The audit run, for a period attestation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub audit_run_id: Option<u64>,
|
||||
/// The amount read off the receipt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub amount_checked: Option<String>,
|
||||
/// The date read off the receipt.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub date_checked: Option<String>,
|
||||
/// True when the reading differs from the entry's amount or date: it
|
||||
/// never counts.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub disagrees: bool,
|
||||
/// How the client says it checked (self-reported; the books cannot
|
||||
/// verify it).
|
||||
pub method: AttestMethodParam,
|
||||
/// The note.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// Whether it meets `policy.attestation_bar` as the bar stands now.
|
||||
pub counted: bool,
|
||||
}
|
||||
|
||||
/// Result of `books.attest_receipt`, `books.review_entry` and
|
||||
/// `books.attest_period`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AttestOut {
|
||||
/// What was recorded.
|
||||
pub attestation: AttestationOut,
|
||||
/// The bar it was weighed against.
|
||||
pub bar: String,
|
||||
/// Why it does not count, when it does not.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub why_not_counted: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.attestations`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AttestationsParams {
|
||||
/// Only attestations of entries dated, or periods ending, on or
|
||||
/// before this day.
|
||||
#[serde(default)]
|
||||
pub through: Option<String>,
|
||||
/// Only this kind: receipt, entry or period.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.attestations`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct AttestationsOut {
|
||||
/// The bar `counted` is weighed against, as it stands now.
|
||||
pub bar: String,
|
||||
/// The attestations, oldest first.
|
||||
pub attestations: Vec<AttestationOut>,
|
||||
/// How many count.
|
||||
pub counted: usize,
|
||||
}
|
||||
@@ -78,7 +78,12 @@
|
||||
//! pages); a posted entry a member paid personally (a credit on a
|
||||
//! "due to"/"owed to" liability) that no entry records settling
|
||||
//! (Todo, one per entry; `entry.settles` only, no FIFO guess,
|
||||
//! IZ-18).
|
||||
//! IZ-18); for books that declared `audits` (plan P8): a closed
|
||||
//! period no `period.attested` names (Todo, one per period, "Month-end
|
||||
//! attestation due"), the last fiscal year that ended with entries,
|
||||
//! closed, and no audit run of scope cpa_handoff or full through its
|
||||
//! end ("Full audit due before the CPA handoff"), else with no run of
|
||||
//! scope annual, cpa_handoff or full ("Annual audit due").
|
||||
//! 3. Nothing else: books with none of those yield no items, except
|
||||
//! that a figure behind an item that can't be computed (an
|
||||
//! amortization plan past chrono's last day, an aging past exact
|
||||
@@ -1375,10 +1380,133 @@ pub fn find_with(
|
||||
});
|
||||
}
|
||||
}
|
||||
out.extend(audit_items(books, &journal, today, settings));
|
||||
out.sort_by_key(|a| a.severity);
|
||||
out
|
||||
}
|
||||
|
||||
/// The audit items (plan P8, R-F), for books that declared `audits` (the
|
||||
/// others can't act on them): a closed month with no attestation, a closed
|
||||
/// fiscal year with no audit for the handoff, and a finished year with no
|
||||
/// annual audit. Read from the journal's `audit.run` and `period.attested`
|
||||
/// lines, so the viewer and `books.start` read the same facts.
|
||||
fn audit_items(
|
||||
books: &impl PackageBooks,
|
||||
journal: &[JournalLine],
|
||||
today: NaiveDate,
|
||||
settings: &Settings,
|
||||
) -> Vec<Attention> {
|
||||
let declared = journal
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|l| l.line_type == "journal.format_set")
|
||||
.is_some_and(|l| {
|
||||
l.payload["features"]
|
||||
.as_array()
|
||||
.is_some_and(|f| f.iter().any(|x| x == "audits"))
|
||||
});
|
||||
if !declared {
|
||||
return Vec::new();
|
||||
}
|
||||
let ledger = books.ledger();
|
||||
let attested: HashSet<u64> = journal
|
||||
.iter()
|
||||
.filter(|l| l.line_type == "period.attested")
|
||||
.filter_map(|l| l.payload["period_id"].as_u64())
|
||||
.collect();
|
||||
// (scope, through) of every run.
|
||||
let runs: Vec<(String, NaiveDate)> = journal
|
||||
.iter()
|
||||
.filter(|l| l.line_type == "audit.run")
|
||||
.filter_map(|l| {
|
||||
let through =
|
||||
NaiveDate::parse_from_str(l.payload["through"].as_str()?, "%Y-%m-%d").ok()?;
|
||||
Some((l.payload["scope"].as_str()?.to_string(), through))
|
||||
})
|
||||
.collect();
|
||||
let mut out = Vec::new();
|
||||
for p in ledger.list_periods() {
|
||||
if p.status != PeriodStatus::Closed || attested.contains(&p.id) {
|
||||
continue;
|
||||
}
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
kind: "month_attestation_due",
|
||||
title: format!("Month-end attestation due: {}", p.name),
|
||||
detail: format!(
|
||||
"{} ({} to {}) is closed and no one has attested it.",
|
||||
p.name,
|
||||
date(p.start),
|
||||
date(p.end)
|
||||
),
|
||||
target: Target::Journal,
|
||||
prompt: format!(
|
||||
"In wanderbooks, {} is closed and has no attestation. Audit the books through {} (books.audit with scope month), then have separate verifier agents read each receipt (books.get_receipt, then attest_receipt), review the entries (books.review_entry) and attest the period (books.attest_period). Read the attestations-and-audit section of the handbook first.",
|
||||
p.name,
|
||||
p.end
|
||||
),
|
||||
tool: Some((
|
||||
"books.audit".into(),
|
||||
serde_json::json!({ "scope": "month", "through": p.end.to_string() }),
|
||||
)),
|
||||
});
|
||||
}
|
||||
// The last fiscal year that ended, if the books have entries in it.
|
||||
let (this_start, _) = settings.company.fiscal_year_containing(today);
|
||||
if let Some(before) = this_start.pred_opt() {
|
||||
let (fy_start, fy_end) = settings.company.fiscal_year_containing(before);
|
||||
let has_entries = ledger
|
||||
.posted_entries()
|
||||
.any(|e| e.date() >= fy_start && e.date() <= fy_end);
|
||||
let closed = ledger
|
||||
.list_periods()
|
||||
.iter()
|
||||
.any(|p| p.status == PeriodStatus::Closed && p.end >= fy_end);
|
||||
let year_audit = |scopes: &[&str]| {
|
||||
runs.iter()
|
||||
.any(|(s, t)| scopes.contains(&s.as_str()) && *t >= fy_end)
|
||||
};
|
||||
if has_entries && closed && !year_audit(&["cpa_handoff", "full"]) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
kind: "handoff_audit_due",
|
||||
title: format!("Full audit due before the CPA handoff: {}", fy_end.year()),
|
||||
detail: format!(
|
||||
"The fiscal year ended {} and is closed; the CPA package waits for an audit for the handoff attested at the bar.",
|
||||
date(fy_end)
|
||||
),
|
||||
target: Target::Journal,
|
||||
prompt: format!(
|
||||
"In wanderbooks, the fiscal year ending {fy_end} is closed and has no audit for the CPA handoff. Run books.audit with scope cpa_handoff through {fy_end}, have independent adversarial verifier agents attest the receipts and the entries, and attest the year (books.attest_period). Read the attestations-and-audit section of the handbook first."
|
||||
),
|
||||
tool: Some((
|
||||
"books.audit".into(),
|
||||
serde_json::json!({ "scope": "cpa_handoff", "through": fy_end.to_string() }),
|
||||
)),
|
||||
});
|
||||
} else if has_entries && !year_audit(&["annual", "cpa_handoff", "full"]) {
|
||||
out.push(Attention {
|
||||
severity: Severity::Todo,
|
||||
kind: "annual_audit_due",
|
||||
title: format!("Annual audit due: {}", fy_end.year()),
|
||||
detail: format!(
|
||||
"The fiscal year ended {} and no audit covers it.",
|
||||
date(fy_end)
|
||||
),
|
||||
target: Target::Journal,
|
||||
prompt: format!(
|
||||
"In wanderbooks, the fiscal year ending {fy_end} has no annual audit. Run books.audit with scope annual through {fy_end} and fix what it finds. Read the attestations-and-audit section of the handbook first."
|
||||
),
|
||||
tool: Some((
|
||||
"books.audit".into(),
|
||||
serde_json::json!({ "scope": "annual", "through": fy_end.to_string() }),
|
||||
)),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One entry, indexed the way the items read it.
|
||||
struct Entry {
|
||||
id: EntryId,
|
||||
|
||||
Reference in new issue
Block a user