feat(audit): audit runs and attestations are journaled (P8)
A claim that someone checked the books is evidence the CPA handoff rests on, so it is a journal fact and replays: audit.run (a programme run through a day at the books' figure head, with the hashes that make it reproducible and the two filed workpapers), receipt.attested, entry.reviewed and period.attested. Behind the journal feature audits, schema 20; no figure moves. The figure head is the last line that bears on a figure: filing a workpaper, a run or an attestation never makes an audit stale, an entry, document, close, settings or role change does, so a period is attested only on an audit that still describes the books. A receipt reading that differs from the entry is recorded as disagreeing and never counts; whether a claim counts is derived against the bar when asked, never stored. The store also reads what a ledger cannot: the chain, the database against a replay, stored and extracted files, the access log, duplicate files, and the backups' standing handed in by the server. Fixture old-books/p8_before was written by the version before this one and opens to its figures. period.attested was the tests' placeholder for an unknown event type; they use ledger.not_yet_written now. 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
843bcc421f
commit
6b7dccd1ee
19 files changed
+2479
-18
No files matched your search
@@ -0,0 +1,612 @@
|
||||
//! Audits and attestations in the store (plan P8; rules.md B-45): the
|
||||
//! `audits` feature, the figure head, idempotent runs, attestations that
|
||||
//! rest on a head, and the integrity findings only the store can see.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::audit::{Effort, Method, Scope};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::audit::{self, Attested, BackupStanding, Claim, NewAuditRun, backup_findings};
|
||||
use ledger_store::format::{AUDITS, JournalFormat};
|
||||
use ledger_store::{BlobStore, JOURNAL_FILE, JournalWriter, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("wb-audit-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(s.parse().unwrap(), Currency::USD)
|
||||
}
|
||||
|
||||
struct Fx {
|
||||
bank: u64,
|
||||
capital: u64,
|
||||
supplies: u64,
|
||||
/// A posted 40.00 purchase.
|
||||
entry: u64,
|
||||
}
|
||||
|
||||
/// A bank, capital and an expense account, and a posted 40.00 purchase on
|
||||
/// the 5th.
|
||||
fn books(dir: &std::path::Path, format: Option<&JournalFormat>) -> (Store, Fx) {
|
||||
let mut s = match format {
|
||||
Some(f) => Store::create_in_format(dir, Currency::USD, f).unwrap(),
|
||||
None => Store::create(dir, Currency::USD).unwrap(),
|
||||
};
|
||||
let bank = s
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let supplies = s
|
||||
.add_account("6000", "Supplies", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(1, 1), "Opening").unwrap();
|
||||
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let entry = s.create_entry(d(1, 5), "Printer paper").unwrap();
|
||||
s.add_line(entry, supplies, Side::Debit, usd("40.00"))
|
||||
.unwrap();
|
||||
s.add_line(entry, bank, Side::Credit, usd("40.00")).unwrap();
|
||||
s.post_entry(entry).unwrap();
|
||||
(
|
||||
s,
|
||||
Fx {
|
||||
bank,
|
||||
capital,
|
||||
supplies,
|
||||
entry,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn run_of(s: &Store, through: NaiveDate, scope: Scope) -> NewAuditRun {
|
||||
NewAuditRun {
|
||||
through,
|
||||
through_seq: s.figure_head().unwrap(),
|
||||
scope,
|
||||
tb_hash: "tb".into(),
|
||||
findings_sha256: "fs".into(),
|
||||
workpaper_attachment: None,
|
||||
workpaper_pdf: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(effort: &str) -> Claim {
|
||||
Claim {
|
||||
by: "verifier-1".into(),
|
||||
method: Method {
|
||||
model: Some("opus".into()),
|
||||
reasoning_effort: Effort::parse(effort),
|
||||
adversarial: Some(true),
|
||||
verifier_agents: Some(2),
|
||||
},
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn journal_bytes(dir: &std::path::Path) -> Vec<u8> {
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
|
||||
}
|
||||
|
||||
/// R-A: every write of the feature waits for the owner's upgrade,
|
||||
/// journaling nothing; afterwards it is taken and the books replay to
|
||||
/// what was live.
|
||||
#[test]
|
||||
fn audit_writes_wait_for_audits() {
|
||||
let dir = test_dir("waits");
|
||||
let (mut s, fx) = books(&dir, Some(&JournalFormat::default()));
|
||||
assert!(!s.journal_format().has_feature(AUDITS));
|
||||
let (att, _, _) = s
|
||||
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
|
||||
.unwrap();
|
||||
let before = journal_bytes(&dir);
|
||||
let new = run_of(&s, d(1, 31), Scope::Month);
|
||||
for r in [
|
||||
s.record_audit_run(new.clone()).map(|_| ()),
|
||||
s.attest_receipt(
|
||||
att,
|
||||
fx.entry,
|
||||
"40.00".parse().unwrap(),
|
||||
d(1, 5),
|
||||
claim("high"),
|
||||
)
|
||||
.map(|_| ()),
|
||||
s.review_entry(fx.entry, claim("high")).map(|_| ()),
|
||||
] {
|
||||
match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature audits")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(journal_bytes(&dir), before, "a refusal journals nothing");
|
||||
s.upgrade_format().unwrap();
|
||||
let run = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
|
||||
.unwrap();
|
||||
s.review_entry(fx.entry, claim("high")).unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.audit_runs().unwrap(), vec![run]);
|
||||
assert_eq!(s.attestations().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// INVARIANT 1 and 2: filing a workpaper and journaling the run do not
|
||||
/// move the figure head; an entry does; the same audit asked again
|
||||
/// returns the first run and journals nothing.
|
||||
#[test]
|
||||
fn a_run_is_idempotent_on_the_figure_head() {
|
||||
let dir = test_dir("idem");
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let head = s.figure_head().unwrap();
|
||||
let (wp, _, _) = s
|
||||
.file_document("workpaper", "w.json", "application/json", b"{}", None)
|
||||
.unwrap();
|
||||
assert_eq!(s.figure_head().unwrap(), head, "a filing is not a figure");
|
||||
let mut new = run_of(&s, d(1, 31), Scope::Month);
|
||||
new.workpaper_attachment = Some(wp);
|
||||
let first = s.record_audit_run(new.clone()).unwrap();
|
||||
assert_eq!(first.id, 1);
|
||||
assert_eq!(first.through_seq, head);
|
||||
assert_eq!(s.figure_head().unwrap(), head, "a run is not a figure");
|
||||
let bytes = journal_bytes(&dir);
|
||||
let again = s.record_audit_run(new).unwrap();
|
||||
assert_eq!(again, first);
|
||||
assert_eq!(journal_bytes(&dir), bytes, "asking again journals nothing");
|
||||
assert!(s.audit_is_current(&first).unwrap());
|
||||
// Another scope is another run.
|
||||
let other = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Full))
|
||||
.unwrap();
|
||||
assert_eq!(other.id, 2);
|
||||
// An entry moves the head: the run no longer stands.
|
||||
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
|
||||
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
|
||||
.unwrap();
|
||||
s.add_line(e, fx.bank, Side::Credit, usd("3.00")).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
assert!(s.figure_head().unwrap() > head);
|
||||
assert!(!s.audit_is_current(&first).unwrap());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A receipt reading that differs from the entry's is recorded, marked,
|
||||
/// and never counts; one that agrees is not marked. The receipt must be on
|
||||
/// the entry.
|
||||
#[test]
|
||||
fn amount_mismatch_never_counts() {
|
||||
let dir = test_dir("mismatch");
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let (att, _, _) = s
|
||||
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper", None)
|
||||
.unwrap();
|
||||
let other = s
|
||||
.attach_receipt(None, "x.txt", "text/plain", b"stray", None)
|
||||
.unwrap()
|
||||
.0;
|
||||
let amount = |t: &str| -> Decimal { t.parse().unwrap() };
|
||||
let disagrees = |a: &ledger_store::audit::Attestation| match a.what {
|
||||
Attested::Receipt { disagrees, .. } => disagrees,
|
||||
_ => panic!("not a receipt"),
|
||||
};
|
||||
let ok = s
|
||||
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 5), claim("high"))
|
||||
.unwrap();
|
||||
assert!(!disagrees(&ok));
|
||||
let wrong_amount = s
|
||||
.attest_receipt(att, fx.entry, amount("41.00"), d(1, 5), claim("high"))
|
||||
.unwrap();
|
||||
assert!(disagrees(&wrong_amount));
|
||||
let wrong_date = s
|
||||
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 6), claim("high"))
|
||||
.unwrap();
|
||||
assert!(disagrees(&wrong_date));
|
||||
// Refused: a receipt not on the entry, a draft, an unknown entry.
|
||||
assert!(matches!(
|
||||
s.attest_receipt(other, fx.entry, amount("40.00"), d(1, 5), claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
let draft = s.create_entry(d(2, 2), "Draft").unwrap();
|
||||
assert!(matches!(
|
||||
s.review_entry(draft, claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
assert!(s.review_entry(999, claim("high")).is_err());
|
||||
// All three kinds replay, in order.
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
let got = s.attestations().unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
assert!(got.windows(2).all(|w| w[0].seq < w[1].seq));
|
||||
assert_eq!(got[0].method.reasoning_effort, Some(Effort::High));
|
||||
assert_eq!(got[0].by, "verifier-1");
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a period is attested only on a run at the current head
|
||||
/// that covers it; an entry since makes the run stale and the attestation
|
||||
/// is refused until the audit is run again.
|
||||
#[test]
|
||||
fn stale_head_period_attestation_refused() {
|
||||
let dir = test_dir("stale");
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let jan = s.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
// No audit yet.
|
||||
assert!(matches!(
|
||||
s.attest_period(jan, claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
// An audit through before the period's end does not cover it.
|
||||
s.record_audit_run(run_of(&s, d(1, 15), Scope::Month))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
s.attest_period(jan, claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
let run = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
|
||||
.unwrap();
|
||||
let (a, on) = s.attest_period(jan, claim("high")).unwrap();
|
||||
assert_eq!(on.id, run.id);
|
||||
assert_eq!(
|
||||
a.what,
|
||||
Attested::Period {
|
||||
period_id: jan,
|
||||
audit_run_id: run.id
|
||||
}
|
||||
);
|
||||
// An entry since: the run is stale.
|
||||
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
|
||||
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
|
||||
.unwrap();
|
||||
s.add_line(e, fx.capital, Side::Credit, usd("3.00"))
|
||||
.unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let head = s.head();
|
||||
match s.attest_period(jan, claim("high")) {
|
||||
Err(StoreError::AuditRefused(why)) => assert!(why.contains("books.audit"), "{why}"),
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.head(), head, "a refusal journals nothing");
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// The integrity table: each code seeded alone.
|
||||
#[test]
|
||||
fn integrity_detects_each_seeded_defect() {
|
||||
// A whole book finds nothing.
|
||||
{
|
||||
let dir = test_dir("whole");
|
||||
let (s, _) = books(&dir, None);
|
||||
assert_eq!(s.integrity_findings(), vec![]);
|
||||
}
|
||||
let seed = |code: &str| -> Vec<&'static str> {
|
||||
let dir = test_dir(&format!("seed-{code}"));
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let found = match code {
|
||||
"blob_missing" | "blob_damaged" | "extraction_blob_missing" => {
|
||||
let (_, sha, _) = s
|
||||
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
|
||||
.unwrap();
|
||||
let target = if code == "extraction_blob_missing" {
|
||||
// The text the extractor read, content-addressed too.
|
||||
let x = s.extractions().unwrap().pop().expect("an extraction");
|
||||
x.text_sha256.expect("text")
|
||||
} else {
|
||||
sha
|
||||
};
|
||||
let path = dir.join("blobs").join(&target[..2]).join(&target);
|
||||
let path = if path.exists() {
|
||||
path
|
||||
} else {
|
||||
// The store's own layout.
|
||||
walk(&dir.join("blobs"), &target).expect("the blob file")
|
||||
};
|
||||
if code == "blob_damaged" {
|
||||
std::fs::write(&path, b"tampered").unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
s.integrity_findings()
|
||||
}
|
||||
"duplicate_blob" => {
|
||||
drop(s);
|
||||
let bytes = b"filed twice by an older version";
|
||||
let (sha, _) = BlobStore::open(&dir.join("blobs"))
|
||||
.unwrap()
|
||||
.put(bytes)
|
||||
.unwrap();
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] {
|
||||
w.append(
|
||||
"receipt.attached",
|
||||
json!({
|
||||
"attachment_id": id, "blob_sha": sha, "entry_id": null,
|
||||
"category": "receipts", "file_name": name,
|
||||
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
|
||||
"description": null,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
Store::open(&dir).unwrap().integrity_findings()
|
||||
}
|
||||
"access_log_damaged" => {
|
||||
std::fs::write(
|
||||
dir.join("access.jsonl"),
|
||||
b"{\"not\": \"a chained record\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
s.integrity_findings()
|
||||
}
|
||||
"projection_differs" => {
|
||||
let db = ledger_store::database_in_use(&dir);
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn.execute("DELETE FROM lines", []).unwrap();
|
||||
drop(conn);
|
||||
s.integrity_findings()
|
||||
}
|
||||
"journal_damaged" => {
|
||||
let path = dir.join(JOURNAL_FILE);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::write(&path, text.replacen("Printer paper", "Printer papers", 1)).unwrap();
|
||||
s.integrity_findings()
|
||||
}
|
||||
"backup_stale" => backup_findings(&[BackupStanding {
|
||||
target: "folder".into(),
|
||||
stale: true,
|
||||
last_error: None,
|
||||
drill_error: None,
|
||||
}]),
|
||||
"drill_failed" => backup_findings(&[BackupStanding {
|
||||
target: "folder".into(),
|
||||
stale: false,
|
||||
last_error: None,
|
||||
drill_error: Some("restore did not reach the head".into()),
|
||||
}]),
|
||||
other => panic!("no seed for {other}"),
|
||||
};
|
||||
found.iter().map(|f| f.code).collect()
|
||||
};
|
||||
for row in audit::CODES {
|
||||
let got = seed(row.code);
|
||||
assert!(got.contains(&row.code), "{}: found {got:?}", row.code);
|
||||
// The seed implies nothing else but its own consequences: a
|
||||
// missing or damaged file is found once; a damaged journal can
|
||||
// also differ from its replay.
|
||||
for c in &got {
|
||||
assert!(
|
||||
*c == row.code
|
||||
|| (row.code == "journal_damaged" && *c == "projection_differs")
|
||||
// verify reads a file's text too, so a damaged or missing
|
||||
// stored file is also a replay that cannot be made.
|
||||
|| (row.code.starts_with("blob_") || row.code == "extraction_blob_missing")
|
||||
&& [
|
||||
"blob_missing",
|
||||
"blob_damaged",
|
||||
"extraction_blob_missing",
|
||||
"projection_differs",
|
||||
]
|
||||
.contains(c),
|
||||
"{}: also found {c}",
|
||||
row.code
|
||||
);
|
||||
}
|
||||
}
|
||||
// No backups set up is the owner's standing choice, not a finding.
|
||||
assert!(backup_findings(&[]).is_empty());
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
|
||||
for e in std::fs::read_dir(dir).ok()? {
|
||||
let p = e.ok()?.path();
|
||||
if p.is_dir() {
|
||||
if let Some(f) = walk(&p, name) {
|
||||
return Some(f);
|
||||
}
|
||||
} else if p.file_name().is_some_and(|n| n == name) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 8.0.6 item 2: the books the version before P8 wrote
|
||||
/// (`old-books/p8_before`) open to the same journal, have no audit run or
|
||||
/// claim, are whole (nothing for the integrity checks to find), refuse
|
||||
/// the new writes until the owner's upgrade, and take them after it.
|
||||
#[test]
|
||||
fn books_the_version_before_p8_wrote_wait_for_the_upgrade() {
|
||||
let from =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p8_before");
|
||||
let dir = test_dir("p8-before");
|
||||
std::fs::create_dir_all(dir.join("blobs")).unwrap();
|
||||
std::fs::copy(from.join(JOURNAL_FILE), dir.join(JOURNAL_FILE)).unwrap();
|
||||
fn copy(from: &std::path::Path, to: &std::path::Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap() {
|
||||
let e = e.unwrap();
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
copy(&e.path(), &to.join(e.file_name()));
|
||||
} else {
|
||||
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
copy(&from.join("blobs"), &dir.join("blobs"));
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert!(!s.journal_format().has_feature(AUDITS));
|
||||
assert!(s.audit_runs().unwrap().is_empty());
|
||||
assert!(s.attestations().unwrap().is_empty());
|
||||
assert_eq!(s.integrity_findings(), vec![]);
|
||||
// The head is the last figure line; the filings after it don't move it.
|
||||
let head = s.figure_head().unwrap();
|
||||
assert!(head > 0 && head <= s.head().0);
|
||||
let jan = s.ledger().list_periods()[0].id;
|
||||
let before = journal_bytes(&dir);
|
||||
assert!(matches!(
|
||||
s.record_audit_run(run_of(&s, d(1, 31), Scope::Month)),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
s.attest_period(jan, claim("high")),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
assert_eq!(journal_bytes(&dir), before, "refused: nothing journaled");
|
||||
s.upgrade_format().unwrap();
|
||||
let run = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
|
||||
.unwrap();
|
||||
s.attest_period(jan, claim("high")).unwrap();
|
||||
assert_eq!(s.figure_head().unwrap(), head, "claims are not figures");
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.audit_runs().unwrap(), vec![run]);
|
||||
}
|
||||
|
||||
/// Done-when 2: books with an unfooted cash flow, a posted entry with no
|
||||
/// receipt, an open draft and a duplicate file give exactly those four
|
||||
/// findings: the statements' and the store's, together.
|
||||
#[test]
|
||||
fn four_defects_give_exactly_four_findings() {
|
||||
use ledger_core::audit::{AuditInput, statements};
|
||||
let dir = test_dir("four");
|
||||
let (mut s, fx) = {
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let bank = s
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let supplies = s
|
||||
.add_account_with_tax(
|
||||
"6000",
|
||||
"Supplies",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
Some(("supplies", None)),
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(1, 1), "Opening").unwrap();
|
||||
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.attach_receipt(Some(e), "open.txt", "text/plain", b"deposit 500.00", None)
|
||||
.unwrap();
|
||||
// A purchase over the threshold with no receipt: posted through the
|
||||
// primitive, as a hand entry made before the rule was tightened.
|
||||
let bare = s.create_entry(d(1, 5), "Printer paper").unwrap();
|
||||
s.add_line(bare, supplies, Side::Debit, usd("40.00"))
|
||||
.unwrap();
|
||||
s.add_line(bare, bank, Side::Credit, usd("40.00")).unwrap();
|
||||
s.post_entry(bare).unwrap();
|
||||
// An open draft.
|
||||
let draft = s.create_entry(d(1, 9), "Stamps").unwrap();
|
||||
s.add_line(draft, supplies, Side::Debit, usd("5.00"))
|
||||
.unwrap();
|
||||
s.add_line(draft, bank, Side::Credit, usd("5.00")).unwrap();
|
||||
(s, (bank, card, bare))
|
||||
};
|
||||
// A duplicate: two records an older version filed for one file.
|
||||
drop(s);
|
||||
let bytes = b"filed twice by an older version";
|
||||
let (sha, _) = BlobStore::open(&dir.join("blobs"))
|
||||
.unwrap()
|
||||
.put(bytes)
|
||||
.unwrap();
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (id, name) in [(10, "one.pdf"), (11, "two.pdf")] {
|
||||
w.append(
|
||||
"receipt.attached",
|
||||
json!({
|
||||
"attachment_id": id, "blob_sha": sha, "entry_id": null,
|
||||
"category": "receipts", "file_name": name,
|
||||
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
|
||||
"description": null,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
s = Store::open(&dir).unwrap();
|
||||
let (bank, card, _) = fx;
|
||||
// The statement of cash flows with a liability named as cash: the
|
||||
// engine refuses it, and that refusal is the finding.
|
||||
let params = ledger_core::CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(1, 31),
|
||||
cash_accounts: vec![bank, card],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let made = ledger_core::cash_flow(s.ledger(), ¶ms)
|
||||
.map(|c| c.reconciles)
|
||||
.map_err(|e| e.to_string());
|
||||
assert!(made.is_err(), "a liability is not a cash account");
|
||||
let receipts = s.entries_with_receipts().unwrap();
|
||||
let mut found = statements(
|
||||
s.ledger(),
|
||||
&AuditInput {
|
||||
settings: s.ledger().settings(),
|
||||
receipts: &receipts,
|
||||
cash_flow: Some(&made),
|
||||
settings_problems: &[],
|
||||
undecided_1099: &[],
|
||||
},
|
||||
d(1, 31),
|
||||
);
|
||||
found.extend(s.integrity_findings());
|
||||
ledger_core::audit::sort(&mut found);
|
||||
let mut codes: Vec<&str> = found.iter().map(|f| f.code).collect();
|
||||
codes.sort_unstable();
|
||||
assert_eq!(
|
||||
codes,
|
||||
[
|
||||
"cash_flow_unfooted",
|
||||
"draft_open",
|
||||
"duplicate_blob",
|
||||
"evidence_missing"
|
||||
],
|
||||
"{found:#?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Writes `p8_before`: books the version before P8 (main b97234ac) wrote
|
||||
//! through its Store API: a receipted opening and purchase, a 40.00
|
||||
//! purchase with no receipt, a small one under the threshold, a reversal,
|
||||
//! an invoice with a part payment, an open draft, and a closed January.
|
||||
//! No audit run, attestation or attestation setting exists in that
|
||||
//! version; this version opens the same journal and must report the same
|
||||
//! figures, and an audit of it finds exactly the missing receipt and the
|
||||
//! open draft.
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, DocumentLine, Money, Side};
|
||||
use ledger_store::Store;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
fn cents(c: i64) -> Decimal {
|
||||
Decimal::new(c, 2)
|
||||
}
|
||||
fn usd(c: i64) -> Money {
|
||||
Money::new(cents(c), Currency::USD)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::args().nth(1).unwrap());
|
||||
let dir = root.join("p8_before");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap();
|
||||
let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap();
|
||||
let sales = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap();
|
||||
let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap();
|
||||
let software = s.add_account("6100", "Software", AccountType::Expense, None, false).unwrap();
|
||||
let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| {
|
||||
let e = s.create_entry(date, what).unwrap();
|
||||
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
|
||||
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||||
e
|
||||
};
|
||||
let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000);
|
||||
s.post_entry(seed).unwrap();
|
||||
s.attach_receipt(Some(seed), "opening.txt", "text/plain", b"Opening deposit 1000.00 2026-01-01", None)
|
||||
.unwrap();
|
||||
let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
|
||||
s.post_entry(rent_jan).unwrap();
|
||||
s.attach_receipt(Some(rent_jan), "rent.txt", "text/plain", b"Rent January 500.00 2026-01-05", None)
|
||||
.unwrap();
|
||||
// 40.00 is over the 25.00 threshold: no receipt, so an audit finds it.
|
||||
let paper = post(&mut s, d(2026, 1, 7), "Printer paper", software, bank, 4_000);
|
||||
s.post_entry(paper).unwrap();
|
||||
// Under the threshold: needs none.
|
||||
let stamps = post(&mut s, d(2026, 1, 8), "Stamps", software, bank, 1_200);
|
||||
s.post_entry(stamps).unwrap();
|
||||
let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000);
|
||||
s.post_entry(wrong).unwrap();
|
||||
s.attach_receipt(Some(wrong), "rent2.txt", "text/plain", b"Rent again 500.00 2026-01-12", None)
|
||||
.unwrap();
|
||||
s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap();
|
||||
let customer = s.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = s
|
||||
.create_invoice(
|
||||
customer,
|
||||
d(2026, 1, 10),
|
||||
d(2026, 2, 9),
|
||||
vec![DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: cents(120_000),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
s.receive_payment(inv, d(2026, 1, 20), cents(40_000), bank).unwrap();
|
||||
// An open draft in February; January closes around it.
|
||||
let _open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500);
|
||||
let jan = s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)).unwrap();
|
||||
s.close_period(jan).unwrap();
|
||||
let _ = sales;
|
||||
s.verify().unwrap();
|
||||
println!("WROTE {:?}", dir);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Opening deposit 1000.00 2026-01-01
|
||||
+1
@@ -0,0 +1 @@
|
||||
Rent again 500.00 2026-01-12
|
||||
+1
@@ -0,0 +1 @@
|
||||
Rent January 500.00 2026-01-05
|
||||
@@ -0,0 +1,461 @@
|
||||
{
|
||||
"assets": [],
|
||||
"bills": [],
|
||||
"entries": [
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"description": "Opening capital",
|
||||
"id": 1,
|
||||
"lines": [
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "1000.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 2,
|
||||
"amount": {
|
||||
"amount": "1000.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-05",
|
||||
"description": "January rent",
|
||||
"id": 2,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-07",
|
||||
"description": "Printer paper",
|
||||
"id": 3,
|
||||
"lines": [
|
||||
{
|
||||
"account": 5,
|
||||
"amount": {
|
||||
"amount": "40.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "40.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-08",
|
||||
"description": "Stamps",
|
||||
"id": 4,
|
||||
"lines": [
|
||||
{
|
||||
"account": 5,
|
||||
"amount": {
|
||||
"amount": "12.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "12.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-12",
|
||||
"description": "Rent paid twice",
|
||||
"id": 5,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-14",
|
||||
"description": "Entered twice",
|
||||
"id": 6,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
}
|
||||
],
|
||||
"reversal_of": 5,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-10",
|
||||
"description": "Invoice 1",
|
||||
"id": 7,
|
||||
"lines": [
|
||||
{
|
||||
"account": 6,
|
||||
"amount": {
|
||||
"amount": "1200.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "1200.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-20",
|
||||
"description": "Payment on invoice 1",
|
||||
"id": 8,
|
||||
"lines": [
|
||||
{
|
||||
"account": 6,
|
||||
"amount": {
|
||||
"amount": "400.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "400.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-02-12",
|
||||
"description": "Coworking day pass",
|
||||
"id": 9,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "35.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "35.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Draft"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-31",
|
||||
"description": "Close period 2026-01",
|
||||
"id": 10,
|
||||
"lines": [
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "1200.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 5,
|
||||
"amount": {
|
||||
"amount": "52.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 7,
|
||||
"amount": {
|
||||
"amount": "648.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
}
|
||||
],
|
||||
"entry_parties": [
|
||||
[
|
||||
7,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
8,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
]
|
||||
],
|
||||
"invoices": [
|
||||
{
|
||||
"amount_paid": "400.00",
|
||||
"customer_id": 1,
|
||||
"date": "2026-01-10",
|
||||
"due_date": "2026-02-09",
|
||||
"entry_id": 7,
|
||||
"id": 1,
|
||||
"lines": [
|
||||
{
|
||||
"amount": "1200.00",
|
||||
"description": "Design"
|
||||
}
|
||||
],
|
||||
"status": "Partial"
|
||||
}
|
||||
],
|
||||
"payroll_runs": [],
|
||||
"periods": [
|
||||
{
|
||||
"close_seq": 1,
|
||||
"closing_entry": 10,
|
||||
"end": "2026-01-31",
|
||||
"id": 1,
|
||||
"member_close": null,
|
||||
"name": "2026-01",
|
||||
"start": "2026-01-01",
|
||||
"status": "Closed"
|
||||
}
|
||||
],
|
||||
"recurring": [],
|
||||
"schedules": [],
|
||||
"snapshot": {
|
||||
"Ok": {
|
||||
"balance_sheet": {
|
||||
"assets": "1648",
|
||||
"balanced": true,
|
||||
"current_earnings": "0",
|
||||
"equity": "1648",
|
||||
"liabilities": "0"
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"expenses": "0",
|
||||
"net_income": "0",
|
||||
"revenue": "0"
|
||||
},
|
||||
"trial_balance": {
|
||||
"lines": [
|
||||
{
|
||||
"code": "1000",
|
||||
"credit": "0",
|
||||
"debit": "848",
|
||||
"name": "Bank"
|
||||
},
|
||||
{
|
||||
"code": "1200",
|
||||
"credit": "0",
|
||||
"debit": "800",
|
||||
"name": "Accounts Receivable"
|
||||
},
|
||||
{
|
||||
"code": "3000",
|
||||
"credit": "1000",
|
||||
"debit": "0",
|
||||
"name": "Capital"
|
||||
},
|
||||
{
|
||||
"code": "3999",
|
||||
"credit": "648",
|
||||
"debit": "0",
|
||||
"name": "Retained Earnings"
|
||||
}
|
||||
],
|
||||
"total_credits": "1648",
|
||||
"total_debits": "1648"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"Ok": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:33:25Z","type":"store.created"}
|
||||
{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"a41fd982ec883c0f7dbeec0382bcf10c69246a4d063cf04997f40ad96467f00d","seq":2,"ts":"2026-10-29T20:33:25Z","type":"journal.format_set"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"f0adba7a9a2da79cc2b28f4ab2a69238da5f3ad9269e76c1c21e0027fdf86c51","seq":3,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"093d7586712b4e3379fea7da2923ab4b25efa7be0d533e212ea4c31ecbb81332","seq":4,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":3,"name":"Sales","parent":null,"placeholder":false},"prev":"2b8f70ae6d4b736aa0cc523563b47cdd1be396e3fd3a5c2f84cceb001fbaff81","seq":5,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"d4fc3949ba8c20b553504561a2c22f96d6a4351713ae66e83fe34f601ead0b2d","seq":6,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"3088d962972a512f5d1a12a8dd4da1da6ae8d1388e67ea18666b87687401b97c","seq":7,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"5947b37f609878df3587e37c72411cc2505fb694aab1bc9a7199cecfd2897031","seq":8,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"d0aff2093f4f8114e04eb158ddfcfc24201846214141fea1def86cd965551006","seq":9,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"850e9125ac3b5d457e611882642600a7298fa3acdd386ce2b7c9494ecb29b0d0","seq":10,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"abc483bc19148cc173cf93402702b60ee5e31029fcdcfa704f10204c38a707ec","seq":11,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
|
||||
{"payload":{"attachment_id":1,"blob_sha":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","category":"receipts","description":null,"entry_id":1,"file_name":"opening.txt","mime_type":"text/plain","size_bytes":34},"prev":"4a2665579fd49f5d11e27eaaca556990d58559dd1a9d8f23e9d274449ff61a8d","seq":12,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
|
||||
{"payload":{"attachment_id":1,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","version":"0.1.0"},"prev":"d8c2d6bf43d24b9cf4b3c0a12b514cd9f77cf51f979ab54e17c6fc879fdc285f","seq":13,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
|
||||
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"d048136f116634d3df443725f71b4f5a9ed2cfdc45a8dba9fc9a54bb993266ff","seq":14,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"6850fe7154d84ec18c876c9c4985607b9660e1a4d6ff1c42814358039d25224d","seq":15,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"24ecb6745877198c45149e53672d81a7e8b75051770c2eac046c373ad5020694","seq":16,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"76e855cebe94f29e2bbf65e4be0634976eec4164867ec4c2152e39b21a5b9ab0","seq":17,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
|
||||
{"payload":{"attachment_id":2,"blob_sha":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","category":"receipts","description":null,"entry_id":2,"file_name":"rent.txt","mime_type":"text/plain","size_bytes":30},"prev":"72fb7f27dba8721febaffcc2dc93870d79272ca3208abfea27d1535f46fa8334","seq":18,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
|
||||
{"payload":{"attachment_id":2,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","version":"0.1.0"},"prev":"bd55df8f6fce00e7a55262be3123985789504189f9bc8364a194fa886719fc6a","seq":19,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
|
||||
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3},"prev":"2129339f3ce925cb9d0ff71f108ce7ba5c1dadd8432834effb2586e7632c1add","seq":20,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":5,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"3980e707c3feb852f13fff9daa38ae8bf64831d851a76b8a5e7e97eff3237506","seq":21,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"673065104b3196477ebed498501f952f62b0cc1ed8cb35cda30b00e3d4932590","seq":22,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":1,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c2c265a5318dfc5437b94272a5ceb654e93eb982815eea919ce11ca492e69f53","seq":23,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-01-08","description":"Stamps","id":4},"prev":"e54f5a63600a423bc05f572cc2e5357b591afee176de438adbfc673e10028443","seq":24,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":5,"amount":"12.00","currency":"USD","id":4,"side":"debit"},"prev":"c0acdf9d0c8fd94c66be0718354953d951f377d961da3846bba4fcfe9715577a","seq":25,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":4,"side":"credit"},"prev":"9231e3a5b347df6caabb01248419230918234919a32552ebb509706a79f4cfd0","seq":26,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-08","description":"Stamps","id":4,"lines":[{"account":5,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"66e4fd5f78d0499f6c748e283ba81325fb43d3277a73bc7ba02e1cc43a8e3d37","seq":27,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5},"prev":"e62d56bcde5b843e5b007e5d70bec0b39159e04c0e496533341af80fb2c04e88","seq":28,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":5,"side":"debit"},"prev":"b9270e6df82d8f9972ec1ce5046113bc460e1c28b48a1d23a3f5631dae23eb6c","seq":29,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":5,"side":"credit"},"prev":"91bd76318ce8bcdc02caaff76a44ade5b63aea97fc2e9406a914f87d91b7b87b","seq":30,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"994d4a25eb115362c405208ffc926af4898c61e0938d225c3cceb1aaff2520b1","seq":31,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"attachment_id":3,"blob_sha":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","category":"receipts","description":null,"entry_id":5,"file_name":"rent2.txt","mime_type":"text/plain","size_bytes":28},"prev":"97c54bdaa6d977bddbb61f0f797903f162a5445aadad0f86fbef119d26f03885","seq":32,"ts":"2026-10-29T20:33:27Z","type":"receipt.attached"}
|
||||
{"payload":{"attachment_id":3,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","version":"0.1.0"},"prev":"6908282c4f663ee92e59574a7f772d341692a684975433afb0370fe7f25ad798","seq":33,"ts":"2026-10-29T20:33:27Z","type":"attachment.text_extracted"}
|
||||
{"payload":{"date":"2026-01-14","description":"Entered twice","id":6,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"d8fae93f64fa2c2fafa13ed4ed3a6f578a6d7a2af15cdef1bd2b3bed86242665","seq":34,"ts":"2026-10-29T20:33:27Z","type":"entry.reversed"}
|
||||
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"c525d0946d25b018a020b71072d59e39a6a842d85fa4c554af73965235b93aca","seq":35,"ts":"2026-10-29T20:33:27Z","type":"customer.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":6,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"99f31ecc1df1963071728eb1927d66574cbbc48579604183b569b0507f725aaa","seq":36,"ts":"2026-10-29T20:33:27Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7},"prev":"000b3f21fbda30070ebdc436788844e60e7fb30a2c574a4fc96df1184e6a974f","seq":37,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":6,"amount":"1200.00","currency":"USD","id":7,"side":"debit"},"prev":"31912cd94f8cf8ab2381467d0c1f06e7b7d1aad7232dc34bf26976f8b07117df","seq":38,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":7,"side":"credit"},"prev":"b3544c286be658d2569eaec61f1ecf924b6345125ccdee5de44c7be69eb80d87","seq":39,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7,"lines":[{"account":6,"amount":"1200.00","currency":"USD","side":"debit"},{"account":3,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"780aa79b403d46831221d2f9de731eafe6e20e3b916fa6bcc712485234a53a41","seq":40,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":7,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"d13a0896487f5aec07d43136ad9fd10e70d4f048528ff4e7a32a9d23a74f7225","seq":41,"ts":"2026-10-29T20:33:27Z","type":"invoice.created"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8},"prev":"293d3e1a957fa177d28515cf7a4dfa324945bb73d8a2b535844fbaafa186ff1a","seq":42,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":6,"amount":"400.00","currency":"USD","id":8,"side":"credit"},"prev":"68c56c6c09a27ba436c95d4ebe2e0fb56511bf5db181ad424d7c6ed56cfd0bc3","seq":43,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"400.00","currency":"USD","id":8,"side":"debit"},"prev":"5284c73245f1f3a6d2820a6165fe720bc7b7034ad3cd07ba1320f30fed4acadc","seq":44,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8,"lines":[{"account":6,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"c2afa1d4c940bbc35df2ec94272080c5fa0dad93a9547cbec6d07ab11f4ba4f3","seq":45,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":8,"invoice_id":1},"prev":"30a6274b20559dc7f83f2b00f39da4c72bc4244c013ad63368d0f6362311e058","seq":46,"ts":"2026-10-29T20:33:27Z","type":"invoice.payment_received"}
|
||||
{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":9},"prev":"adb2e790bd245c5ab0286870ad837296d0df062498c080b4349579b9f5f67ed9","seq":47,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"35.00","currency":"USD","id":9,"side":"debit"},"prev":"030bdf46cf4b8dae86f000cca47fd0f95d0f7dc01f3637729c082797846a1416","seq":48,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"35.00","currency":"USD","id":9,"side":"credit"},"prev":"b6e4796ab7517782b0e5ee77126e5701246d2b4234c505bc079ba1ce93ad4b04","seq":49,"ts":"2026-10-29T20:33:28Z","type":"entry.line_added"}
|
||||
{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"d5653ccd2b9fdc574ebb68c068246e70b69639f26a3934faf41eeeb77dc5f8bb","seq":50,"ts":"2026-10-29T20:33:28Z","type":"period.created"}
|
||||
{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"14a0f2b42d59a5a18e901c935c1fe15198d6a8d308917359b840504b74700cb8","seq":51,"ts":"2026-10-29T20:33:28Z","type":"account.added"}
|
||||
{"payload":{"close_to":"retained_earnings","closing_entry":10,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":10,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":5,"amount":"52.00","currency":"USD","side":"credit"},{"account":7,"amount":"648.00","currency":"USD","side":"credit"}]},"id":1},"prev":"724a1d45168bcb5282dd894952b98aa4b364641158c42e9204758de8c2afeba2","seq":52,"ts":"2026-10-29T20:33:28Z","type":"period.closed"}
|
||||
@@ -150,7 +150,7 @@ fn assert_corrupt<T>(got: Result<T, StoreError>) {
|
||||
fn old_reader_gets_newer_journal() {
|
||||
let cases = [
|
||||
(
|
||||
"period.attested",
|
||||
"ledger.not_yet_written",
|
||||
json!({"key": "company.name", "value": "X"}),
|
||||
"an event type this version doesn't know",
|
||||
),
|
||||
@@ -243,12 +243,24 @@ fn the_writer_and_the_viewer_agree_on_newer_books() {
|
||||
&[
|
||||
// Chained and of a known type, but replay refuses it.
|
||||
("entry.posted", json!({"id": 99})),
|
||||
("period.attested", json!({"key": "company.name"})),
|
||||
("ledger.not_yet_written", json!({"key": "company.name"})),
|
||||
],
|
||||
)[1];
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(
|
||||
Store::open(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
assert_newer(
|
||||
Snapshot::load(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Damage stays corruption, whatever the damaged line's type: the gate
|
||||
@@ -282,7 +294,7 @@ fn damaged_journals_are_still_corrupt() {
|
||||
];
|
||||
let newer = [
|
||||
(FORMAT_SET, json!({"format": 3, "features": []})),
|
||||
("period.attested", json!({"key": "company.name"})),
|
||||
("ledger.not_yet_written", json!({"key": "company.name"})),
|
||||
];
|
||||
for (name, damage) in damages {
|
||||
for (i, last) in newer.iter().enumerate() {
|
||||
@@ -566,32 +578,32 @@ fn a_newer_format_frames_its_lines_its_own_way() {
|
||||
let nexts: [(&str, Next, Option<bool>); 6] = [
|
||||
(
|
||||
"float",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": prev}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"string-seq",
|
||||
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}),
|
||||
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"envelope-key",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev, "actor": "agent"}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"no-prev",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "parent": prev}),
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"prev-not-text",
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}),
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": 1.5}),
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"wrong-prev",
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}),
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": "00"}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
@@ -686,11 +698,23 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
|
||||
} else {
|
||||
// After a refused line, it is a next line without a readable
|
||||
// prev: unverified, for both.
|
||||
let seq = append(&dir, &[("period.attested", json!({}))])[0];
|
||||
let seq = append(&dir, &[("ledger.not_yet_written", json!({}))])[0];
|
||||
append_raw(&dir, b"{\"prev\":\"\xff\"}\n");
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(
|
||||
Store::open(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
assert_newer(
|
||||
Snapshot::load(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,12 +727,21 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
|
||||
fn an_old_reader_never_repairs_newer_books() {
|
||||
let dir = test_dir("newer-torn");
|
||||
books(&dir);
|
||||
let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0];
|
||||
let seq = append(
|
||||
&dir,
|
||||
&[("ledger.not_yet_written", json!({"key": "company.name"}))],
|
||||
)[0];
|
||||
append_raw(&dir, b"{\"seq\":");
|
||||
let path = dir.join(JOURNAL_FILE);
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(
|
||||
Store::open(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
let set_aside: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
|
||||
@@ -2181,6 +2181,37 @@ fn books_written_under_older_rules_still_open() {
|
||||
json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}),
|
||||
)
|
||||
.unwrap();
|
||||
// P8's new shapes, hand-written as this version writes them: an audit
|
||||
// run and the three kinds of attestation (the method is the client's
|
||||
// own claim). This reader takes them; the version before it stopped at
|
||||
// the declaration.
|
||||
let method = json!({"model": "opus", "reasoning_effort": "high",
|
||||
"adversarial": true, "verifier_agents": 2});
|
||||
w.append(
|
||||
"audit.run",
|
||||
json!({"id": 1, "through": "2026-05-31", "through_seq": 30, "scope": "month",
|
||||
"program_version": "audit-0.1.0-1", "tb_hash": "aa", "findings_sha256": "bb",
|
||||
"workpaper_attachment": 6, "workpaper_pdf": 7}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"receipt.attested",
|
||||
json!({"attachment_id": 5, "entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:00Z",
|
||||
"method": method, "amount_checked": "99.99", "date_checked": "2026-04-01",
|
||||
"disagrees": false, "note": "read the page"}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"entry.reviewed",
|
||||
json!({"entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:01Z", "method": {}}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"period.attested",
|
||||
json!({"period_id": 1, "by": "verifier", "at": "2026-06-01T00:00:02Z",
|
||||
"method": method, "audit_run_id": 1}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
// Opening rebuilds from the journal; it must accept all of it.
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
@@ -2210,6 +2241,17 @@ fn books_written_under_older_rules_still_open() {
|
||||
assert_eq!(annotation.fields.receipt_number, Some(3));
|
||||
assert_eq!(annotation.fields.payee.as_deref(), Some("Acme"));
|
||||
assert_eq!(store.ledger().next_receipt_number(), 4);
|
||||
// P8's shapes replayed: the run and the three claims, as written.
|
||||
let runs = store.audit_runs().unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(runs[0].scope, ledger_core::audit::Scope::Month);
|
||||
assert_eq!(runs[0].workpaper_pdf, Some(7));
|
||||
let claims = store.attestations().unwrap();
|
||||
assert_eq!(claims.len(), 3);
|
||||
assert_eq!(claims[0].kind(), "receipt");
|
||||
assert_eq!(claims[0].method.verifier_agents, Some(2));
|
||||
assert_eq!(claims[1].method, ledger_core::audit::Method::default());
|
||||
assert_eq!(claims[2].kind(), "period");
|
||||
assert_eq!(store.ledger().entry_settles(7), [1]);
|
||||
assert!(store.ledger().settles_entry(1));
|
||||
let acme = store.ledger().get_party(1).expect("the party");
|
||||
|
||||
Reference in new issue
Block a user