Files
wanderbooks/crates/ledger-store/tests/store.rs
T
swaitsandClaude Opus 5.5 b48addd0e0 feat: 1099 tracking — vendor forms, payment tags, year-end worksheet
Vendors carry form_1099 (NEC/MISC) and tin_on_file (never the TIN).
Entries carry the vendor they paid; bills, bill payments, and reversals
are tagged automatically. books.vendor_1099 counts cash paid per vendor
per calendar year (card payments excluded) against $600 through 2025 and
$2,000 from 2026, or an override, and flags missing W-9s. Four new tools
(64), two journal events, schema v7. Docs: finished R-FUTURE work is no
longer listed as future.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
2026-10-11 21:18:32 -07:00

686 lines
23 KiB
Rust

//! Store tests: the durability story, proven.
//!
//! Round-trip persistence, tamper detection, rebuild-from-journal healing,
//! blob deduplication and hash verification.
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JournalWriter, Store, StoreError};
use rust_decimal::Decimal;
use std::path::PathBuf;
fn usd(amount: u64) -> Money {
Money::new(Decimal::from(amount), Currency::USD)
}
fn test_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn build_books(dir: &std::path::Path) -> Store {
let mut store = Store::create(dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
let revenue = store
.add_account("4000", "Service Revenue", AccountType::Revenue, None, false)
.unwrap();
let rent = store
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
.unwrap();
let e1 = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(),
"Owner investment",
)
.unwrap();
store.add_line(e1, cash, Side::Debit, usd(10_000)).unwrap();
store
.add_line(e1, equity, Side::Credit, usd(10_000))
.unwrap();
store.post_entry(e1).unwrap();
let e2 = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 1, 10).unwrap(),
"Invoice client",
)
.unwrap();
store.add_line(e2, cash, Side::Debit, usd(4_000)).unwrap();
store
.add_line(e2, revenue, Side::Credit, usd(4_000))
.unwrap();
store.post_entry(e2).unwrap();
let e3 = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
"January rent",
)
.unwrap();
store.add_line(e3, rent, Side::Debit, usd(800)).unwrap();
store.add_line(e3, cash, Side::Credit, usd(800)).unwrap();
store.post_entry(e3).unwrap();
// A receipt attached to the rent entry.
let (_rid, sha, dedup) = store
.attach_receipt(
Some(e3),
"rent-receipt.png",
"image/png",
b"fake-png-bytes",
Some("January rent receipt"),
)
.unwrap();
assert!(!dedup);
assert_eq!(sha.len(), 64);
// A reversal, to exercise the reversal path through the journal.
store
.reverse_entry(
e3,
NaiveDate::from_ymd_opt(2026, 1, 16).unwrap(),
"Reverse rent",
)
.unwrap();
store
}
#[test]
fn round_trip_survives_reopen() {
let dir = test_dir("roundtrip");
let store = build_books(&dir);
// Live ledger: rent was posted then reversed → net zero movement on rent.
assert_eq!(
store
.ledger()
.account_movement(store.ledger().chart().find_by_code("6000").unwrap().id),
Decimal::from(0)
);
drop(store);
let store = Store::open(&dir).unwrap();
// Same facts after reopen: cash = 10000 + 4000 = 14000.
let cash_id = store.ledger().chart().find_by_code("1000").unwrap().id;
assert_eq!(
store.ledger().account_movement(cash_id),
Decimal::from(14_000)
);
// Trial balance still balances.
let tb = ledger_core::reports::trial_balance(store.ledger());
assert!(tb.balanced());
assert_eq!(tb.total_debits, Decimal::from(14_000));
// Receipt survived with its bytes.
let receipts = store.list_attachments(None).unwrap();
assert_eq!(receipts.len(), 1);
assert_eq!(receipts[0].file_name, "rent-receipt.png");
assert_eq!(
store.attachment_bytes(receipts[0].id).unwrap(),
b"fake-png-bytes"
);
// Full verification passes.
store.verify().unwrap();
assert_eq!(store.verify_blobs().unwrap(), 1);
}
#[test]
fn journal_tamper_is_detected() {
let dir = test_dir("tamper");
let store = build_books(&dir);
drop(store);
// Flip a byte in the middle of the journal.
let journal_path = dir.join(ledger_store::JOURNAL_FILE);
let mut bytes = std::fs::read(&journal_path).unwrap();
let mid = bytes.len() / 2;
bytes[mid] = if bytes[mid] == b'X' { b'Y' } else { b'X' };
std::fs::write(&journal_path, &bytes).unwrap();
let err = match Store::open(&dir) {
Ok(_) => panic!("expected the tampered journal to be rejected"),
Err(e) => e,
};
assert!(
format!("{err:?}").contains("JournalCorrupt"),
"expected JournalCorrupt, got {err:?}"
);
}
#[test]
fn torn_tail_is_truncatable() {
let dir = test_dir("torntail");
let store = build_books(&dir);
let seq_before = {
let w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.next_seq() - 1
};
drop(store);
// Simulate a torn backup: append half a line.
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(dir.join(ledger_store::JOURNAL_FILE))
.unwrap();
f.write_all(b"{\"seq\": 999, \"trunca").unwrap();
f.sync_all().unwrap();
drop(f);
// Strict open now fails...
assert!(Store::open(&dir).is_err());
// ...but truncation recovers to the last good seq.
let last_good =
ledger_store::journal::truncate_to_last_good_seq(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap();
assert_eq!(last_good, seq_before);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
}
#[test]
fn rebuild_heals_a_lost_database() {
let dir = test_dir("rebuild");
let mut store = build_books(&dir);
let tb_before = ledger_core::reports::trial_balance(store.ledger());
// Simulate total SQLite loss.
store.rebuild().unwrap();
let tb_after = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb_before.total_debits, tb_after.total_debits);
assert_eq!(tb_before.total_credits, tb_after.total_credits);
store.verify().unwrap();
// Attachments survived (metadata re-applied from the journal).
assert_eq!(store.list_attachments(None).unwrap().len(), 1);
}
#[test]
fn missing_database_heals_on_open() {
let dir = test_dir("healopen");
let store = build_books(&dir);
drop(store);
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
// No database at all: open rebuilds the projection from the journal.
let store = Store::open(&dir).unwrap();
let tb = ledger_core::reports::trial_balance(store.ledger());
assert!(tb.balanced());
store.verify().unwrap();
}
#[test]
fn blob_dedup_and_hash_verification() {
let dir = test_dir("blobs");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let bytes = b"same bytes twice";
let (_, sha1, dedup1) = store
.file_document("tax", "w2.pdf", "application/pdf", bytes, None)
.unwrap();
let (_, sha2, dedup2) = store
.file_document("tax", "w2-copy.pdf", "application/pdf", bytes, None)
.unwrap();
assert!(!dedup1);
assert!(dedup2);
assert_eq!(sha1, sha2);
// Only one blob row; two attachment rows.
assert_eq!(store.verify_blobs().unwrap(), 1);
assert_eq!(store.list_attachments(None).unwrap().len(), 2);
// Corrupt the blob file on disk → the next read detects it.
let shard = dir
.join("blobs")
.join(&sha1[0..2])
.join(&sha1[2..4])
.join(&sha1);
let mut raw = std::fs::read(&shard).unwrap();
raw[0] ^= 0xff;
std::fs::write(&shard, &raw).unwrap();
let err = match store.verify_blobs() {
Ok(_) => panic!("expected the corrupted blob to be detected"),
Err(e) => e,
};
assert!(format!("{err:?}").contains("CorruptBlob"));
}
#[test]
fn unbalanced_entry_never_reaches_the_journal() {
let dir = test_dir("unbalanced");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Owner's Equity", AccountType::Equity, None, false)
.unwrap();
let seq_before = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.next_seq();
let e = store
.create_entry(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap(), "Bad")
.unwrap();
store.add_line(e, cash, Side::Debit, usd(100)).unwrap();
store.add_line(e, equity, Side::Credit, usd(99)).unwrap();
assert!(store.post_entry(e).is_err());
// The failed post journaled nothing new: the draft creation and its
// two lines were already journaled (durable drafts); entry.posted was not.
let seq_after = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.next_seq();
assert_eq!(seq_after, seq_before + 3);
// And a fresh open sees a clean, balanced book.
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
}
#[test]
fn draft_lines_survive_reopen() {
// Drafts are durable: build a draft, close the store, reopen, and the
// draft comes back with its lines intact.
let dir = test_dir("draft-durable");
let (cash, revenue);
let draft_id;
{
let mut store = build_books(&dir);
cash = store.ledger().chart().find_by_code("1000").unwrap().id;
revenue = store.ledger().chart().find_by_code("4000").unwrap().id;
draft_id = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
"unposted sale",
)
.unwrap();
store
.add_line(draft_id, cash, Side::Debit, usd(250))
.unwrap();
store
.add_line(draft_id, revenue, Side::Credit, usd(250))
.unwrap();
// Draft is not posted: trial balance only sees the posted entry.
let tb = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb.total_debits, Decimal::from(14000));
}
// Reopen: the draft and its lines are back.
let mut store = Store::open(&dir).unwrap();
let draft = store.ledger().get_entry(draft_id).unwrap();
assert_eq!(draft.lines().len(), 2);
assert_eq!(draft.status(), ledger_core::EntryStatus::Draft);
// Post it after reopen: works, and the books balance.
store.post_entry(draft_id).unwrap();
let tb = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb.total_debits, Decimal::from(14250));
assert_eq!(tb.total_credits, Decimal::from(14250));
store.verify().unwrap();
}
#[test]
fn corrupt_projection_is_quarantined_and_rebuilt() {
let dir = test_dir("corrupt-projection");
{
let _store = build_books(&dir);
}
// Corrupt the SQLite file with garbage.
let db_path = dir.join(ledger_store::DB_FILE);
std::fs::write(&db_path, b"this is not a sqlite database at all").unwrap();
// Reopen heals from the journal instead of failing.
let store = Store::open(&dir).unwrap();
// The damaged file was quarantined for forensics.
let quarantined: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("wanderbooks.corrupt.")
})
.collect();
assert_eq!(quarantined.len(), 1);
// The books are intact and verify clean.
let tb = ledger_core::reports::trial_balance(store.ledger());
assert_eq!(tb.total_debits, Decimal::from(14000));
store.verify().unwrap();
}
#[test]
fn receipt_corrections_are_journaled_and_survive_rebuild() {
let dir = test_dir("receipt-corrections");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account("6000", "Rent Expense", AccountType::Expense, None, false)
.unwrap();
let e = store
.create_entry(
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
"February rent",
)
.unwrap();
store.add_line(e, rent, Side::Debit, usd(800)).unwrap();
store.add_line(e, cash, Side::Credit, usd(800)).unwrap();
store.post_entry(e).unwrap();
let (eml, _, _) = store
.attach_receipt(Some(e), "rent.eml", "message/rfc822", b"eml-bytes", None)
.unwrap();
let (pdf, _, _) = store
.attach_receipt(Some(e), "rent.pdf", "application/pdf", b"pdf-bytes", None)
.unwrap();
// Every correction needs a reason.
assert!(matches!(
store.unlink_receipt(eml, " "),
Err(StoreError::ReasonRequired)
));
// A linked receipt can't be withdrawn or moved: unlink it first.
assert!(matches!(
store.withdraw_attachment(eml, "dup"),
Err(StoreError::AttachmentLinked(a, b)) if a == eml && b == e
));
assert!(matches!(
store.link_receipt(pdf, e),
Err(StoreError::AttachmentLinked(_, _))
));
// Swap the .eml for the PDF: unlink, then withdraw.
assert_eq!(
store
.unlink_receipt(eml, "replaced by the PDF render")
.unwrap(),
e
);
assert!(matches!(
store.unlink_receipt(eml, "again"),
Err(StoreError::AttachmentNotLinked(_))
));
store
.withdraw_attachment(eml, "file PDFs, not .eml")
.unwrap();
assert!(matches!(
store.link_receipt(eml, e),
Err(StoreError::AttachmentWithdrawn(_))
));
assert!(matches!(
store.withdraw_attachment(eml, "again"),
Err(StoreError::AttachmentWithdrawn(_))
));
assert!(matches!(
store.unlink_receipt(999, "x"),
Err(StoreError::UnknownAttachment(999))
));
let check = |store: &Store| {
let linked: Vec<u64> = store
.list_attachments(Some(e))
.unwrap()
.iter()
.map(|a| a.id)
.collect();
assert_eq!(linked, vec![pdf]);
let all = store.list_attachments(None).unwrap();
let gone = all.iter().find(|a| a.id == eml).unwrap();
assert_eq!(gone.entry_id, None);
assert!(gone.withdrawn_at.is_some());
assert_eq!(
gone.withdrawn_reason.as_deref(),
Some("file PDFs, not .eml")
);
// Withdrawn is not deleted: the bytes are still there, hash-verified.
assert_eq!(store.attachment_bytes(eml).unwrap(), b"eml-bytes");
};
check(&store);
// The journal is the history: the attach, the unlink, and the withdrawal.
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
for t in [
"receipt.attached",
"receipt.unlinked",
"attachment.withdrawn",
] {
assert!(
journal.contains(&format!("\"type\":\"{t}\"")),
"missing {t}"
);
}
drop(store);
let mut store = Store::open(&dir).unwrap();
check(&store);
store.rebuild().unwrap();
check(&store);
store.verify().unwrap();
}
#[test]
fn a_close_into_members_capital_replays_exactly() {
let dir = test_dir("members-close");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let alice = store
.add_account("3000", "Capital — Alice", AccountType::Equity, None, false)
.unwrap();
let bob = store
.add_account("3001", "Capital — Bob", AccountType::Equity, None, false)
.unwrap();
let revenue = store
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
let jan15 = NaiveDate::from_ymd_opt(2026, 1, 15).unwrap();
let e = store.create_entry(jan15, "Sale").unwrap();
store
.add_line(
e,
cash,
Side::Debit,
Money::new(Decimal::new(10001, 2), Currency::USD),
)
.unwrap();
store
.add_line(
e,
revenue,
Side::Credit,
Money::new(Decimal::new(10001, 2), Currency::USD),
)
.unwrap();
store.post_entry(e).unwrap();
let jan = store
.create_period(
"2026-01",
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
)
.unwrap();
let half = Decimal::new(5, 1);
store
.close_period_to(
jan,
&ledger_core::CloseTo::Members(vec![
ledger_core::MemberClose {
capital: alice,
share: half,
distributions: None,
},
ledger_core::MemberClose {
capital: bob,
share: half,
distributions: None,
},
]),
)
.unwrap();
// 100.01 splits 50.01 / 50.00 into the capital accounts.
let balances = |s: &Store| {
(
s.ledger().account_balance(alice).unwrap(),
s.ledger().account_balance(bob).unwrap(),
)
};
let want = (Decimal::new(5001, 2), Decimal::new(5000, 2));
assert_eq!(balances(&store), want);
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
assert!(journal.contains("\"close_to\":{\"members\""));
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(balances(&store), want);
store.rebuild().unwrap();
assert_eq!(balances(&store), want);
store.verify().unwrap();
}
#[test]
fn schedules_and_recurring_entries_replay_exactly() {
let dir = test_dir("schedules");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let prepaid = store
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let ins = store
.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
let rent = store
.add_account("6600", "Rent", AccountType::Expense, None, false)
.unwrap();
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
store
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd_s("1000"),
start: date(1, 1),
months: 3,
balance_account: prepaid,
pl_account: ins,
})
.unwrap();
store
.register_recurring(ledger_core::NewRecurring {
description: "Rent".into(),
lines: vec![
(rent, Side::Debit, usd_s("500")),
(cash, Side::Credit, usd_s("500")),
],
frequency: ledger_core::Frequency::Monthly,
start: date(1, 1),
end: None,
})
.unwrap();
// 333.33, 333.33 through February; rent drafts for Jan 1 and Feb 1.
let posted = store.run_schedules(date(2, 28)).unwrap();
assert_eq!(posted.len(), 2);
let drafts = store.run_recurring(date(2, 28)).unwrap();
assert_eq!(
drafts.iter().map(|d| d.1).collect::<Vec<_>>(),
[date(1, 1), date(2, 1)]
);
store.post_entry(drafts[0].2).unwrap();
let state = |s: &Store| {
(
s.ledger().account_balance(ins).unwrap(),
s.ledger().account_balance(rent).unwrap(),
s.ledger().get_entry(drafts[1].2).unwrap().status(),
)
};
let want = (
Decimal::new(66666, 2),
Decimal::new(500, 0),
ledger_core::EntryStatus::Draft,
);
assert_eq!(state(&store), want);
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(state(&store), want);
store.rebuild().unwrap();
assert_eq!(state(&store), want);
store.verify().unwrap();
// Idempotent after replay: nothing new is due through February.
assert!(store.run_schedules(date(2, 28)).unwrap().is_empty());
assert!(store.run_recurring(date(2, 28)).unwrap().is_empty());
}
#[test]
fn vendor_1099_tags_replay_and_project() {
use ledger_core::arap::DocumentLine;
use ledger_core::{Form1099, vendor_1099_report};
let dir = test_dir("form1099");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let labor = store
.add_account("6000", "Contract Labor", AccountType::Expense, None, false)
.unwrap();
let dana = store.add_vendor("Dana").unwrap();
store
.set_vendor_1099(dana, Some(Form1099::Nec), true)
.unwrap();
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
let bill = store
.create_bill(
dana,
d(3, 1),
d(3, 31),
vec![DocumentLine {
description: "Design".into(),
amount: Decimal::from(1500),
}],
)
.unwrap();
store
.pay_bill(bill, d(3, 20), Decimal::from(1500), cash)
.unwrap();
let e = store.create_entry(d(6, 1), "Dana, June").unwrap();
store.add_line(e, labor, Side::Debit, usd(700)).unwrap();
store.add_line(e, cash, Side::Credit, usd(700)).unwrap();
store.post_entry(e).unwrap();
store.set_entry_vendor(e, Some(dana)).unwrap();
let dup = store.create_entry(d(6, 2), "Dana, duplicate").unwrap();
store.add_line(dup, labor, Side::Debit, usd(700)).unwrap();
store.add_line(dup, cash, Side::Credit, usd(700)).unwrap();
store.post_entry(dup).unwrap();
store.set_entry_vendor(dup, Some(dana)).unwrap();
store.reverse_entry(dup, d(6, 3), "duplicate").unwrap();
let before = vendor_1099_report(store.ledger(), 2026, None);
assert_eq!(before.vendors[0].paid, Decimal::from(2200));
assert!(before.vendors[0].reportable);
drop(store);
// Reopen (replay), then rebuild the projection from scratch.
for rebuild in [false, true] {
if rebuild {
std::fs::remove_file(dir.join(ledger_store::DB_FILE)).unwrap();
}
let store = Store::open(&dir).unwrap();
assert_eq!(vendor_1099_report(store.ledger(), 2026, None), before);
let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap();
let tagged: i64 = conn
.query_row(
"SELECT COUNT(*) FROM entry_vendors WHERE vendor_id = ?",
[dana as i64],
|r| r.get(0),
)
.unwrap();
// Bill, its payment, June, the duplicate, and its reversal.
assert_eq!(tagged, 5);
let form: String = conn
.query_row("SELECT form_1099 FROM vendors", [], |r| r.get(0))
.unwrap();
assert_eq!(form, "nec");
}
}