Files
wanderbooks/crates/ledger-store/tests/attachments.rs
T
swaitsandClaude Opus 5.5 e265ed4339
check / check (push) Waiting to run
Parties, attachment links, statements, reconciliations (store), TIN vault crate
Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 14:43:40 -06:00

1276 lines
42 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-derived tests for attachment links, statements, and
//! reconciliations in the store.
//!
//! Oracles: the doc comments on `Store::link_attachment`,
//! `unlink_attachment`, `attachment_links`, `attachments_for`,
//! `record_statement`, `statement`, `statements`, `complete_reconciliation`,
//! `void_reconciliation`, `reconciliations`, `withdraw_attachment`; the
//! `Subject`, `Statement`, `Reconciliation` types; the "Attachments" and
//! "Reconciliations" sections of docs/parties-and-attachments.md;
//! `Store::verify` (a fresh replay equals the live state); and the store
//! errors (UnknownSubject, AlreadyLinked, NotLinkedTo, InvalidStatement,
//! NotReconciled{difference}, ReconciliationRefused). Expected values are
//! worked by hand from those texts, not from the method bodies.
use chrono::NaiveDate;
use ledger_core::arap::DocumentLine;
use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::store::{Reconciliation, Statement, Subject};
use ledger_store::{Attachment, DB_FILE, JOURNAL_FILE, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::str::FromStr;
// ---------------------------------------------------------------- helpers
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wb-attach-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn dec(s: &str) -> Decimal {
Decimal::from_str(s).unwrap()
}
fn usd(s: &str) -> Money {
Money::new(dec(s), Currency::USD)
}
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// The journal's (type, payload) pairs, verified.
fn journal(dir: &Path) -> Vec<(String, Value)> {
read_and_verify_journal(&dir.join(JOURNAL_FILE))
.unwrap()
.into_iter()
.map(|l| (l.line_type, l.payload))
.collect()
}
fn journal_bytes(dir: &Path) -> Vec<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
/// Run `f`; it must journal exactly the lines of types `tys`, in order.
/// Returns the payloads.
fn events<T>(
s: &mut Store,
dir: &Path,
tys: &[&str],
f: impl FnOnce(&mut Store) -> T,
) -> (T, Vec<Value>) {
let seq = s.journal_seq();
let len = journal(dir).len();
let out = f(s);
assert_eq!(
s.journal_seq(),
seq + tys.len() as u64,
"{tys:?}: line count"
);
let j = journal(dir);
assert_eq!(j.len(), len + tys.len(), "{tys:?}: lines on disk");
let new = &j[len..];
let types: Vec<&str> = new.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(types, tys);
(out, new.iter().map(|(_, p)| p.clone()).collect())
}
/// Run `f`; it must journal exactly one line, of type `ty`. Returns that
/// line's payload.
fn one_event<T>(
s: &mut Store,
dir: &Path,
ty: &str,
f: impl FnOnce(&mut Store) -> T,
) -> (T, Value) {
let (out, mut ps) = events(s, dir, &[ty], f);
(out, ps.remove(0))
}
/// Run `f`; it must fail and journal nothing. Returns the error.
fn refused_nothing_journaled<T: std::fmt::Debug>(
s: &mut Store,
dir: &Path,
what: &str,
f: impl FnOnce(&mut Store) -> Result<T, StoreError>,
) -> StoreError {
let seq = s.journal_seq();
let bytes = journal_bytes(dir);
let before = s.ledger().clone();
let r = f(s);
assert_eq!(s.journal_seq(), seq, "{what}: journaled");
assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed");
assert!(*s.ledger() == before, "{what}: ledger changed");
match r {
Ok(v) => panic!("{what}: expected refusal, got {v:?}"),
Err(e) => e,
}
}
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
}
}
fn ids(v: &[Attachment]) -> Vec<u64> {
let mut v: Vec<u64> = v.iter().map(|a| a.id).collect();
v.sort();
v
}
fn sorted<T: Ord + Clone>(v: &[T]) -> Vec<T> {
let mut v = v.to_vec();
v.sort();
v
}
fn doc(s: &mut Store, name: &str) -> u64 {
s.file_document(
"accounting",
name,
"application/pdf",
format!("%PDF {name}").as_bytes(),
Some(name),
)
.unwrap()
.0
}
struct Fx {
cash: AccountId,
capital: AccountId,
expense: AccountId,
card: AccountId,
revenue: AccountId,
/// 2026-01-01 Dr cash 500.00 (line 0) / Cr capital 500.00 (line 1).
e1: EntryId,
/// 2026-01-15 Dr expense 120.00 (line 0) / Cr cash 120.00 (line 1).
e2: EntryId,
/// 2026-02-03 Dr expense 30.00 (line 0) / Cr cash 30.00 (line 1).
e3: EntryId,
/// 2026-01-10 Dr expense 75.00 (line 0) / Cr card 75.00 (line 1).
e4: EntryId,
}
fn post(
s: &mut Store,
d: NaiveDate,
desc: &str,
dr: AccountId,
cr: AccountId,
amt: &str,
) -> EntryId {
let e = s.create_entry(d, desc).unwrap();
s.add_line(e, dr, Side::Debit, usd(amt)).unwrap();
s.add_line(e, cr, Side::Credit, usd(amt)).unwrap();
s.post_entry(e).unwrap();
e
}
fn fixture(dir: &Path) -> (Store, Fx) {
let mut s = Store::create(dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let card = s
.add_account("2050", "Card", AccountType::Liability, None, false)
.unwrap();
let capital = s
.add_account("3000", "Owner Capital", AccountType::Equity, None, false)
.unwrap();
let revenue = s
.add_account("4000", "App Revenue", AccountType::Revenue, None, false)
.unwrap();
let expense = s
.add_account("6000", "Software", AccountType::Expense, None, false)
.unwrap();
let e1 = post(&mut s, date(2026, 1, 1), "Opening", cash, capital, "500.00");
let e2 = post(&mut s, date(2026, 1, 15), "Tool", expense, cash, "120.00");
let e3 = post(&mut s, date(2026, 2, 3), "Tool 2", expense, cash, "30.00");
let e4 = post(
&mut s,
date(2026, 1, 10),
"Card buy",
expense,
card,
"75.00",
);
(
s,
Fx {
cash,
capital,
expense,
card,
revenue,
e1,
e2,
e3,
e4,
},
)
}
fn stmt(
attachment_id: u64,
account_id: AccountId,
start: NaiveDate,
end: NaiveDate,
opening: &str,
closing: &str,
) -> Statement {
Statement {
attachment_id,
account_id,
period_start: start,
period_end: end,
opening_balance: dec(opening),
closing_balance: dec(closing),
}
}
/// January's cash statement: 0.00 -> 380.00 (500.00 in, 120.00 out).
fn jan_cash(s: &mut Store, fx: &Fx, name: &str) -> u64 {
let a = doc(s, name);
s.record_statement(stmt(
a,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"380.00",
))
.unwrap();
a
}
fn clear_jan(s: &mut Store, fx: &Fx) {
s.mark_cleared(fx.e1, 0, true).unwrap();
s.mark_cleared(fx.e2, 1, true).unwrap();
}
fn subject_json(subject: Subject) -> Value {
match subject {
Subject::Company => json!({"kind": "company"}),
s => json!({"kind": s.kind(), "id": s.id()}),
}
}
/// One of every subject kind, created in the fixture's store.
fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
let cost = s
.add_account("1500", "Equipment", AccountType::Asset, None, false)
.unwrap();
let accum = s
.add_account("1501", "Accum Depr", AccountType::Asset, None, false)
.unwrap();
let depr = s
.add_account("6400", "Depreciation", AccountType::Expense, None, false)
.unwrap();
let prepaid = s
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let asset = s
.register_asset(NewAsset {
description: "Laptop".into(),
purchase_date: date(2026, 1, 5),
cost: usd("3000.00"),
salvage_value: usd("0"),
useful_life_months: 36,
cost_account: cost,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
let party = s.add_party("Dev Shop", &[]).unwrap();
let cust = s.add_customer("Acme").unwrap();
let invoice = s
.create_invoice(
cust,
date(2026, 3, 1),
date(2026, 3, 31),
vec![DocumentLine {
description: "App".into(),
amount: dec("100.00"),
}],
)
.unwrap();
let vend = s.add_vendor("Dana").unwrap();
let bill = s
.create_bill(
vend,
date(2026, 3, 1),
date(2026, 3, 31),
vec![DocumentLine {
description: "Design".into(),
amount: dec("200.00"),
}],
)
.unwrap();
let period = s
.create_period("2026-01", date(2026, 1, 1), date(2026, 1, 31))
.unwrap();
let run = s
.run_payroll(
date(2026, 3, 27),
vec![ledger_core::PayrollLine {
employee_name: "Pat".into(),
employee: None,
gross_pay: dec("1000.00"),
federal_withholding: dec("100.00"),
state_withholding: dec("0"),
ss_employee: dec("62.00"),
medicare_employee: dec("14.50"),
ss_employer: dec("62.00"),
medicare_employer: dec("14.50"),
other_deductions: dec("0"),
}],
fx.cash,
)
.unwrap();
let schedule = s
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd("1200.00"),
start: date(2026, 1, 1),
months: 12,
balance_account: prepaid,
pl_account: fx.expense,
})
.unwrap();
clear_jan(s, fx);
let st = jan_cash(s, fx, "subject-stmt.pdf");
let rec = s.complete_reconciliation(st).unwrap();
vec![
Subject::Entry(fx.e2),
Subject::Account(fx.revenue),
Subject::Asset(asset),
Subject::Party(party),
Subject::Invoice(invoice),
Subject::Bill(bill),
Subject::Period(period),
Subject::PayrollRun(run),
Subject::Schedule(schedule),
Subject::Reconciliation(rec.id),
Subject::Company,
]
}
// ---------------------------------------------------------------- linking
/// Oracle: link_attachment ("another entry, an account, an asset, a party,
/// an invoice, a bill, a period, a payroll run, a schedule, a
/// reconciliation, or the company"; journaled as `attachment.linked
/// {attachment_id, subject}`); Subject's serde (tag "kind", content "id",
/// snake_case); attachment_links; attachments_for.
#[test]
fn links_to_every_subject_kind() {
let dir = test_dir("every-kind");
let (mut s, fx) = fixture(&dir);
let subjects = every_subject(&mut s, &fx);
let a = doc(&mut s, "contract.pdf");
for &subject in &subjects {
let (r, p) = one_event(&mut s, &dir, "attachment.linked", |s| {
s.link_attachment(a, subject)
});
r.unwrap();
assert_eq!(p["attachment_id"], json!(a), "{subject:?}");
assert_eq!(p["subject"], subject_json(subject), "{subject:?}");
assert!(
ids(&s.attachments_for(subject).unwrap()).contains(&a),
"attachments_for({subject:?}) lacks the file"
);
}
// Filed by file_document: no filing entry, so just the links.
assert_eq!(sorted(&s.attachment_links(a).unwrap()), sorted(&subjects));
s.verify().unwrap();
}
/// Oracle: link_attachment SCENARIO 1 ("the subject doesn't exist: SHOULD
/// refuse"), INVARIANT 3 (a refusal journals nothing); UnknownSubject.
#[test]
fn refuses_unknown_subjects() {
let dir = test_dir("unknown-subject");
let (mut s, _) = fixture(&dir);
let a = doc(&mut s, "x.pdf");
let missing = 9_999;
for subject in [
Subject::Entry(missing),
Subject::Account(missing),
Subject::Asset(missing),
Subject::Party(missing),
Subject::Invoice(missing),
Subject::Bill(missing),
Subject::Period(missing),
Subject::PayrollRun(missing),
Subject::Schedule(missing),
Subject::Reconciliation(missing),
] {
let e = refused_nothing_journaled(&mut s, &dir, &format!("{subject:?}"), |s| {
s.link_attachment(a, subject)
});
assert!(
matches!(e, StoreError::UnknownSubject(_)),
"{subject:?}: {e:?}"
);
}
assert!(s.attachment_links(a).unwrap().is_empty());
}
/// Oracle: link_attachment SCENARIO 1 (unknown or withdrawn attachment).
#[test]
fn refuses_unknown_or_withdrawn_attachment() {
let dir = test_dir("link-bad-attachment");
let (mut s, _) = fixture(&dir);
let e = refused_nothing_journaled(&mut s, &dir, "unknown", |s| {
s.link_attachment(9_999, Subject::Company)
});
assert!(matches!(e, StoreError::UnknownAttachment(9_999)), "{e:?}");
let a = doc(&mut s, "gone.pdf");
s.withdraw_attachment(a, "wrong file").unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| {
s.link_attachment(a, Subject::Company)
});
assert!(
matches!(e, StoreError::AttachmentWithdrawn(x) if x == a),
"{e:?}"
);
}
/// Oracle: link_attachment SCENARIO 2 (already linked to that subject, or
/// filed on that entry: refuse); AlreadyLinked(id, subject words);
/// attachment_links ("its filing entry, if any, first").
#[test]
fn refuses_duplicate_links_and_the_filing_entry() {
let dir = test_dir("dup-link");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "agreement.pdf");
s.link_attachment(a, Subject::Account(fx.cash)).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "dup account", |s| {
s.link_attachment(a, Subject::Account(fx.cash))
});
assert!(
matches!(&e, StoreError::AlreadyLinked(x, w) if *x == a && *w == Subject::Account(fx.cash).describe()),
"{e:?}"
);
s.link_attachment(a, Subject::Company).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "dup company", |s| {
s.link_attachment(a, Subject::Company)
});
assert!(matches!(e, StoreError::AlreadyLinked(..)), "{e:?}");
let (r, _, _) = s
.attach_receipt(Some(fx.e1), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "filing entry", |s| {
s.link_attachment(r, Subject::Entry(fx.e1))
});
assert!(
matches!(e, StoreError::AlreadyLinked(x, _) if x == r),
"{e:?}"
);
// The same receipt is evidence for a second entry, without refiling.
s.link_attachment(r, Subject::Entry(fx.e2)).unwrap();
s.link_attachment(r, Subject::Account(fx.cash)).unwrap();
let links = s.attachment_links(r).unwrap();
assert_eq!(links[0], Subject::Entry(fx.e1), "filing entry first");
assert_eq!(
sorted(&links[1..]),
sorted(&[Subject::Entry(fx.e2), Subject::Account(fx.cash)])
);
assert_eq!(
ids(&s.attachments_for(Subject::Entry(fx.e1)).unwrap()),
vec![r]
);
assert_eq!(
ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()),
vec![r]
);
s.verify().unwrap();
}
/// Oracle: link_attachment INVARIANT 2 ("an entry's receipts are the files
/// filed on it and the files linked to it"); attachments_for ("for an
/// entry: filed on it or linked to it"); docs "A receipt is an attachment
/// linked to an entry". The store is policy-free, so the receipt rule's
/// count is what attachments_for(Entry) returns.
#[test]
fn a_document_linked_to_an_entry_is_its_receipt() {
let dir = test_dir("doc-as-receipt");
let (mut s, fx) = fixture(&dir);
assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty());
let a = doc(&mut s, "invoice.pdf");
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
assert_eq!(
ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()),
vec![a]
);
let (r, _, _) = s
.attach_receipt(
Some(fx.e2),
"paid.pdf",
"application/pdf",
b"%PDF paid",
None,
)
.unwrap();
assert_eq!(
ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()),
sorted(&[a, r])
);
// Other entries are untouched.
assert!(s.attachments_for(Subject::Entry(fx.e3)).unwrap().is_empty());
// A draft supported only by a linked document.
let d = s.create_entry(date(2026, 1, 20), "Draft").unwrap();
s.add_line(d, fx.expense, Side::Debit, usd("10.00"))
.unwrap();
s.add_line(d, fx.cash, Side::Credit, usd("10.00")).unwrap();
let b = doc(&mut s, "draft-support.pdf");
s.link_attachment(b, Subject::Entry(d)).unwrap();
assert_eq!(ids(&s.attachments_for(Subject::Entry(d)).unwrap()), vec![b]);
s.post_entry(d).unwrap();
assert_eq!(ids(&s.attachments_for(Subject::Entry(d)).unwrap()), vec![b]);
s.verify().unwrap();
}
// ---------------------------------------------------------------- unlinking
/// Oracle: unlink_attachment SCENARIOS 1–2 (blank reason or not linked:
/// refuse; otherwise `attachment.unlinked {attachment_id, subject,
/// reason}`); ReasonRequired; NotLinkedTo; "the file stays filed".
#[test]
fn unlink_needs_a_reason_and_a_link() {
let dir = test_dir("unlink");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "misfiled.pdf");
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
for blank in ["", " "] {
let e = refused_nothing_journaled(&mut s, &dir, &format!("blank {blank:?}"), |s| {
s.unlink_attachment(a, Subject::Entry(fx.e2), blank)
});
assert!(matches!(e, StoreError::ReasonRequired), "{e:?}");
}
let e = refused_nothing_journaled(&mut s, &dir, "not linked", |s| {
s.unlink_attachment(a, Subject::Entry(fx.e3), "wrong entry")
});
assert!(
matches!(&e, StoreError::NotLinkedTo(x, w) if *x == a && *w == Subject::Entry(fx.e3).describe()),
"{e:?}"
);
let e = refused_nothing_journaled(&mut s, &dir, "unknown attachment", |s| {
s.unlink_attachment(9_999, Subject::Entry(fx.e2), "why")
});
let _ = e;
let (r, p) = one_event(&mut s, &dir, "attachment.unlinked", |s| {
s.unlink_attachment(a, Subject::Entry(fx.e2), "belongs to e3")
});
r.unwrap();
assert_eq!(p["attachment_id"], json!(a));
assert_eq!(p["subject"], subject_json(Subject::Entry(fx.e2)));
assert_eq!(p["reason"], json!("belongs to e3"));
assert!(s.attachment_links(a).unwrap().is_empty());
assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty());
// Still filed, and it can go where it belongs.
assert!(ids(&s.list_attachments(None).unwrap()).contains(&a));
s.link_attachment(a, Subject::Entry(fx.e3)).unwrap();
assert_eq!(s.attachment_links(a).unwrap(), vec![Subject::Entry(fx.e3)]);
// Unlinking twice: no longer linked.
s.unlink_attachment(a, Subject::Entry(fx.e3), "done")
.unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "twice", |s| {
s.unlink_attachment(a, Subject::Entry(fx.e3), "again")
});
assert!(matches!(e, StoreError::NotLinkedTo(..)), "{e:?}");
s.verify().unwrap();
}
/// Oracle: unlink_attachment ("a receipt filed on an entry comes off with
/// `unlink_receipt` instead"; "isn't linked to the subject (through this
/// table): SHOULD refuse").
#[test]
fn unlink_attachment_does_not_take_a_filed_receipt_off() {
let dir = test_dir("unlink-filed");
let (mut s, fx) = fixture(&dir);
let (r, _, _) = s
.attach_receipt(Some(fx.e1), "r.pdf", "application/pdf", b"%PDF r1", None)
.unwrap();
refused_nothing_journaled(&mut s, &dir, "filed receipt", |s| {
s.unlink_attachment(r, Subject::Entry(fx.e1), "wrong")
});
assert_eq!(
ids(&s.attachments_for(Subject::Entry(fx.e1)).unwrap()),
vec![r]
);
}
// ---------------------------------------------------------------- withdrawal
/// Oracle: withdraw_attachment SCENARIO 2 ("IF it is linked to any subject
/// (link_attachment): SHOULD refuse (AlreadyLinked, naming the first
/// subject)"). Linked through link_attachment rather than filed on the entry.
#[test]
fn withdraw_while_linked_to_an_entry_is_already_linked() {
let dir = test_dir("withdraw-variant");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "dup.pdf");
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "linked", |s| {
s.withdraw_attachment(a, "duplicate")
});
assert!(
matches!(&e, StoreError::AlreadyLinked(x, sub) if *x == a && *sub == Subject::Entry(fx.e2).describe()),
"{e:?}"
);
}
/// Oracle: withdraw_attachment SCENARIO 2 ("linked to any subject: SHOULD
/// refuse"), SCENARIO 3 (blank reason), SCENARIO 4, and
/// "can never be linked again"; attachments_for ("not withdrawn").
#[test]
fn withdraw_refuses_while_linked_to_an_entry_and_works_after_unlink() {
let dir = test_dir("withdraw");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "dup.pdf");
s.link_attachment(a, Subject::Entry(fx.e2)).unwrap();
refused_nothing_journaled(&mut s, &dir, "linked", |s| {
s.withdraw_attachment(a, "duplicate")
});
s.unlink_attachment(a, Subject::Entry(fx.e2), "duplicate")
.unwrap();
refused_nothing_journaled(&mut s, &dir, "blank reason", |s| {
s.withdraw_attachment(a, " ")
});
let (r, p) = one_event(&mut s, &dir, "attachment.withdrawn", |s| {
s.withdraw_attachment(a, "duplicate")
});
r.unwrap();
assert_eq!(p["attachment_id"], json!(a));
let listed = s
.list_attachments(None)
.unwrap()
.into_iter()
.find(|x| x.id == a)
.unwrap();
assert_eq!(listed.withdrawn_reason.as_deref(), Some("duplicate"));
assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty());
let e = refused_nothing_journaled(&mut s, &dir, "relink", |s| {
s.link_attachment(a, Subject::Entry(fx.e2))
});
assert!(
matches!(e, StoreError::AttachmentWithdrawn(x) if x == a),
"{e:?}"
);
refused_nothing_journaled(&mut s, &dir, "withdraw twice", |s| {
s.withdraw_attachment(a, "again")
});
s.verify().unwrap();
}
// ---------------------------------------------------------------- statements
/// Oracle: record_statement INVARIANT 1 (`statement.recorded
/// {attachment_id, account_id, period_start, period_end, opening_balance,
/// closing_balance}` then `attachment.linked` to the account); statement;
/// statements ("optionally one account's, by period end").
#[test]
fn record_statement_journals_and_links_to_the_account() {
let dir = test_dir("statement");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "jan.pdf");
let st = stmt(
a,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"380.00",
);
let (r, ps) = events(
&mut s,
&dir,
&["statement.recorded", "attachment.linked"],
|s| s.record_statement(st.clone()),
);
r.unwrap();
let p = &ps[0];
assert_eq!(p["attachment_id"], json!(a));
assert_eq!(p["account_id"], json!(fx.cash));
assert_eq!(p["period_start"], json!("2026-01-01"));
assert_eq!(p["period_end"], json!("2026-01-31"));
let money = |v: &Value| dec(v.as_str().unwrap_or(&v.to_string()));
assert_eq!(money(&p["opening_balance"]), dec("0"));
assert_eq!(money(&p["closing_balance"]), dec("380.00"));
assert_eq!(ps[1]["attachment_id"], json!(a));
assert_eq!(ps[1]["subject"], subject_json(Subject::Account(fx.cash)));
assert_eq!(s.statement(a).unwrap(), Some(st.clone()));
assert_eq!(
s.attachment_links(a).unwrap(),
vec![Subject::Account(fx.cash)]
);
assert!(ids(&s.attachments_for(Subject::Account(fx.cash)).unwrap()).contains(&a));
// A plain document records no statement.
let plain = doc(&mut s, "plain.pdf");
assert_eq!(s.statement(plain).unwrap(), None);
// A card (liability) statement, and February's cash one recorded
// before January's in time order of calls but sorted by period end.
let feb = doc(&mut s, "feb.pdf");
let st_feb = stmt(
feb,
fx.cash,
date(2026, 2, 1),
date(2026, 2, 28),
"380.00",
"350.00",
);
let c = doc(&mut s, "card.pdf");
let st_card = stmt(
c,
fx.card,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"75.00",
);
s.record_statement(st_card.clone()).unwrap();
s.record_statement(st_feb.clone()).unwrap();
assert_eq!(
s.statements(Some(fx.cash)).unwrap(),
vec![st.clone(), st_feb.clone()]
);
assert_eq!(s.statements(Some(fx.card)).unwrap(), vec![st_card.clone()]);
assert!(s.statements(Some(fx.capital)).unwrap().is_empty());
let all = s.statements(None).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all.last().unwrap(), &st_feb, "by period end");
s.verify().unwrap();
}
/// Oracle: record_statement SCENARIO 1 (unknown or withdrawn attachment,
/// already records a statement, unknown account or not an asset or
/// liability, period backwards, a balance with more than two decimal
/// places: refuse, journaling nothing); InvalidStatement.
#[test]
fn record_statement_refusals_journal_nothing() {
let dir = test_dir("statement-refusals");
let (mut s, fx) = fixture(&dir);
let a = doc(&mut s, "stmt.pdf");
let jan = |acct| {
stmt(
a,
acct,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"380.00",
)
};
for acct in [fx.capital, fx.revenue, fx.expense] {
let e = refused_nothing_journaled(&mut s, &dir, &format!("account {acct}"), |s| {
s.record_statement(jan(acct))
});
assert!(
matches!(e, StoreError::InvalidStatement(_)),
"{acct}: {e:?}"
);
}
refused_nothing_journaled(&mut s, &dir, "unknown account", |s| {
s.record_statement(jan(9_999))
});
let e = refused_nothing_journaled(&mut s, &dir, "backwards", |s| {
s.record_statement(stmt(
a,
fx.cash,
date(2026, 1, 31),
date(2026, 1, 1),
"0",
"380.00",
))
});
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
for (open, close) in [("0.001", "380.00"), ("0.00", "380.005")] {
let e = refused_nothing_journaled(&mut s, &dir, &format!("sub-cent {open} {close}"), |s| {
s.record_statement(stmt(
a,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
open,
close,
))
});
assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}");
}
let e = refused_nothing_journaled(&mut s, &dir, "unknown attachment", |s| {
s.record_statement(stmt(
9_999,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
"0",
"1",
))
});
assert!(matches!(e, StoreError::UnknownAttachment(9_999)), "{e:?}");
let w = doc(&mut s, "withdrawn.pdf");
s.withdraw_attachment(w, "wrong month").unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| {
s.record_statement(stmt(
w,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 31),
"0",
"1",
))
});
assert!(
matches!(e, StoreError::AttachmentWithdrawn(x) if x == w),
"{e:?}"
);
// Nothing was recorded by the refusals.
assert_eq!(s.statement(a).unwrap(), None);
assert!(s.statements(None).unwrap().is_empty());
// A one-day period does not run backwards; a liability is allowed.
s.record_statement(stmt(
a,
fx.card,
date(2026, 1, 31),
date(2026, 1, 31),
"0",
"75.00",
))
.unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "duplicate", |s| {
s.record_statement(jan(fx.cash))
});
let _ = e;
assert_eq!(s.statements(None).unwrap().len(), 1);
s.verify().unwrap();
}
// ---------------------------------------------------------------- reconciliations
/// Oracle: complete_reconciliation SCENARIO 2 (NotReconciled naming the
/// difference); NotReconciled's doc ("cleared balance less the statement
/// balance"); Ledger::reconciliation_report (cleared lines dated on or
/// before the statement date).
#[test]
fn refuses_with_the_exact_difference() {
let dir = test_dir("not-reconciled");
let (mut s, fx) = fixture(&dir);
let a = jan_cash(&mut s, &fx, "jan.pdf");
// Nothing cleared: 0.00 - 380.00.
let e = refused_nothing_journaled(&mut s, &dir, "none cleared", |s| {
s.complete_reconciliation(a)
});
assert!(
matches!(e, StoreError::NotReconciled { difference } if difference == dec("-380.00")),
"{e:?}"
);
// Only the deposit: 500.00 - 380.00.
s.mark_cleared(fx.e1, 0, true).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "deposit only", |s| {
s.complete_reconciliation(a)
});
assert!(
matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")),
"{e:?}"
);
// A February line, cleared, is after the statement date and doesn't count.
s.mark_cleared(fx.e3, 1, true).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "feb cleared", |s| {
s.complete_reconciliation(a)
});
assert!(
matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")),
"{e:?}"
);
// Clearing the non-cash side of an entry doesn't move the cash balance.
s.mark_cleared(fx.e2, 0, true).unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "expense side cleared", |s| {
s.complete_reconciliation(a)
});
assert!(
matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")),
"{e:?}"
);
assert!(s.reconciliations(None).unwrap().is_empty());
}
/// Oracle: complete_reconciliation SCENARIO 1 (no statement: refuse).
#[test]
fn refuses_without_a_statement() {
let dir = test_dir("no-statement");
let (mut s, fx) = fixture(&dir);
clear_jan(&mut s, &fx);
let plain = doc(&mut s, "plain.pdf");
refused_nothing_journaled(&mut s, &dir, "plain doc", |s| {
s.complete_reconciliation(plain)
});
refused_nothing_journaled(&mut s, &dir, "unknown", |s| {
s.complete_reconciliation(9_999)
});
}
/// Oracle: complete_reconciliation INVARIANTS 1, 2, 4 (records the cleared
/// lines on the account dated on or before the statement date, links the
/// statement to it; journaled as `reconciliation.completed`); the docs'
/// payload `{id, account_id, statement_date, statement_balance, cleared,
/// attachment_id}`; Reconciliation; reconciliations.
#[test]
fn completes_when_the_cleared_balance_matches() {
let dir = test_dir("reconciled");
let (mut s, fx) = fixture(&dir);
let a = jan_cash(&mut s, &fx, "jan.pdf");
clear_jan(&mut s, &fx);
// Cleared but out of scope: another account's line, and a cash line
// after the statement date.
s.mark_cleared(fx.e1, 1, true).unwrap();
s.mark_cleared(fx.e3, 1, true).unwrap();
// 500.00 - 120.00 = 380.00: reconciled.
let seq = s.journal_seq();
let len = journal(&dir).len();
let rec = s.complete_reconciliation(a).unwrap();
let new: Vec<(String, Value)> = journal(&dir)[len..].to_vec();
assert_eq!(s.journal_seq(), seq + new.len() as u64);
let completed: Vec<&Value> = new
.iter()
.filter(|(t, _)| t == "reconciliation.completed")
.map(|(_, p)| p)
.collect();
assert_eq!(completed.len(), 1, "one reconciliation.completed: {new:?}");
for (t, _) in &new {
assert!(
t == "reconciliation.completed" || t == "attachment.linked",
"unexpected line {t}"
);
}
assert_eq!(rec.account_id, fx.cash);
assert_eq!(rec.statement_date, date(2026, 1, 31));
assert_eq!(rec.statement_balance, dec("380.00"));
assert_eq!(rec.attachment_id, a);
assert_eq!(rec.voided, None);
assert_eq!(sorted(&rec.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1)]));
let p = completed[0];
assert_eq!(p["id"], json!(rec.id));
assert_eq!(p["account_id"], json!(fx.cash));
assert_eq!(p["statement_date"], json!("2026-01-31"));
assert_eq!(p["attachment_id"], json!(a));
let mut cleared: Vec<(u64, u64)> = p["cleared"]
.as_array()
.unwrap()
.iter()
.map(|x| (x[0].as_u64().unwrap(), x[1].as_u64().unwrap()))
.collect();
cleared.sort();
assert_eq!(cleared, vec![(fx.e1, 0), (fx.e2, 1)]);
let links = s.attachment_links(a).unwrap();
assert!(links.contains(&Subject::Account(fx.cash)), "{links:?}");
assert!(
links.contains(&Subject::Reconciliation(rec.id)),
"{links:?}"
);
assert_eq!(
ids(&s.attachments_for(Subject::Reconciliation(rec.id)).unwrap()),
vec![a]
);
assert_eq!(s.reconciliations(Some(fx.cash)).unwrap(), vec![rec.clone()]);
assert_eq!(s.reconciliations(None).unwrap(), vec![rec.clone()]);
assert!(s.reconciliations(Some(fx.card)).unwrap().is_empty());
s.verify().unwrap();
}
/// Oracle: Statement ("closing balance, in the account's normal
/// direction"); complete_reconciliation INVARIANT 1. A card (liability)
/// owing 75.00 states 75.00.
#[test]
fn reconciles_a_liability_in_its_normal_direction() {
let dir = test_dir("liability");
let (mut s, fx) = fixture(&dir);
let c = doc(&mut s, "card.pdf");
s.record_statement(stmt(
c,
fx.card,
date(2026, 1, 1),
date(2026, 1, 31),
"0.00",
"75.00",
))
.unwrap();
s.mark_cleared(fx.e4, 1, true).unwrap();
let rec = s.complete_reconciliation(c).unwrap();
assert_eq!(rec.account_id, fx.card);
assert_eq!(rec.cleared, vec![(fx.e4, 1)]);
assert_eq!(rec.statement_balance, dec("75.00"));
}
/// Oracle: complete_reconciliation INVARIANT 3 / SCENARIO 3 ("a
/// reconciliation not voided is dated on or after it: SHOULD refuse");
/// ReconciliationRefused; reconciliations ("by date").
#[test]
fn reconciliations_go_forward() {
let dir = test_dir("forward");
let (mut s, fx) = fixture(&dir);
clear_jan(&mut s, &fx);
let jan = jan_cash(&mut s, &fx, "jan.pdf");
let r1 = s.complete_reconciliation(jan).unwrap();
// Same date, another copy of the statement.
let jan2 = jan_cash(&mut s, &fx, "jan-again.pdf");
let e = refused_nothing_journaled(&mut s, &dir, "same date", |s| {
s.complete_reconciliation(jan2)
});
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
// Earlier date: cleared through 01-20 is also 380.00, but it's backwards.
let mid = doc(&mut s, "mid.pdf");
s.record_statement(stmt(
mid,
fx.cash,
date(2026, 1, 1),
date(2026, 1, 20),
"0",
"380.00",
))
.unwrap();
let e = refused_nothing_journaled(&mut s, &dir, "earlier", |s| s.complete_reconciliation(mid));
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
// The same January statement, again.
let e = refused_nothing_journaled(&mut s, &dir, "jan again", |s| {
s.complete_reconciliation(jan)
});
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
// Another account's sequence is its own.
let c = doc(&mut s, "card.pdf");
s.record_statement(stmt(
c,
fx.card,
date(2026, 1, 1),
date(2026, 1, 15),
"0",
"75.00",
))
.unwrap();
s.mark_cleared(fx.e4, 1, true).unwrap();
s.complete_reconciliation(c).unwrap();
// February goes forward: 380.00 - 30.00 = 350.00.
s.mark_cleared(fx.e3, 1, true).unwrap();
let feb = doc(&mut s, "feb.pdf");
s.record_statement(stmt(
feb,
fx.cash,
date(2026, 2, 1),
date(2026, 2, 28),
"380.00",
"350.00",
))
.unwrap();
let r2 = s.complete_reconciliation(feb).unwrap();
assert_ne!(r2.id, r1.id);
assert_eq!(
sorted(&r2.cleared),
sorted(&[(fx.e1, 0), (fx.e2, 1), (fx.e3, 1)])
);
let cash_recs = s.reconciliations(Some(fx.cash)).unwrap();
assert_eq!(cash_recs, vec![r1, r2]);
assert_eq!(s.reconciliations(None).unwrap().len(), 3);
s.verify().unwrap();
}
/// Oracle: void_reconciliation ("with the reason (to redo it)";
/// `reconciliation.voided {id, reason}`); complete_reconciliation
/// INVARIANT 3 (only reconciliations not voided bound the next);
/// Reconciliation.voided.
#[test]
fn void_and_redo() {
let dir = test_dir("void");
let (mut s, fx) = fixture(&dir);
clear_jan(&mut s, &fx);
let jan = jan_cash(&mut s, &fx, "jan.pdf");
let r1 = s.complete_reconciliation(jan).unwrap();
for blank in ["", " "] {
refused_nothing_journaled(&mut s, &dir, &format!("blank {blank:?}"), |s| {
s.void_reconciliation(r1.id, blank)
});
}
refused_nothing_journaled(&mut s, &dir, "unknown", |s| {
s.void_reconciliation(r1.id + 1_000, "why")
});
let (r, p) = one_event(&mut s, &dir, "reconciliation.voided", |s| {
s.void_reconciliation(r1.id, "cleared the wrong line")
});
r.unwrap();
assert_eq!(p["id"], json!(r1.id));
assert_eq!(p["reason"], json!("cleared the wrong line"));
refused_nothing_journaled(&mut s, &dir, "double void", |s| {
s.void_reconciliation(r1.id, "again")
});
let listed = s.reconciliations(Some(fx.cash)).unwrap();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].voided.as_deref(), Some("cleared the wrong line"));
assert_eq!(
Reconciliation {
voided: None,
..listed[0].clone()
},
r1,
"voiding changes nothing else"
);
// Redo the same statement.
let r2 = s.complete_reconciliation(jan).unwrap();
assert_ne!(r2.id, r1.id);
assert_eq!(r2.voided, None);
assert_eq!(r2.statement_date, date(2026, 1, 31));
assert_eq!(sorted(&r2.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1)]));
let mut got: Vec<u64> = s
.reconciliations(Some(fx.cash))
.unwrap()
.iter()
.map(|r| r.id)
.collect();
got.sort();
assert_eq!(got, sorted(&[r1.id, r2.id]));
assert!(
s.attachment_links(jan)
.unwrap()
.contains(&Subject::Reconciliation(r2.id))
);
// And the redo bounds the next one again.
let e = refused_nothing_journaled(&mut s, &dir, "after redo", |s| {
s.complete_reconciliation(jan)
});
assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}");
s.verify().unwrap();
}
// ---------------------------------------------------------------- replay
#[derive(Debug, PartialEq)]
struct View {
/// Attachment has no PartialEq: compared by its Debug form.
attachments: Vec<String>,
links: Vec<(u64, Vec<Subject>)>,
for_subjects: Vec<(Subject, Vec<u64>)>,
statements: Vec<Statement>,
statement_of: Vec<(u64, Option<Statement>)>,
reconciliations: Vec<Reconciliation>,
}
fn view(s: &Store, subjects: &[Subject]) -> View {
let attachments = s.list_attachments(None).unwrap();
View {
links: attachments
.iter()
.map(|a| (a.id, s.attachment_links(a.id).unwrap()))
.collect(),
for_subjects: subjects
.iter()
.map(|&x| (x, ids(&s.attachments_for(x).unwrap())))
.collect(),
statements: s.statements(None).unwrap(),
statement_of: attachments
.iter()
.map(|a| (a.id, s.statement(a.id).unwrap()))
.collect(),
reconciliations: s.reconciliations(None).unwrap(),
attachments: attachments.iter().map(|a| format!("{a:?}")).collect(),
}
}
/// Oracle: Store::open (the ledger is the replay of every journal line;
/// a missing database is rebuilt from the journal); Store::verify.
#[test]
fn everything_survives_replay_and_verifies() {
let dir = test_dir("replay");
let (mut s, fx) = fixture(&dir);
let mut subjects = every_subject(&mut s, &fx);
let a = doc(&mut s, "multi.pdf");
for &x in &subjects {
s.link_attachment(a, x).unwrap();
}
s.unlink_attachment(a, Subject::Company, "not formation")
.unwrap();
let (r, _, _) = s
.attach_receipt(Some(fx.e3), "r.pdf", "application/pdf", b"%PDF r3", None)
.unwrap();
s.link_attachment(r, Subject::Entry(fx.e2)).unwrap();
let w = doc(&mut s, "gone.pdf");
s.link_attachment(w, Subject::Entry(fx.e1)).unwrap();
s.unlink_attachment(w, Subject::Entry(fx.e1), "wrong")
.unwrap();
s.withdraw_attachment(w, "wrong").unwrap();
// every_subject reconciled January; void it, redo, then February.
let first = s.reconciliations(Some(fx.cash)).unwrap()[0].clone();
s.void_reconciliation(first.id, "redo").unwrap();
let redo = s.complete_reconciliation(first.attachment_id).unwrap();
s.mark_cleared(fx.e3, 1, true).unwrap();
let feb = doc(&mut s, "feb.pdf");
s.record_statement(stmt(
feb,
fx.cash,
date(2026, 2, 1),
date(2026, 2, 28),
"380.00",
"350.00",
))
.unwrap();
let r_feb = s.complete_reconciliation(feb).unwrap();
let c = doc(&mut s, "card.pdf");
s.record_statement(stmt(
c,
fx.card,
date(2026, 1, 1),
date(2026, 1, 31),
"0",
"75.00",
))
.unwrap();
subjects.extend([
Subject::Entry(fx.e1),
Subject::Entry(fx.e3),
Subject::Account(fx.cash),
Subject::Account(fx.card),
Subject::Reconciliation(first.id),
Subject::Reconciliation(redo.id),
Subject::Reconciliation(r_feb.id),
]);
s.verify().unwrap();
let before = view(&s, &subjects);
assert_eq!(before.statements.len(), 3);
assert_eq!(before.reconciliations.len(), 3);
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(view(&s, &subjects), before, "reopen");
s.verify().unwrap();
drop(s);
delete_db(&dir);
let s = Store::open(&dir).unwrap();
assert_eq!(view(&s, &subjects), before, "full replay");
s.verify().unwrap();
}