Files
wanderbooks/crates/ledger-store/tests/store.rs
T

4548 lines
176 KiB
Rust

//! Store tests: the durability story, proven.
//!
//! Round-trip persistence, tamper detection, rebuild-from-journal healing,
//! blob deduplication and hash verification.
use chrono::{Datelike, 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)
}
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&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!("wanderbooks-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(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:?}"
);
}
/// Oracle (rank 1, journal::repair_torn_tail's spec): an append cut off
/// mid-line was never acknowledged, so opening sets it aside, keeps every
/// acknowledged line, puts the repair on the record, and reports it.
#[test]
fn a_torn_tail_is_set_aside_on_open() {
let dir = test_dir("torntail");
let store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
let seq_before = JournalWriter::open(&journal).unwrap().next_seq() - 1;
drop(store);
use std::io::Write;
let torn = b"{\"seq\": 999, \"trunca";
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&journal)
.unwrap();
f.write_all(torn).unwrap();
f.sync_all().unwrap();
drop(f);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(
ledger_core::reports::snapshot(store.ledger()).unwrap(),
before
);
let repairs = store.tail_repairs().unwrap();
assert_eq!(repairs.len(), 1);
assert_eq!(repairs[0].1, torn.len() as u64);
// The cut-off bytes are kept, exactly, beside the journal.
assert_eq!(std::fs::read(dir.join(&repairs[0].2)).unwrap(), torn);
// The repair is the one new line, right after the last good one.
let lines = ledger_store::journal::read_and_verify_journal(&journal).unwrap();
assert_eq!(lines.len() as u64, seq_before + 1);
assert_eq!(lines.last().unwrap().line_type, "journal.tail_repaired");
// Opening again repairs nothing more.
drop(store);
let store = Store::open(&dir).unwrap();
assert_eq!(store.tail_repairs().unwrap().len(), 1);
store.verify().unwrap();
}
/// A damaged *complete* line is corruption, not a torn tail: opening still
/// refuses it, and the journal is left as it was.
#[test]
fn damage_before_the_last_newline_still_refuses_to_open() {
let dir = test_dir("tornmid");
drop(build_books(&dir));
let journal = dir.join(ledger_store::JOURNAL_FILE);
let mut raw = std::fs::read(&journal).unwrap();
let cut = raw.len() - 2; // inside the last complete line
raw.remove(cut - 5);
std::fs::write(&journal, &raw).unwrap();
assert!(Store::open(&dir).is_err());
assert_eq!(std::fs::read(&journal).unwrap(), raw);
}
/// Every file and directory under `from`, under `to`.
fn copy_tree(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_tree(&e.path(), &to.join(e.file_name()));
} else {
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
}
}
}
/// Power loss at any byte of the last append: whatever prefix reached the
/// disk, the books open with exactly what was acknowledged before it.
#[test]
fn power_loss_at_any_byte_of_the_last_append_is_recovered() {
let dir = test_dir("tornevery");
let store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let before_bytes = std::fs::read(&journal).unwrap();
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
drop(store);
// The books as they were on disk before the last append (the database
// is never ahead of the journal: the line is written first), blobs
// included: verify reads the receipt's extracted text from them (plan
// P6 step 2).
let pre = test_dir("tornevery-pre");
copy_tree(&dir, &pre);
let mut store = Store::open(&dir).unwrap();
store.add_customer("Acme").unwrap();
drop(store);
let full = std::fs::read(&journal).unwrap();
let last = &full[before_bytes.len()..];
assert_eq!(last.last(), Some(&b'\n'));
for cut in 1..last.len() {
let case = test_dir(&format!("tornevery-{cut}"));
copy_tree(&pre, &case);
let mut torn = before_bytes.clone();
torn.extend_from_slice(&last[..cut]);
std::fs::write(case.join(ledger_store::JOURNAL_FILE), &torn).unwrap();
let store = Store::open(&case).unwrap_or_else(|e| panic!("cut at {cut}: {e}"));
store.verify().unwrap();
assert_eq!(
ledger_core::reports::snapshot(store.ledger()).unwrap(),
before,
"cut at {cut}"
);
assert!(store.ledger().list_customers().is_empty(), "cut at {cut}");
drop(store);
let _ = std::fs::remove_dir_all(&case);
}
}
#[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);
// One blob row and one attachment row: the same bytes filed twice are
// one record (plan P6 step 4), under the first filing's name.
assert_eq!(store.verify_blobs().unwrap(), 1);
let records = store.list_attachments(None).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].file_name, "w2.pdf");
// 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| {
// Kept beside it under its own name: wanderbooks.v{N}.corrupt.*
let stem = ledger_store::db_file().trim_end_matches(".db").to_string();
e.file_name()
.to_string_lossy()
.starts_with(&format!("{stem}.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,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.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());
}
/// Regression (B-12, found by the refusal property on 2026-10-13):
/// `run_recurring` leaves occurrences in a closed period ungenerated
/// instead of creating drafts there, and still generates the rest.
#[test]
fn run_recurring_skips_occurrences_in_a_closed_period() {
let dir = test_dir("recurring-closed");
let date = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let usd_s = |s: &str| Money::new(s.parse().unwrap(), Currency::USD);
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", AccountType::Expense, None, false)
.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();
let jan = store
.create_period("2026-01", date(1, 1), date(1, 31))
.unwrap();
store.close_period(jan).unwrap();
let drafts = store.run_recurring(date(2, 28)).unwrap();
assert_eq!(drafts.iter().map(|d| d.1).collect::<Vec<_>>(), [date(2, 1)]);
assert_eq!(
store.ledger().recurring_in_closed_periods(date(2, 28)),
[(1, date(1, 1))]
);
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(
store
.ledger()
.recurring_in_closed_periods(date(2, 28))
.len(),
1
);
}
#[test]
fn vendor_1099_tags_replay_and_project() {
use ledger_core::arap::DocumentLine;
use ledger_core::{Dated1099, Decision1099, Form1099, PaymentAccounts, 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();
let d = |m, day| NaiveDate::from_ymd_opt(2026, m, day).unwrap();
store
.set_vendor_1099(
dana,
&Dated1099::from_beginning(
Decision1099::Files(Form1099::Nec),
Some(ledger_core::settings::Source {
decided_by: "Riley Chen".into(),
decided_on: d(1, 5),
citation: None,
note: None,
}),
),
Some(true),
)
.unwrap();
let report = |l: &ledger_core::Ledger| {
vendor_1099_report(
l,
2026,
None,
&PaymentAccounts {
cash: vec![cash],
financing: vec![],
},
&[],
None,
)
.unwrap()
};
let bill = store
.create_bill(
dana,
d(3, 1),
d(3, 31),
vec![DocumentLine {
description: "Design".into(),
amount: Decimal::from(1500),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.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 = report(store.ledger());
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!(report(store.ledger()), 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");
// The decision, with who made it, line by line.
let (decided, by): (String, String) = conn
.query_row(
"SELECT form_1099, source_json FROM vendor_1099_decisions",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(decided, "nec");
assert!(by.contains("Riley Chen"), "{by}");
}
}
#[test]
fn the_whole_projection_is_checked_and_healed() {
let dir = test_dir("heal");
let mut store = build_books(&dir);
let e = store
.ledger()
.posted_entries()
.next()
.map(|e| e.id())
.unwrap();
store
.attach_receipt(Some(e), "r.png", "image/png", b"receipt", None)
.unwrap();
store.verify().unwrap();
drop(store);
// Drift no accounting report would notice: a receipt quietly unlinked.
let db = dir.join(ledger_store::db_file());
let conn = rusqlite::Connection::open(&db).unwrap();
conn.execute("UPDATE attachments SET entry_id = NULL", [])
.unwrap();
drop(conn);
let mut store = Store::open(&dir).unwrap();
match store.verify() {
Err(StoreError::VerifyMismatch(why)) => assert!(why.contains("attachments"), "{why}"),
other => panic!("drift not caught: {other:?}"),
}
let report = store.verify_and_heal().unwrap();
assert!(report.healed.is_some(), "healed");
assert!(report.events > 0);
store.verify().unwrap();
assert_eq!(
store.list_attachments(Some(e)).unwrap().len(),
1,
"link restored"
);
let kept = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|d| d.ok())
.any(|d| d.file_name().to_string_lossy().contains(".mismatch."));
assert!(kept, "the divergent database is kept for forensics");
// A healthy store reports nothing to heal.
assert_eq!(store.verify_and_heal().unwrap().healed, None);
}
#[test]
fn a_draft_before_a_posted_entry_verifies_and_reopens() {
// The real case: a reimbursement drafted (#8, waiting on the bank),
// then a domain posted (#9). Drafts aren't in the projection, so its
// entry ids skip.
let dir = test_dir("draft-gap");
let mut store = build_books(&dir);
let cash = store.ledger().chart().find_by_code("1000").unwrap().id;
let equity = store.ledger().chart().find_by_code("3000").unwrap().id;
let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
let draft = store.create_entry(d, "waiting on the bank").unwrap();
store.add_line(draft, cash, Side::Debit, usd(5)).unwrap();
store.add_line(draft, equity, Side::Credit, usd(5)).unwrap();
let posted = store.create_entry(d, "posted after the draft").unwrap();
store.add_line(posted, cash, Side::Debit, usd(7)).unwrap();
store
.add_line(posted, equity, Side::Credit, usd(7))
.unwrap();
store.post_entry(posted).unwrap();
store.verify().unwrap();
drop(store);
let mut store = Store::open(&dir).unwrap();
assert_eq!(store.verify_and_heal().unwrap().healed, None);
}
/// Regression (found by the max-testing pass, 2026-10-13): reversing into a
/// closed period journaled the reversal *before* the engine refused it, so
/// the journal held an event replay could never apply and the books would
/// not reopen. Oracle (rank 1, spec R-3 + closed periods): a refused
/// command leaves no journal line, and the books always reopen.
#[test]
fn reversal_into_a_closed_period_is_refused_before_journaling() {
let dir = test_dir("reverse-closed");
let mut store = build_books(&dir);
let jan = store
.create_period(
"2026-01",
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
)
.unwrap();
store.close_period(jan).unwrap();
let posted = store.ledger().posted_entries().next().unwrap().id();
let journal = std::fs::read_to_string(dir.join("journal.jsonl")).unwrap();
let err = store.reverse_entry(
posted,
NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(),
"into a closed month",
);
assert!(err.is_err());
assert_eq!(
std::fs::read_to_string(dir.join("journal.jsonl")).unwrap(),
journal,
"a refused reversal must not reach the journal"
);
drop(store);
let reopened = Store::open(&dir).expect("the books still open");
reopened.verify().unwrap();
}
// ---------------------------------------------------------------------------
// Refusals never reach the journal (rank 2: a law over every command
// sequence). Found by the max-testing pass, 2026-10-13: a reversal into a
// closed period was journaled before the engine refused it, and the books
// would not reopen.
// ---------------------------------------------------------------------------
mod refusals {
use super::*;
use ledger_core::{DocumentLine, Form1099};
use proptest::prelude::*;
/// One store command, with arguments drawn to hit both the happy path
/// and every refusal: small id spaces so unknown, duplicate, posted,
/// closed, and already-reversed cases come up constantly.
#[derive(Clone, Debug)]
enum Op {
AddAccount(u8, u8, Option<u64>, bool),
CreateEntry(u32),
AddLine(u64, u64, bool, i64),
Post(u64),
Reverse(u64, u32),
CreatePeriod(u32, u32),
Close(u64),
AddCustomer(bool),
AddVendor(bool),
Invoice(u64, u32, u32, i64),
Bill(u64, u32, u32, i64),
ReceivePayment(u64, u32, i64, u64),
PayBill(u64, u32, i64, u64),
/// A payment with its method and reference (plan P4b S1): an
/// invoice's (true) or a bill's, id, day, cents, account, payment
/// method (maybe unknown), reference (none, a good one, two lines).
PayWith(bool, u64, u32, i64, u64, Option<u64>, u8),
/// Void an invoice (true) or a bill: id, day, reason (good, blank).
Void(bool, u64, u32, bool),
AddClass(u8),
SetLineClass(u64, usize, Option<u32>),
SetEntryVendor(u64, Option<u64>),
/// A 1099 decision (0 undetermined, 1 nec, 2 a corporation, 3 a
/// blank reason, 4 no source), dated or not, with a W-9 flag or
/// none.
SetVendor1099(u64, u8, Option<u32>, Option<bool>),
SetW9Flag(u64, bool),
/// A payment method: kind, label, account.
AddInstrument(u8, u8, u64),
SetEntryInstrument(u64, Option<u64>),
SetAccountTax(u64, u8),
Renumber(u64, u8),
// P3 (defects 8, 15): renames, accounts added with a category,
// and the tax category policy turned on and off.
Rename(u64, u8),
AddAccountTax(u8, u8, u8, bool),
SetTaxPolicy(bool),
// The rest of the command surface (2026-10-13).
AttachReceipt(Option<u64>, u8),
FileDocument(u8),
LinkReceipt(u64, u64),
UnlinkReceipt(u64),
Withdraw(u64),
SetFxRate(u32, i64),
AddLineFx(u64, u64, bool, i64, i64),
RegisterAsset(u64, u64, u64, i64, u32),
RunDepreciation(u64, u32),
DisposeAsset(u64, u32, i64, u64, u64),
RunPayroll(u32, i64, i64, u64),
Remit(u32, u64),
RegisterSchedule(bool, i64, u32, u32, u64, u64, bool),
PostScheduleMonth(u64, u32),
RegisterRecurring(u64, u64, i64, u32),
RunRecurring(u32),
MarkCleared(u64, usize, bool),
CloseMembers(u64, u64, u64),
/// A balanced pair of lines on a draft, so entries post.
Pair(u64, u64, u64, i64),
// Compound commands with valid arguments, so the success paths
// that need a chain of earlier steps get exercised too.
Book(u32, u64, u64, i64),
GoodAsset(i64, u32),
GoodSchedule(bool, i64, u32, bool),
GoodInvoice(u32, i64),
GoodBill(u32, i64),
GoodClose(u32),
}
fn day(n: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(n % 120))
}
/// A schedule's convention: by the day, or equal months (refused
/// unless it starts on the 1st, D-37).
fn convention(daily: bool) -> ledger_core::ScheduleConvention {
if daily {
ledger_core::ScheduleConvention::Daily
} else {
ledger_core::ScheduleConvention::MonthlyEven
}
}
/// Cents, biased toward the edges: zero, negative, tiny, huge.
fn arb_cents() -> impl Strategy<Value = i64> {
prop_oneof![
4 => 1i64..200_000,
1 => Just(0i64),
1 => -50_000i64..0,
1 => Just(i64::MAX / 2),
]
}
fn arb_op() -> impl Strategy<Value = Op> {
let id = 0u64..12;
prop_oneof![
(0u8..8, 0u8..5, prop::option::of(0u64..10), any::<bool>())
.prop_map(|(c, t, p, h)| Op::AddAccount(c, t, p, h)),
(0u32..120).prop_map(Op::CreateEntry),
(id.clone(), 0u64..10, any::<bool>(), arb_cents())
.prop_map(|(e, a, d, c)| Op::AddLine(e, a, d, c)),
id.clone().prop_map(Op::Post),
(id.clone(), 0u32..120).prop_map(|(e, d)| Op::Reverse(e, d)),
(0u32..120, 0u32..120).prop_map(|(a, b)| Op::CreatePeriod(a, b)),
(0u64..5).prop_map(Op::Close),
any::<bool>().prop_map(Op::AddCustomer),
any::<bool>().prop_map(Op::AddVendor),
(0u64..4, 0u32..120, 0u32..120, arb_cents())
.prop_map(|(c, d, due, a)| Op::Invoice(c, d, due, a)),
(0u64..4, 0u32..120, 0u32..120, arb_cents())
.prop_map(|(v, d, due, a)| Op::Bill(v, d, due, a)),
(0u64..4, 0u32..120, arb_cents(), 0u64..10)
.prop_map(|(i, d, a, c)| Op::ReceivePayment(i, d, a, c)),
(0u64..4, 0u32..120, arb_cents(), 0u64..10)
.prop_map(|(b, d, a, c)| Op::PayBill(b, d, a, c)),
(
any::<bool>(),
0u64..4,
0u32..120,
arb_cents(),
0u64..10,
prop::option::of(0u64..4),
0u8..3
)
.prop_map(|(k, i, d, a, c, m, r)| Op::PayWith(k, i, d, a, c, m, r)),
(any::<bool>(), 0u64..4, 0u32..120, any::<bool>())
.prop_map(|(k, i, d, r)| Op::Void(k, i, d, r)),
(0u8..3).prop_map(Op::AddClass),
(id.clone(), 0usize..4, prop::option::of(0u32..4))
.prop_map(|(e, i, c)| Op::SetLineClass(e, i, c)),
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, v)| Op::SetEntryVendor(e, v)),
(
0u64..4,
0u8..5,
prop::option::of(0u32..120),
prop::option::of(any::<bool>())
)
.prop_map(|(v, k, d, t)| Op::SetVendor1099(v, k, d, t)),
(0u64..4, any::<bool>()).prop_map(|(v, t)| Op::SetW9Flag(v, t)),
(0u8..6, 0u8..3, 0u64..12).prop_map(|(k, l, a)| Op::AddInstrument(k, l, a)),
(id.clone(), prop::option::of(0u64..4)).prop_map(|(e, i)| Op::SetEntryInstrument(e, i)),
(0u64..10, 0u8..4).prop_map(|(a, k)| Op::SetAccountTax(a, k)),
(0u64..10, 0u8..9).prop_map(|(a, c)| Op::Renumber(a, c)),
(0u64..12, 0u8..8).prop_map(|(a, n)| Op::Rename(a, n)),
(0u8..8, 0u8..5, 0u8..4, any::<bool>())
.prop_map(|(c, t, k, h)| Op::AddAccountTax(c, t, k, h)),
any::<bool>().prop_map(Op::SetTaxPolicy),
(prop::option::of(id.clone()), 0u8..4).prop_map(|(e, b)| Op::AttachReceipt(e, b)),
(0u8..4).prop_map(Op::FileDocument),
(0u64..6, id.clone()).prop_map(|(a, e)| Op::LinkReceipt(a, e)),
(0u64..6).prop_map(Op::UnlinkReceipt),
(0u64..6).prop_map(Op::Withdraw),
(0u32..120, -2i64..30_000).prop_map(|(d, r)| Op::SetFxRate(d, r)),
(
id.clone(),
0u64..10,
any::<bool>(),
arb_cents(),
1i64..30_000
)
.prop_map(|(e, a, s, f, r)| Op::AddLineFx(e, a, s, f, r)),
(0u64..10, 0u64..10, 0u64..10, arb_cents(), 0u32..40)
.prop_map(|(c, d, x, cost, m)| Op::RegisterAsset(c, d, x, cost, m)),
(0u64..4, 0u32..12).prop_map(|(a, m)| Op::RunDepreciation(a, m)),
(0u64..4, 0u32..120, arb_cents(), 0u64..10, 0u64..10)
.prop_map(|(a, d, p, c, g)| Op::DisposeAsset(a, d, p, c, g)),
(0u32..120, arb_cents(), arb_cents(), 0u64..10)
.prop_map(|(d, g, w, c)| Op::RunPayroll(d, g, w, c)),
(0u32..120, 0u64..10).prop_map(|(d, c)| Op::Remit(d, c)),
(
any::<bool>(),
arb_cents(),
0u32..120,
0u32..30,
0u64..10,
0u64..10,
any::<bool>()
)
.prop_map(|(k, t, st, m, b, p, daily)| Op::RegisterSchedule(
k, t, st, m, b, p, daily
)),
(0u64..4, 0u32..12).prop_map(|(s, m)| Op::PostScheduleMonth(s, m)),
(0u64..10, 0u64..10, arb_cents(), 0u32..120)
.prop_map(|(a, b, c, d)| Op::RegisterRecurring(a, b, c, d)),
(0u32..180).prop_map(Op::RunRecurring),
(id.clone(), 0usize..4, any::<bool>()).prop_map(|(e, i, c)| Op::MarkCleared(e, i, c)),
(0u64..5, 0u64..10, 0u64..10).prop_map(|(p, a, b)| Op::CloseMembers(p, a, b)),
(id.clone(), 1u64..10, 1u64..10, 1i64..500_000)
.prop_map(|(e, a, b, c)| Op::Pair(e, a, b, c)),
(id.clone(), 1u64..10, 1u64..10, 1i64..500_000)
.prop_map(|(e, a, b, c)| Op::Pair(e, a, b, c)),
(id.clone(), 1u64..10, 1u64..10, 1i64..500_000)
.prop_map(|(e, a, b, c)| Op::Pair(e, a, b, c)),
id.clone().prop_map(Op::Post),
id.clone().prop_map(Op::Post),
(0u32..120, 1u64..10, 1u64..10, 1i64..500_000)
.prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)),
(0u32..120, 1u64..10, 1u64..10, 1i64..500_000)
.prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)),
(0u32..120, 1u64..10, 1u64..10, 1i64..500_000)
.prop_map(|(d, a, b, c)| Op::Book(d, a, b, c)),
(1i64..5_000_000, 1u32..60).prop_map(|(c, m)| Op::GoodAsset(c, m)),
(any::<bool>(), 1i64..1_000_000, 1u32..24, any::<bool>())
.prop_map(|(k, t, m, daily)| Op::GoodSchedule(k, t, m, daily)),
(0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodInvoice(d, c)),
(0u32..120, 1i64..1_000_000).prop_map(|(d, c)| Op::GoodBill(d, c)),
(1u32..5).prop_map(Op::GoodClose),
]
}
const CODES: [&str; 8] = [
"1000", "1200", "2000", "3000", "3999", "4000", "6000", "6100",
];
const TYPES: [AccountType; 5] = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
const TAX: [Option<&str>; 4] = [
None,
Some("advertising"),
Some("no-such-category"),
Some(""),
];
/// Runs one op. A compound op is several commands; `mark` is called
/// after each step that succeeded, so the refusal law is checked
/// against the one command that was refused.
fn apply(store: &mut Store, op: &Op, mark: &mut dyn FnMut()) -> Result<(), StoreError> {
let usd = |c: i64| Money::new(Decimal::new(c, 2), Currency::USD);
let line = |c: i64| {
vec![DocumentLine {
description: "work".into(),
amount: Decimal::new(c, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}]
};
match *op {
// Named for the code: a sibling's name is refused (B-31), and
// a code is the first refusal.
Op::AddAccount(c, t, p, h) => store
.add_account(
CODES[c as usize],
&format!("Account {}", CODES[c as usize]),
TYPES[t as usize],
p,
h,
)
.map(drop),
// A setup account's own name, another's (in other capitals),
// a blank, nine digits, and fresh names.
Op::Rename(a, n) => store.rename_account(
a,
[
"Sales",
"sales",
"CASH",
"",
"Card 123456789",
"Renamed",
"Office",
"Rent ",
][n as usize],
(n % 2 == 0).then_some("owner asked"),
),
Op::AddAccountTax(c, t, k, h) => store
.add_account_with_tax(
CODES[c as usize],
&format!("Taxed {}", CODES[c as usize]),
TYPES[t as usize],
None,
h,
TAX[k as usize].map(|cat| (cat, None)),
)
.map(drop),
Op::SetTaxPolicy(refuse) => store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(serde_json::json!(if refuse { "refuse" } else { "warn" })),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: day(0),
citation: None,
note: None,
},
)
.map(drop),
Op::CreateEntry(d) => store.create_entry(day(d), "e").map(drop),
Op::AddLine(e, a, d, c) => {
let side = if d { Side::Debit } else { Side::Credit };
store.add_line(e, a, side, usd(c))
}
Op::Post(e) => store.post_entry(e),
Op::Reverse(e, d) => store.reverse_entry(e, day(d), "undo").map(drop),
Op::CreatePeriod(a, b) => store.create_period("p", day(a), day(b)).map(drop),
Op::Close(p) => store.close_period(p).map(drop),
Op::AddCustomer(blank) => store
.add_customer(if blank { " " } else { "Acme" })
.map(drop),
Op::AddVendor(blank) => store.add_vendor(if blank { "" } else { "Vend" }).map(drop),
Op::Invoice(c, d, due, a) => {
store.create_invoice(c, day(d), day(due), line(a)).map(drop)
}
Op::Bill(v, d, due, a) => store.create_bill(v, day(d), day(due), line(a)).map(drop),
Op::ReceivePayment(i, d, a, c) => store
.receive_payment(i, day(d), Decimal::new(a, 2), c)
.map(drop),
Op::PayBill(b, d, a, c) => store.pay_bill(b, day(d), Decimal::new(a, 2), c).map(drop),
Op::PayWith(invoice, i, d, a, c, m, r) => {
let details = ledger_core::PaymentDetails {
instrument: m,
reference: [None, Some("ch_3Nx 0042"), Some("two\nlines")][r as usize]
.map(str::to_string),
};
if invoice {
store
.receive_payment_with(i, day(d), Decimal::new(a, 2), c, &details)
.map(drop)
} else {
store
.pay_bill_with(i, day(d), Decimal::new(a, 2), c, &details)
.map(drop)
}
}
Op::Void(invoice, i, d, reason) => {
let doc = if invoice {
ledger_core::DocRef::Invoice(i)
} else {
ledger_core::DocRef::Bill(i)
};
store
.void_document(doc, day(d), if reason { "duplicate" } else { " " })
.map(drop)
}
Op::AddClass(n) => store.add_class(["A", "B", ""][n as usize]).map(drop),
Op::SetLineClass(e, i, c) => store.set_line_class(e, i, c),
Op::SetEntryVendor(e, v) => store.set_entry_vendor(e, v),
Op::SetVendor1099(v, k, d, t) => {
let decision = match k {
0 => ledger_core::Decision1099::Undetermined,
1 => ledger_core::Decision1099::Files(Form1099::Nec),
2 => ledger_core::Decision1099::not_reportable(
ledger_core::ExemptReason::Corporation,
"a corporation by name",
)
.unwrap(),
_ => ledger_core::Decision1099::NotReportable {
kind: ledger_core::ExemptReason::Other,
reason: " ".into(),
},
};
let source = (k != 4).then(|| ledger_core::settings::Source {
decided_by: "Riley Chen".into(),
decided_on: day(0),
citation: None,
note: None,
});
let dated = ledger_core::Dated1099 {
effective: d.map(day),
decision,
source,
};
store.set_vendor_1099(v, &dated, t).map(drop)
}
Op::SetW9Flag(v, t) => store.set_vendor_w9_flag(v, t).map(drop),
Op::AddInstrument(k, l, a) => store
.add_instrument(
[
ledger_core::InstrumentKind::Bank,
ledger_core::InstrumentKind::BusinessCard,
ledger_core::InstrumentKind::MemberCard,
ledger_core::InstrumentKind::MemberCashOrCheck,
ledger_core::InstrumentKind::Platform,
ledger_core::InstrumentKind::Bank,
][k as usize],
["Harbor Bank", "Riley's Visa", ""][l as usize],
(k == 5).then_some("12345"),
None,
a,
)
.map(drop),
Op::SetEntryInstrument(e, i) => store.set_entry_instrument(e, i).map(drop),
Op::SetAccountTax(a, k) => store.set_account_tax(a, TAX[k as usize], None),
// The chart's codes, control codes (payroll's 6100 and 2110,
// revenue, receivables), a blank, and fresh ones.
Op::Renumber(a, c) => store.renumber_account(
a,
[
"1000", "6100", "2110", "4000", "1200", "", "7001", "7002", "3999",
][c as usize],
),
Op::AttachReceipt(e, b) => store
.attach_receipt(e, "r.pdf", "application/pdf", &[b; 3], Some("receipt"))
.map(drop),
Op::FileDocument(b) => store
.file_document("legal", "d.pdf", "application/pdf", &[b, 9], None)
.map(drop),
Op::LinkReceipt(a, e) => store.link_receipt(a, e),
Op::UnlinkReceipt(a) => store.unlink_receipt(a, "wrong receipt").map(drop),
Op::Withdraw(a) => store.withdraw_attachment(a, "duplicate"),
Op::SetFxRate(d, r) => store.set_fx_rate(Currency::EUR, day(d), Decimal::new(r, 4)),
Op::AddLineFx(e, a, d, f, r) => {
let side = if d { Side::Debit } else { Side::Credit };
store.add_line_fx(
e,
a,
side,
Currency::EUR,
Decimal::new(f, 2),
Decimal::new(r, 4),
)
}
Op::RegisterAsset(c, d, x, cost, m) => store
.register_asset(ledger_core::NewAsset {
description: "van".into(),
in_service_date: day(0),
cost: usd(cost),
salvage_value: usd(0),
useful_life_months: m,
cost_account: c,
accum_depr_account: d,
expense_account: x,
})
.map(drop),
Op::RunDepreciation(a, m) => store
.run_depreciation(a, &format!("2026-{:02}", m + 1))
.map(drop),
Op::DisposeAsset(a, d, p, c, g) => {
store.dispose_asset(a, day(d), usd(p), c, g).map(drop)
}
Op::RunPayroll(d, g, w, c) => store
.run_payroll(
day(d),
vec![ledger_core::PayrollLine {
employee_name: "Pat".into(),
employee: None,
gross_pay: Decimal::new(g, 2),
federal_withholding: Decimal::new(w, 2),
state_withholding: Decimal::ZERO,
ss_employee: Decimal::ZERO,
medicare_employee: Decimal::ZERO,
// 6.2%, rounded to the cent as a caller must.
ss_employer: (Decimal::new(g, 2) * Decimal::new(62, 3)).round_dp(2),
medicare_employer: Decimal::ZERO,
other_deductions: Decimal::ZERO,
}],
c,
)
.map(drop),
Op::Remit(d, c) => store.remit_payroll_taxes(day(d), c).map(drop),
Op::RegisterSchedule(k, t, st, m, b, pl, daily) => store
.register_schedule(ledger_core::NewSchedule {
description: "sched".into(),
kind: if k {
ledger_core::ScheduleKind::PrepaidExpense
} else {
ledger_core::ScheduleKind::DeferredRevenue
},
total: usd(t),
start: day(st),
months: m,
balance_account: b,
pl_account: pl,
convention: convention(daily),
})
.map(drop),
Op::PostScheduleMonth(id, m) => {
let first = NaiveDate::from_ymd_opt(2026, m + 1, 1).unwrap();
let end = first + chrono::Months::new(1) - chrono::Days::new(1);
store.post_schedule_month(id, end).map(drop)
}
Op::RegisterRecurring(a, b, c, d) => store
.register_recurring(ledger_core::NewRecurring {
description: "rent".into(),
lines: vec![(a, Side::Debit, usd(c)), (b, Side::Credit, usd(c))],
frequency: ledger_core::Frequency::Monthly,
start: day(d),
end: None,
})
.map(drop),
Op::RunRecurring(d) => store
.run_recurring(
NaiveDate::from_ymd_opt(2026, 1, 1).unwrap() + chrono::Days::new(u64::from(d)),
)
.map(drop),
Op::MarkCleared(e, i, c) => store.mark_cleared(e, i, c),
Op::Book(d, a, b, c) => {
let e = store.create_entry(day(d), "book")?;
mark();
store.add_line(e, a, Side::Debit, usd(c))?;
mark();
store.add_line(e, b, Side::Credit, usd(c))?;
mark();
store.post_entry(e)
}
Op::GoodAsset(c, m) => store
.register_asset(ledger_core::NewAsset {
description: "machine".into(),
in_service_date: day(0),
cost: usd(c),
salvage_value: usd(0),
useful_life_months: m,
cost_account: 2,
accum_depr_account: 3,
expense_account: 9,
})
.map(drop),
Op::GoodSchedule(k, t, m, daily) => store
.register_schedule(ledger_core::NewSchedule {
description: "insurance".into(),
kind: if k {
ledger_core::ScheduleKind::PrepaidExpense
} else {
ledger_core::ScheduleKind::DeferredRevenue
},
total: usd(t),
start: day(0),
months: m,
balance_account: if k { 1 } else { 6 },
pl_account: if k { 8 } else { 7 },
convention: convention(daily),
})
.map(drop),
Op::GoodInvoice(d, c) => {
let who = store.add_customer("Client")?;
mark();
store
.create_invoice(who, day(d), day(d) + chrono::Days::new(30), line(c))
.map(drop)
}
Op::GoodBill(d, c) => {
let who = store.add_vendor("Supplier")?;
mark();
store
.create_bill(who, day(d), day(d) + chrono::Days::new(30), line(c))
.map(drop)
}
Op::GoodClose(m) => {
let start = NaiveDate::from_ymd_opt(2026, m, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2026, m + 1, 1)
.unwrap()
.pred_opt()
.unwrap();
let p = store.create_period(&format!("2026-{m:02}"), start, end)?;
mark();
store
.close_period_to(
p,
&ledger_core::CloseTo::Members(vec![
ledger_core::MemberClose {
capital: 4,
share: Decimal::new(5, 1),
distributions: None,
},
ledger_core::MemberClose {
capital: 5,
share: Decimal::new(5, 1),
distributions: None,
},
]),
)
.map(drop)
}
Op::Pair(e, a, b, c) => {
store.add_line(e, a, Side::Debit, usd(c))?;
mark();
store.add_line(e, b, Side::Credit, usd(c))
}
Op::CloseMembers(p, a, b) => store
.close_period_to(
p,
&ledger_core::CloseTo::Members(vec![
ledger_core::MemberClose {
capital: a,
share: Decimal::new(5, 1),
distributions: None,
},
ledger_core::MemberClose {
capital: b,
share: Decimal::new(5, 1),
distributions: None,
},
]),
)
.map(drop),
}
}
/// The Lean model's `Inv` (verify/lean/Wanderbooks/Ledger.lean), checked
/// on the real store: every posted entry is acceptable, the books net
/// to zero, and no draft is dated in a closed period.
fn check_invariants(store: &Store) -> Result<(), TestCaseError> {
let l = store.ledger();
for e in l.posted_entries() {
prop_assert!(
e.validate(l.chart()).is_ok(),
"posted entry {} invalid",
e.id()
);
}
let tb = ledger_core::reports::trial_balance(l);
prop_assert_eq!(tb.total_debits, tb.total_credits);
prop_assert_eq!(store.list_classes().len(), l.list_classes().len());
prop_assert_eq!(store.list_payroll_runs().len(), l.list_payroll_runs().len());
for id in 1..l.next_entry_id() {
if let Some(e) = l.get_entry(id)
&& e.status() == ledger_core::EntryStatus::Draft
{
prop_assert!(
l.closed_period_containing(e.date()).is_none(),
"draft {} is dated {} inside a closed period",
id,
e.date()
);
}
}
Ok(())
}
proptest! {
// 256 cases on every check; `STORE_CASES=5000` for a deeper run.
#![proptest_config(ProptestConfig {
cases: std::env::var("STORE_CASES").ok().and_then(|v| v.parse().ok()).unwrap_or(256),
failure_persistence: None,
..ProptestConfig::default()
})]
/// A refused command leaves the journal byte-identical, and after
/// any sequence the books reopen and verify.
#[test]
fn refused_commands_never_reach_the_journal(
ops in prop::collection::vec(arb_op(), 1..40),
seed in any::<u64>(),
) {
run_ops(&ops, seed)?;
}
}
/// Run one sequence of ops against fresh books and check every law.
fn run_ops(ops: &[Op], seed: u64) -> Result<(), TestCaseError> {
let dir = test_dir(&format!("refusals-{seed}"));
let mut store = Store::create(&dir, Currency::USD).unwrap();
// Start from realistic books, so most commands act on real
// things and refusals come from bad arguments (the first
// version started empty and almost nothing ever posted).
// Ids 1..=9: cash, equipment, accumulated depreciation, two
// members' capital, a liability, sales, rent, depreciation.
for (code, name, t) in [
("1000", "Cash", AccountType::Asset),
("1500", "Equipment", AccountType::Asset),
("1510", "Accumulated depreciation", AccountType::Asset),
("3000", "Capital A", AccountType::Equity),
("3001", "Capital B", AccountType::Equity),
("2500", "Loan", AccountType::Liability),
("4100", "Sales", AccountType::Revenue),
("6000", "Rent", AccountType::Expense),
("6200", "Depreciation", AccountType::Expense),
] {
store.add_account(code, name, t, None, false).unwrap();
}
for (n, (dr, cr, cents)) in [(1u64, 4u64, 1_000_000i64), (8, 1, 120_000), (1, 7, 250_000)]
.into_iter()
.enumerate()
{
let e = store.create_entry(day(n as u32 * 10), "setup").unwrap();
let m = Money::new(Decimal::new(cents, 2), Currency::USD);
store.add_line(e, dr, Side::Debit, m).unwrap();
store.add_line(e, cr, Side::Credit, m).unwrap();
store.post_entry(e).unwrap();
}
let journal = dir.join(ledger_store::JOURNAL_FILE);
for op in ops {
check_invariants(&store)?;
let ledger_before = store.ledger().clone();
let mut before = std::fs::read_to_string(&journal).unwrap();
let result = apply(&mut store, op, &mut || {
before = std::fs::read_to_string(&journal).unwrap();
});
if std::env::var("STORE_STATS").is_ok() {
let kind = format!("{op:?}");
let kind = kind.split('(').next().unwrap_or("");
match &result {
Ok(()) => eprintln!("OK {kind}"),
Err(e) => eprintln!("REFUSED {kind} {e}"),
}
}
// The converse law: an accepted command is on the record, or
// changed nothing at all (a batch run with nothing due; a
// setting changed to the value it has; a 1099 decision, W-9
// flag or payment method already so; bytes already on file,
// which are the record they were, plan P6 step 4).
if result.is_ok() {
let after = std::fs::read_to_string(&journal).unwrap();
if after.len() == before.len() {
let parts = ledger_before.differs_from(store.ledger());
prop_assert!(
parts.is_empty(),
"accepted {:?}, journaled nothing, but changed {:?}",
op,
parts
);
prop_assert!(
matches!(
op,
Op::RunDepreciation(..)
| Op::RunRecurring(..)
| Op::SetTaxPolicy(..)
| Op::SetVendor1099(..)
| Op::SetW9Flag(..)
| Op::SetEntryInstrument(..)
| Op::AttachReceipt(..)
| Op::FileDocument(..)
),
"accepted {:?} but journaled nothing",
op
);
}
}
if let Err(e) = result {
let after = std::fs::read_to_string(&journal).unwrap();
prop_assert!(
after == before,
"refused {:?} ({}) but the journal grew by:\n{}",
op,
e,
after.strip_prefix(&before).unwrap_or(&after)
);
}
}
check_invariants(&store)?;
// Reopening gives back exactly these books (rank 3: the live
// ledger against journal replay).
// The ledger the commands built against its own journal's
// replay, every part of it (rank 3).
let verified = store.verify();
prop_assert!(verified.is_ok(), "live vs. replay: {:?}", verified.err());
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
let live = store.ledger().clone();
let attachments = store.list_attachments(None).unwrap().len();
drop(store);
let reopened = Store::open(&dir);
prop_assert!(
reopened.is_ok(),
"books failed to reopen: {:?}",
reopened.err()
);
let reopened = reopened.unwrap();
reopened.verify().unwrap();
prop_assert_eq!(
ledger_core::reports::snapshot(reopened.ledger()).unwrap(),
before
);
let parts = live.differs_from(reopened.ledger());
prop_assert!(parts.is_empty(), "reopened books differ in: {:?}", parts);
prop_assert_eq!(reopened.list_attachments(None).unwrap().len(), attachments);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
/// Regression (CI, 2026-10-13): a payroll run with unrounded employer
/// tax posted $65.27794, and sums of it differed in scale between the
/// journal replay and the SQLite read-back. Posting now refuses
/// fractions of a cent (B-3; `no_entry_posts_a_fraction_of_the_minor_unit`);
/// this replays the sequence with the tax rounded, as callers must.
#[test]
fn regression_payroll_with_a_fraction_of_a_cent() {
run_ops(
&[
Op::Book(0, 1, 1, 1),
Op::Book(0, 1, 1, 1),
Op::CreateEntry(0),
Op::RunPayroll(0, 105_287, 1, 1),
Op::GoodClose(2),
Op::Pair(6, 8, 1, 1),
Op::Post(6),
],
0,
)
.unwrap();
}
}
/// Contract (2026-10-13): rules added to the engine never lock anyone out
/// of books written before them. A journal holding what the new rules
/// refuse (a child account of another type, partially overlapping periods,
/// a draft line on a missing account) must still open and verify; replay
/// goes through Ledger::replay_*. Each phase adds what its books hold (P0,
/// 2026-10-16: the journal format declared, `journal.format_set` format 1).
#[test]
fn books_written_under_older_rules_still_open() {
use serde_json::json;
let dir = test_dir("older-rules");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, true)
.unwrap();
drop(store);
// What an older wanderbooks could have written.
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append("journal.format_set", json!({"format": 1, "features": []}))
.unwrap();
w.append(
"account.added",
json!({"id": 2, "code": "1010", "name": "Rent", "account_type": "Expense",
"parent": cash, "placeholder": false, "currency": "USD"}),
)
.unwrap();
w.append(
"period.created",
json!({"id": 1, "name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
)
.unwrap();
w.append(
"period.created",
json!({"id": 2, "name": "straddle", "start": "2026-01-15", "end": "2026-02-15"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 1, "date": "2026-03-01", "description": "draft"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 1, "account": 999, "side": "debit", "amount": "10.00", "currency": "USD"}),
)
.unwrap();
// A foreign line booked before rounding to the cent (2026-10-13).
w.append(
"entry.line_added",
json!({"id": 1, "account": cash, "side": "credit", "amount": "108.34567", "currency": "USD",
"foreign_currency": "EUR", "foreign_amount": "100.00", "fx_rate": "1.0834567"}),
)
.unwrap();
// P4b S4.2's new shapes, hand-written as a later version writes them:
// a party's email and default terms, and an invoicing setting. This
// reader takes them; the version before it stopped at the declaration.
w.append("party.added", json!({"id": 1, "name": "Acme", "roles": []}))
.unwrap();
w.append(
"party.updated",
json!({"id": 1, "change": {"field": "email", "value": "billing@acme.example"}}),
)
.unwrap();
w.append(
"party.updated",
json!({"id": 1, "change": {"field": "default_terms_as_customer",
"value": {"kind": {"net": 30}, "text": "Net 30"}}}),
)
.unwrap();
// D-55's contact block, hand-written the same way (P4b S4.4).
for change in [
json!({"field": "phone", "value": "555-0142"}),
json!({"field": "contact", "value": "Dana Reed"}),
json!({"field": "billing_address", "value": "12 Billing Way"}),
json!({"field": "shipping_address", "value": "Dock 4"}),
json!({"field": "billing_email", "value": "ap@acme.example"}),
] {
w.append("party.updated", json!({"id": 1, "change": change}))
.unwrap();
}
w.append(
"settings.set",
json!({"changes": [{"key": "invoicing.default_terms", "value": "2/10 net 30"}],
"source": {"decided_by": "Owner", "decided_on": "2026-10-26"}}),
)
.unwrap();
// P4b S4.3's new shapes, hand-written as this version writes them: a
// document carrying its number, terms, snapshots, references and memo,
// and a line naming its account, quantity and unit price. This reader
// takes them; the version before it stopped at the declaration.
for (id, code, name, t) in [
(3, "1200", "Accounts Receivable", "Asset"),
(4, "4000", "Revenue", "Revenue"),
(5, "2000", "Accounts Payable", "Liability"),
(6, "6500", "Supplies", "Expense"),
(7, "4100", "Refunds and Allowances", "Revenue"),
(8, "1020", "Bank", "Asset"),
(9, "6990", "Bad Debt Expense", "Expense"),
] {
w.append(
"account.added",
json!({"id": id, "code": code, "name": name, "account_type": t,
"parent": null, "placeholder": false, "currency": "USD"}),
)
.unwrap();
}
w.append("customer.added", json!({"id": 1, "name": "Fernhill Labs"}))
.unwrap();
w.append(
"vendor.added",
json!({"id": 1, "name": "Tobin Harlow Supply"}),
)
.unwrap();
// P6's new shapes, hand-written as a later version writes them: a file's
// extraction record, an annotation of it, and a settlement. This reader
// takes them; the version before it stopped at the declaration.
w.append(
"attachment.text_extracted",
json!({"attachment_id": 5, "text_sha256": null, "extractor": "wanderbooks",
"version": "1", "pages": 0, "status": "none"}),
)
.unwrap();
w.append(
"attachment.annotated",
json!({"attachment_id": 5, "fields": {"receipt_number": 3, "payee": "Acme"},
"note": "read by hand"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 2, "date": "2026-04-01", "description": "Invoice 1"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 2, "account": 3, "side": "debit", "amount": "99.99", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 2, "account": 4, "side": "credit", "amount": "99.99", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.posted",
json!({"id": 2, "date": "2026-04-01", "description": "Invoice 1", "reversal_of": null,
"lines": [
{"account": 3, "side": "debit", "amount": "99.99", "currency": "USD"},
{"account": 4, "side": "credit", "amount": "99.99", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.created",
json!({"id": 1, "customer_id": 1, "date": "2026-04-01", "due_date": "2026-05-01",
"lines": [{"description": "Consulting", "amount": "99.99", "account": 4,
"quantity": "3", "unit_price": "33.33"}],
"entry_id": 2, "number": "INV-0001",
"terms": {"kind": {"net": 30}, "text": "Net 30"},
"bill_to": {"name": "Fernhill Labs", "email": "billing@fernhill.example"},
"seller": {"name": "Acme", "address": "1 Main St"},
"customer_ref": "PO-7", "memo": "Thanks"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 3, "date": "2026-04-02", "description": "Bill 1"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 3, "account": 6, "side": "debit", "amount": "20.00", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 3, "account": 5, "side": "credit", "amount": "20.00", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.posted",
json!({"id": 3, "date": "2026-04-02", "description": "Bill 1", "reversal_of": null,
"lines": [
{"account": 6, "side": "debit", "amount": "20.00", "currency": "USD"},
{"account": 5, "side": "credit", "amount": "20.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"bill.created",
json!({"id": 1, "vendor_id": 1, "date": "2026-04-02", "due_date": "2026-05-17",
"lines": [{"description": "Paper", "amount": "20.00", "account": 6}],
"entry_id": 3, "vendor_ref": "TOBIN-77", "received_on": "2026-04-03",
"terms": {"kind": {"net_eom": 15}, "text": "Net 15 EOM"},
"memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}),
)
.unwrap();
// P4b S5's new shape: the record that the invoice went out
// (`invoice.sent`, feature documents_v2). A send moves no figure, so
// the document's figures are the same before and after it.
w.append(
"invoice.sent",
json!({"invoice_id": 1, "on": "2026-04-05", "by": "Avery Quill", "via": "email",
"to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}),
)
.unwrap();
// P4b S6's new shape, hand-written as this version writes it: a
// payment event naming its line and the discount line it also tags.
// This reader tags both; the version before it would drop the discount
// and read the invoice paid above its total.
w.append(
"entry.created",
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1"}),
)
.unwrap();
for (account, side, amount) in [
(3, "credit", "98.99"),
(3, "credit", "1.00"),
(7, "debit", "1.00"),
(8, "debit", "98.99"),
] {
w.append(
"entry.line_added",
json!({"id": 4, "account": account, "side": side, "amount": amount,
"currency": "USD"}),
)
.unwrap();
}
// A second invoice (50.00) for P4b S7's write-off below, so S6's full
// payment of invoice 1 and S7's write-off never touch the same document.
w.append(
"entry.created",
json!({"id": 5, "date": "2026-04-03", "description": "Invoice 2"}),
)
.unwrap();
for (account, side) in [(3, "debit"), (4, "credit")] {
w.append(
"entry.line_added",
json!({"id": 5, "account": account, "side": side, "amount": "50.00", "currency": "USD"}),
)
.unwrap();
}
w.append(
"entry.posted",
json!({"id": 5, "date": "2026-04-03", "description": "Invoice 2", "reversal_of": null,
"lines": [
{"account": 3, "side": "debit", "amount": "50.00", "currency": "USD"},
{"account": 4, "side": "credit", "amount": "50.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.created",
json!({"id": 2, "customer_id": 1, "date": "2026-04-03", "due_date": "2026-05-03",
"lines": [{"description": "Survey", "amount": "50.00"}], "entry_id": 5}),
)
.unwrap();
// P4b S7's new shape, hand-written as this version writes it: a
// bad-debt write-off of invoice 1 (its entry, then the event). This
// reader tags the receivable line WriteOff and derives the figures;
// the version before it stopped at the declaration.
w.append(
"entry.created",
json!({"id": 6, "date": "2026-04-10", "description": "Write off invoice 2: uncollectible"}),
)
.unwrap();
for (account, side) in [(9, "debit"), (3, "credit")] {
w.append(
"entry.line_added",
json!({"id": 6, "account": account, "side": side, "amount": "30.00", "currency": "USD"}),
)
.unwrap();
}
w.append(
"entry.posted",
json!({"id": 4, "date": "2026-04-05", "description": "Payment on invoice 1",
"reversal_of": null,
"lines": [
{"account": 3, "side": "credit", "amount": "98.99", "currency": "USD"},
{"account": 3, "side": "credit", "amount": "1.00", "currency": "USD"},
{"account": 7, "side": "debit", "amount": "1.00", "currency": "USD"},
{"account": 8, "side": "debit", "amount": "98.99", "currency": "USD"}
]}),
)
.unwrap();
w.append(
"invoice.payment_received",
json!({"invoice_id": 1, "date": "2026-04-05", "amount": "98.99",
"cash_account": 8, "entry_id": 4, "line": 0,
"discount": "1.00", "discount_line": 1,
"discount_reason": "the customer paid the rest late"}),
)
.unwrap();
w.append(
"entry.posted",
json!({"id": 6, "date": "2026-04-10",
"description": "Write off invoice 2: uncollectible", "reversal_of": null,
"lines": [
{"account": 9, "side": "debit", "amount": "30.00", "currency": "USD"},
{"account": 3, "side": "credit", "amount": "30.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.written_off",
json!({"invoice_id": 2, "entry_id": 6, "date": "2026-04-10",
"amount": "30.00", "reason": "uncollectible"}),
)
.unwrap();
// P6's new shapes, hand-written as a later version writes them: an
// entry a member was reimbursed by, and the record of what it settles.
w.append(
"entry.created",
json!({"id": 7, "date": "2026-03-02", "description": "Reimburse"}),
)
.unwrap();
for side in ["debit", "credit"] {
w.append(
"entry.line_added",
json!({"id": 7, "account": 2, "side": side, "amount": "5.00", "currency": "USD"}),
)
.unwrap();
}
w.append(
"entry.posted",
json!({"id": 7, "date": "2026-03-02", "description": "Reimburse",
"lines": [{"account": 2, "side": "debit", "amount": "5.00", "currency": "USD"},
{"account": 2, "side": "credit", "amount": "5.00", "currency": "USD"}],
"reversal_of": null}),
)
.unwrap();
w.append("entry.settles", json!({"entry_id": 7, "settles": [1]}))
.unwrap();
// P4's new shapes, hand-written as this version writes them: a draft
// awaiting its evidence, and a draft thrown away. This reader takes
// them; the version before it stopped at the declaration.
for (id, what) in [(8, "Waiting on a receipt"), (9, "Entered twice")] {
w.append(
"entry.created",
json!({"id": id, "date": "2026-05-01", "description": what}),
)
.unwrap();
}
w.append(
"entry.draft_state_set",
json!({"id": 8, "state": "awaiting_evidence", "note": "invoice due"}),
)
.unwrap();
w.append(
"entry.draft_discarded",
json!({"id": 9, "reason": "entered twice"}),
)
.unwrap();
// P5's new shapes, hand-written as this version writes them: a
// convention, a decision and an open item, each with its source
// corrected (`source_revisions`). This reader takes them; the version
// before it stopped at the declaration.
let owner = json!({"decided_by": "Owner", "decided_on": "2026-10-26"});
w.append(
"convention.set",
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
"body_md": "Reconcile, then close.", "source": owner}),
)
.unwrap();
w.append(
"decision.recorded",
json!({"id": 1, "subject": "Materiality", "decision_md": "Five hundred dollars.",
"decided_by": ["Owner"], "decided_on": "2026-10-26"}),
)
.unwrap();
w.append(
"open_item.raised",
json!({"id": 1, "title": "Ask the CPA", "source": owner}),
)
.unwrap();
let cpa = json!({"decided_by": "Pat Smith", "decided_on": "2026-10-27",
"citation": "IRS Pub. 538"});
w.append(
"convention.source_revised",
json!({"id": "close-checklist", "source": cpa, "reason": "the CPA decided it"}),
)
.unwrap();
w.append(
"decision.source_revised",
json!({"id": 1, "source": {"decided_by": ["Owner", "Pat Smith"],
"decided_on": "2026-10-27", "citation": "IRS Pub. 538"},
"reason": "both decided it"}),
)
.unwrap();
w.append(
"open_item.source_revised",
json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}),
)
.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()));
let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap();
assert_eq!(store.journal_format().format, 1);
assert_eq!(store.ledger().chart().len(), 9);
assert_eq!(store.ledger().list_periods().len(), 2);
let lines = store.ledger().get_entry(1).unwrap().lines();
assert_eq!(lines.len(), 2);
// Replayed exactly as booked, not re-rounded.
assert_eq!(
lines[1].amount.amount,
"108.34567".parse::<Decimal>().unwrap()
);
// The new shapes replay as written: the party's facts and the setting.
// P6's shapes replayed: the extraction, the annotation and the
// settlement.
let extractions = store.extractions().unwrap();
assert_eq!(extractions.len(), 1);
assert_eq!(extractions[0].attachment_id, 5);
assert_eq!(extractions[0].status, ledger_store::ExtractionStatus::None);
let annotation = store
.ledger()
.annotation(5)
.expect("the annotation, replayed");
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);
assert_eq!(store.ledger().entry_settles(7), [1]);
assert!(store.ledger().settles_entry(1));
let acme = store.ledger().get_party(1).expect("the party");
assert_eq!(acme.email.as_deref(), Some("billing@acme.example"));
assert_eq!(acme.phone.as_deref(), Some("555-0142"));
assert_eq!(acme.contact.as_deref(), Some("Dana Reed"));
assert_eq!(acme.billing_address.as_deref(), Some("12 Billing Way"));
assert_eq!(acme.shipping_address.as_deref(), Some("Dock 4"));
assert_eq!(acme.billing_email.as_deref(), Some("ap@acme.example"));
assert_eq!(
acme.default_terms_as_customer
.as_ref()
.map(|t| t.describe()),
Some("Net 30".to_string())
);
assert_eq!(
store
.ledger()
.settings()
.get("invoicing.default_terms")
.and_then(|r| r.value.as_str()),
Some("2/10 net 30")
);
// The documents' new fields replay as written, its lines included.
let inv = store.ledger().get_invoice(1).expect("the invoice");
assert_eq!(inv.number.as_deref(), Some("INV-0001"));
assert_eq!(
inv.terms.as_ref().map(|t| t.describe()),
Some("Net 30".into())
);
assert_eq!(
inv.bill_to.as_ref().and_then(|s| s.email.as_deref()),
Some("billing@fernhill.example")
);
assert_eq!(inv.seller.as_ref().map(|s| s.name.as_str()), Some("Acme"));
assert_eq!(inv.customer_ref.as_deref(), Some("PO-7"));
assert_eq!(inv.memo.as_deref(), Some("Thanks"));
assert_eq!(inv.lines[0].account, Some(4));
assert_eq!(inv.lines[0].quantity, Some("3".parse().unwrap()));
assert_eq!(inv.lines[0].unit_price, Some("33.33".parse().unwrap()));
let bill = store.ledger().get_bill(1).expect("the bill");
assert_eq!(bill.vendor_ref.as_deref(), Some("TOBIN-77"));
assert_eq!(
bill.received_on,
Some(NaiveDate::from_ymd_opt(2026, 4, 3).unwrap())
);
assert_eq!(
bill.terms.as_ref().map(|t| t.describe()),
Some("Net 15 EOM".into())
);
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
assert_eq!(bill.lines[0].account, Some(6));
// P5's source corrections replay as written; the text stays as it was.
let c = store
.ledger()
.conventions()
.convention("close-checklist")
.expect("the convention");
assert_eq!(c.source.decided_by, "Pat Smith");
assert_eq!(c.source.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!(
(c.body_md.as_str(), c.revision),
("Reconcile, then close.", 1)
);
let d = store
.ledger()
.conventions()
.decision(1)
.expect("the decision");
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
assert_eq!(d.new.decision_md, "Five hundred dollars.");
let i = store.ledger().conventions().open_item(1).expect("the item");
assert_eq!(i.new.source.decided_by, "Pat Smith");
// P4b S5's send replays as written, and moves no figure.
let sends = &store.ledger().get_invoice(1).expect("the invoice").sends;
assert_eq!(sends.len(), 1);
assert_eq!(sends[0].on, NaiveDate::from_ymd_opt(2026, 4, 5).unwrap());
assert_eq!(sends[0].by, "Avery Quill");
assert_eq!(sends[0].to, ["billing@fernhill.example"]);
assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original);
assert_eq!(sends[0].reference.as_deref(), Some("msg-1"));
// The S6 shape: the invoice reads paid and discounted, and replay
// tagged the lines the event names.
let st = store
.ledger()
.invoice_state(1, NaiveDate::from_ymd_opt(2026, 12, 31).unwrap())
.unwrap()
.unwrap();
assert_eq!(
(st.status, st.paid, st.discounted, st.outstanding),
(
ledger_core::DocStatus::Paid,
"98.99".parse::<Decimal>().unwrap(),
"1.00".parse::<Decimal>().unwrap(),
Decimal::ZERO
)
);
assert_eq!(
store.ledger().line_doc(4, 0).unwrap().movement,
ledger_core::Movement::Payment
);
assert_eq!(
store.ledger().line_doc(4, 1).unwrap().movement,
ledger_core::Movement::Discount
);
// The write-off replays as written: its line is the invoice's
// (WriteOff), so what it owes fell by 30.00 and what was written off
// rose by it (B-41, B-38).
let st = store
.ledger()
.invoice_state(2, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap())
.unwrap()
.unwrap();
assert_eq!(st.written_off, "30.00".parse::<Decimal>().unwrap());
assert_eq!(st.outstanding, "20.00".parse::<Decimal>().unwrap());
assert_eq!(
store.ledger().line_doc(6, 1).map(|t| t.movement),
Some(ledger_core::Movement::WriteOff)
);
// P4's draft state and discard replay as written, and move no figure.
assert!(
store.ledger().get_entry(9).is_none(),
"the discarded draft is gone"
);
let waiting = store.ledger().get_entry(8).expect("the waiting draft");
assert_eq!(waiting.draft_state().key(), "awaiting_evidence");
assert_eq!(waiting.draft_state().note(), "invoice due");
}
/// The books an earlier wanderbooks wrote, one directory each under
/// tests/fixtures/old-books: its journal (and blobs) exactly as it wrote
/// them, and expected.json, what that version computed from them. Each was
/// written through main 188c90ea's Store API (or, for x_older_rules, its
/// JournalWriter, as above), and expected.json by main's own code: verify,
/// the canonical report snapshot, and every record as replayed. They hold
/// what P0 bounds for new commands only (rules.md B-28): dates before 0001
/// and after 9999 (to chrono's first and last days), draft, recurring and
/// share amounts past the line limit (to Decimal's largest), a contributor
/// basis past it, sub-cent document and payroll lines, an asset life of
/// 10,000 years, schedules and assets running into chrono's last month,
/// depreciation posted for 10000-01, one recurring run of 1,044 drafts.
/// tests/fixtures/old-books-writer is the harness that wrote them; each
/// later phase adds the books its version writes. `p4_s7_before` is P4b
/// S7's: the books main 4cad2cfc (the version before it) wrote — a
/// numbered invoice with terms and snapshots, a partial payment, a voided
/// invoice and a paid bill, no write-off — with that version's
/// expected.json.
fn old_books() -> Vec<PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.is_dir())
.collect();
dirs.sort();
// The P0 review's thirteen and its control, and the edges added with
// them (2026-10-17).
assert!(dirs.len() >= 29, "{} old books", dirs.len());
dirs
}
/// A fixture's books (journal and blobs, never expected.json) in a fresh
/// directory of its own for `test`: opening writes a database and a lock
/// beside them.
fn old_books_copy(fixture: &std::path::Path, test: &str) -> TestDir {
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_name() == "expected.json" {
continue;
}
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();
}
}
}
let name = fixture.file_name().unwrap().to_string_lossy();
let dir = test_dir(&format!("old-books-{test}-{name}"));
copy(fixture, &dir);
dir
}
/// What expected.json records of a ledger (beside verify): the same JSON
/// main's harness wrote, field for field.
fn old_books_figures(l: &ledger_core::Ledger) -> serde_json::Value {
let entries: Vec<_> = (1..l.next_entry_id())
.filter_map(|id| l.get_entry(id))
.collect();
let parties: Vec<_> = (1..l.next_entry_id())
.map(|id| (id, l.entry_parties(id)))
.filter(|(_, p)| !p.is_empty())
.collect();
serde_json::json!({
"snapshot": ledger_core::reports::snapshot(l).map_err(|e| e.to_string()),
"entries": entries,
"assets": l.registered_assets().collect::<Vec<_>>(),
"periods": l.list_periods(),
"schedules": l.schedules().collect::<Vec<_>>(),
"recurring": l.recurring_entries().collect::<Vec<_>>(),
"invoices": l.invoices(),
"bills": l.bills(),
"payroll_runs": l.list_payroll_runs(),
"entry_parties": parties,
})
}
/// Contract (posted is forever, rules.md B-28; P0 review round 4,
/// 2026-10-17): every book an earlier version wrote opens, in the writer
/// (Store::open, its database rebuilt from the journal) and the viewer
/// (Snapshot::load) alike, verifies as it did, and replays to exactly the
/// figures that version computed: the trial balance, P&L and balance sheet,
/// and every entry, asset, period, schedule, recurring entry, document,
/// payroll run and share, to the last decimal place as journaled.
#[test]
fn books_an_earlier_version_wrote_open_to_its_figures() {
for fixture in old_books() {
let name = fixture.file_name().unwrap().to_string_lossy().to_string();
let expected: serde_json::Value =
serde_json::from_slice(&std::fs::read(fixture.join("expected.json")).unwrap()).unwrap();
let dir = old_books_copy(&fixture, "figures");
let store = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: the writer refused: {e}"));
let verify = store.verify().map(|_| ()).map_err(|e| e.to_string());
assert_eq!(
serde_json::json!(verify),
expected["verify"],
"{name}: verify"
);
let mut figures = old_books_figures(store.ledger());
figures["verify"] = expected["verify"].clone();
assert_eq!(figures, expected, "{name}: the writer's figures");
drop(store);
// And again from the database it built (no rebuild this time).
let store = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: reopen: {e}"));
let mut again = old_books_figures(store.ledger());
again["verify"] = expected["verify"].clone();
assert_eq!(again, expected, "{name}: reopened");
drop(store);
let snap = ledger_store::Snapshot::load(&dir)
.unwrap_or_else(|e| panic!("{name}: the viewer refused: {e}"));
let mut viewed = old_books_figures(snap.ledger());
viewed["verify"] = expected["verify"].clone();
assert_eq!(viewed, expected, "{name}: the viewer's figures");
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Every figure the engine derives from old books (B-28: computed, or
/// refused with TooLargeToCompute or DateOutOfRange, never a panic): every
/// report over the books' whole span and the calendar's, every asset's
/// schedule and register row, every amortization plan, every recurring
/// entry's due dates, aging, 1099 and K-1 worksheets, shares and money
/// flows of every entry, documents, payroll, period closes, and
/// reconciliations. Returns how many were refused, so a run that refuses
/// everything can't pass unnoticed.
fn every_figure_of(l: &ledger_core::Ledger) -> (usize, usize) {
use ledger_core::analysis::{self, Interval};
use ledger_core::reports;
let (mut ok, mut refused) = (0usize, 0usize);
let mut tally = |r: Result<(), ledger_core::LedgerError>| match r {
Ok(()) => ok += 1,
Err(
ledger_core::LedgerError::TooLargeToCompute(_)
| ledger_core::LedgerError::DateOutOfRange(_),
) => refused += 1,
// Any other refusal is a reason the books give (an unknown
// account, a period already closed): answered, not a panic.
Err(_) => refused += 1,
};
let dates: Vec<NaiveDate> = (1..l.next_entry_id())
.filter_map(|id| l.get_entry(id))
.map(|e| e.date())
.chain(l.registered_assets().map(|a| a.in_service_date))
.chain(l.list_periods().iter().flat_map(|p| [p.start, p.end]))
.chain(l.schedules().map(|s| s.start))
.collect();
let first = dates.iter().min().copied().unwrap_or(NaiveDate::MIN);
let last = dates.iter().max().copied().unwrap_or(NaiveDate::MAX);
let spans = [
(first, last),
(
NaiveDate::from_ymd_opt(1, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(9999, 12, 31).unwrap(),
),
(NaiveDate::MIN, NaiveDate::MAX),
];
let accounts: Vec<_> = l.chart().all().map(|a| a.id).collect();
let cash: Vec<_> = l
.chart()
.all()
.filter(|a| a.code == "1000")
.map(|a| a.id)
.collect();
let members: Vec<reports::MemberCapital> = l
.chart()
.all()
.filter(|a| a.account_type == AccountType::Equity)
.map(|a| reports::MemberCapital {
name: a.name.clone(),
capital_account: a.id,
distribution_account: None,
share: Decimal::ONE,
})
.collect();
tally(reports::snapshot(l).map(|_| ()));
let _ = reports::trial_balance(l);
tally(reports::balance_sheet(l).map(|_| ()));
tally(reports::unclosed_earnings(l).map(|_| ()));
tally(reports::profit_and_loss_by_class(l).map(|_| ()));
for (start, end) in spans {
for d in [start, end] {
let _ = reports::trial_balance_as_of(l, d);
tally(reports::balance_sheet_as_of(l, d).map(|_| ()));
tally(l.ar_aging(d).map(|_| ()));
tally(l.ap_aging(d).map(|_| ()));
tally(ledger_core::fixed_asset_register(l, d).map(|_| ()));
for r in l.recurring_entries() {
// Weekly from year 1 through chrono's last day is
// 13.7 million dates: through the books' own span only.
if d <= last {
let _ = r.due(d);
}
}
for s in l.schedules() {
tally(s.due(d).map(|_| ()));
}
for a in l.registered_assets() {
tally(ledger_core::depreciation_schedule(a, d).map(|_| ()));
tally(ledger_core::scheduled_book_value(a, d).map(|_| ()));
tally(
ledger_core::disposal_amounts(
a,
d,
Money::new(Decimal::ZERO, l.base_currency()),
)
.map(|_| ()),
);
}
}
tally(reports::profit_and_loss_for_period(l, start, end, None).map(|_| ()));
tally(reports::general_ledger(l, start, end).map(|_| ()));
tally(reports::members_equity(l, start, end, &members).map(|_| ()));
// The statement of cash flows foots on every book, or is refused
// for a reason: never an engine error (rules.md B-14 rule 9).
let cf = reports::cash_flow(
l,
&reports::CashFlowParams {
start,
end,
cash_accounts: cash.clone(),
financing_accounts: vec![],
investing_accounts: vec![],
members: members.clone(),
},
);
assert!(
!matches!(
cf,
Err(ledger_core::LedgerError::CashFlowDoesNotFoot { .. })
),
"{cf:?}"
);
tally(cf.map(|_| ()));
tally(ledger_core::k1::k1_worksheet(l, start, end, &members, &cash).map(|_| ()));
tally(ledger_core::tax_summary(l, start, end, None).map(|_| ()));
let _ = reports::cancelled_in_span(l, start, end);
// Interval series over a span of 524,000 years are not what anyone
// asks: the books' own span, and the calendar's in years.
if (end - start).num_days() < 400 * 366 {
for interval in [Interval::Month, Interval::Quarter, Interval::Year] {
let pieces = analysis::intervals(start, end, interval);
let ends: Vec<NaiveDate> = pieces.iter().map(|(_, e)| *e).collect();
let _ = analysis::balance_series(l, &accounts, &ends);
tally(analysis::pnl_by_interval(l, start, end, interval).map(|_| ()));
let _ = analysis::flows_by_interval(l, &accounts, start, end, interval);
}
} else {
let _ = analysis::intervals(start, end, Interval::Year).len();
}
}
let _ = analysis::balance_history(l, &accounts);
for year in [first.year(), last.year(), 2026] {
let sources = ledger_core::form1099::PaymentAccounts {
cash: cash.clone(),
financing: vec![],
};
tally(
ledger_core::form1099::vendor_1099_report(l, year, None, &sources, &[], None)
.map(|_| ()),
);
}
let money = ledger_core::parties::money_accounts(l, &cash, &[]);
let party_members: Vec<ledger_core::PartyMember> = l
.parties()
.map(|p| ledger_core::PartyMember {
party: p.id,
share: Decimal::new(5, 1),
capital: members.first().map_or(0, |m| m.capital_account),
distributions: None,
})
.collect();
for id in 1..l.next_entry_id() {
let Some(e) = l.get_entry(id) else { continue };
tally(ledger_core::parties::entry_flow(e, &money).map(|_| ()));
tally(ledger_core::shares::target(l, id, l.entry_parties(id), &money).map(|_| ()));
tally(ledger_core::parties::flow(l, id, l.entry_parties(id), &money).map(|_| ()));
let _ = ledger_core::shares::resolve(l, id, l.entry_parties(id), &money, &party_members);
let _ = e.validate(l.chart());
}
for p in l.parties() {
for (start, end) in spans {
let _ =
ledger_core::parties::activity_with(l, p.id, (start, end), &money, &party_members);
}
}
for inv in l.invoices() {
tally(inv.total().map(|_| ()));
tally(inv.balance().map(|_| ()));
}
for bill in l.bills() {
tally(bill.total().map(|_| ()));
tally(bill.balance().map(|_| ()));
}
for run in l.list_payroll_runs() {
for line in &run.lines {
tally(line.net_pay().map(|_| ()));
}
}
for s in l.schedules() {
tally(s.plan().map(|_| ()));
tally(s.remaining().map(|_| ()));
}
for p in l.list_periods() {
tally(l.preview_close_period(p.id).map(|_| ()));
}
for &account in &cash {
for d in [first, last] {
for balance in [Decimal::ZERO, Decimal::MAX, Decimal::MIN] {
tally(l.reconciliation_report(account, d, balance).map(|_| ()));
}
}
}
(ok, refused)
}
/// B-28 over every old book: each figure is computed or refused, never a
/// panic, in the writer's ledger and the viewer's.
#[test]
fn every_figure_over_old_books_is_computed_or_refused() {
let (mut ok, mut refused) = (0, 0);
for fixture in old_books() {
let dir = old_books_copy(&fixture, "derived");
let store = Store::open(&dir).unwrap();
let (o, r) = every_figure_of(store.ledger());
drop(store);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(
every_figure_of(snap.ledger()),
(o, r),
"{}",
fixture.display()
);
ok += o;
refused += r;
let _ = std::fs::remove_dir_all(&dir);
}
// Most figures compute; the edges refuse.
assert!(ok > refused, "{ok} computed, {refused} refused");
assert!(refused > 0, "none refused: the edges weren't reached");
}
/// Regression (fuzz_journal_line, 2026-10-13, crash-c9fa20f3): a journal
/// line holding a number too large for an integer parsed as a float, and
/// its canonical form (which the hash chain covers) re-canonicalized
/// differently. The journal never holds floats (R-2), so the reader now
/// refuses them; float-free lines are a fixed point.
#[test]
fn a_journal_line_with_a_float_is_refused() {
use ledger_store::JournalLine;
let crash = br#"{"payload":4444454444444444404444,"prev":"x","seq":8,"ts":";u","type":""}"#;
assert!(JournalLine::parse(crash).is_err());
assert!(
JournalLine::parse(br#"{"payload":{"amount":1.5},"prev":"x","seq":1,"ts":"t","type":"a"}"#)
.is_err()
);
// Anywhere in the line: inside an array too (an entry's lines).
assert!(
JournalLine::parse(
br#"{"payload":{"lines":[{"amount":"1.00"},{"amount":2.5}]},"prev":"x","seq":1,"ts":"t","type":"a"}"#
)
.is_err()
);
let ok = br#"{"payload":{"amount":"1.50","id":7},"prev":"x","seq":1,"ts":"t","type":"a"}"#;
let line = JournalLine::parse(ok).unwrap();
let canon = line.to_canonical();
assert_eq!(
JournalLine::parse(canon.as_bytes()).unwrap().to_canonical(),
canon
);
}
/// Regression (max-testing pass on the store, 2026-10-13): nothing stopped
/// two processes (say, two agent sessions, each with its own MCP server)
/// from writing the same books. One write from each broke the hash chain
/// ("seq gap") and the books would not open. Oracle (rank 1, R-11: one
/// append-only log, one writer): a second writer is refused while the
/// first is open, and allowed once it closes; the books always reopen.
#[test]
fn a_second_writer_is_refused_while_the_first_has_the_books() {
let dir = test_dir("two-writers");
drop(Store::create(&dir, Currency::USD).unwrap());
let mut first = Store::open(&dir).unwrap();
match Store::open(&dir) {
Err(StoreError::Locked(why)) => assert!(why.contains("another"), "{why}"),
Err(e) => panic!("expected Locked, got {e}"),
Ok(_) => panic!("a second writer was allowed"),
}
first
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
drop(first);
// Released on close: the next writer gets in, and the books are whole.
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert_eq!(reopened.ledger().chart().len(), 1);
}
/// Security (rank 2; max-testing pass on the store, 2026-10-13): a blob id
/// comes from the journal, which is untrusted input on disk. Whatever the
/// id, the blob store never panics and never touches a path outside its
/// own directory: only a 64-hex-digit id names a blob.
#[test]
fn a_blob_id_never_reaches_outside_the_blob_store() {
let dir = test_dir("blob-ids");
let blobs = ledger_store::BlobStore::open(&dir.join("blobs")).unwrap();
let outside = dir.join("secret.txt");
std::fs::write(&outside, b"not a blob").unwrap();
for id in [
"",
"a",
"ab",
"abc",
"é",
"日本語",
"../secret.txt",
"../../secret.txt",
"ab/../../secret.txt",
"/etc/passwd",
"zz",
&"g".repeat(64),
&"a".repeat(63),
&"a".repeat(65),
] {
assert!(blobs.get(id).is_err(), "{id:?} was served");
assert!(!blobs.exists(id), "{id:?} probed a path");
}
let (sha, _) = blobs.put(b"receipt").unwrap();
assert_eq!(blobs.get(&sha).unwrap(), b"receipt");
assert_eq!(blobs.get(&sha.to_uppercase()).unwrap(), b"receipt");
}
/// Regression (found by the refusal property on 2026-10-13): scrapping an
/// asset (no proceeds) is a valid disposal. It posts without a zero
/// proceeds line, books the whole book value as a loss, and survives
/// reopen. Before the fix it journaled a catch-up depreciation entry and a
/// half-built disposal draft, then refused.
#[test]
fn scrapping_an_asset_for_nothing_disposes_it() {
let dir = test_dir("scrap");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let mut add = |code: &str, t| store_add(&mut store, code, t);
let cash = add("1000", AccountType::Asset);
let equipment = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let loss = add("6900", AccountType::Expense);
let depr = add("6200", AccountType::Expense);
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let buy = store.create_entry(day(1, 1), "laptop").unwrap();
store
.add_line(buy, equipment, Side::Debit, usd(1200))
.unwrap();
store.add_line(buy, cash, Side::Credit, usd(1200)).unwrap();
store.post_entry(buy).unwrap();
let asset = store
.register_asset(ledger_core::NewAsset {
description: "laptop".into(),
in_service_date: day(1, 1),
cost: usd(1200),
salvage_value: usd(0),
useful_life_months: 12,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
let entry = store
.dispose_asset(asset, day(3, 31), usd(0), cash, loss)
.unwrap();
let lines = store.ledger().get_entry(entry).unwrap().lines().to_vec();
assert!(lines.iter().all(|l| !l.amount.amount.is_zero()));
assert!(lines.iter().all(|l| l.account != cash), "no proceeds line");
// January through March depreciated (300), so a 900 loss on 1,200.
let balance = |s: &Store, a| s.ledger().account_balance(a).unwrap();
assert_eq!(balance(&store, loss), Decimal::from(900));
assert_eq!(balance(&store, equipment), Decimal::ZERO);
assert_eq!(balance(&store, accum), Decimal::ZERO);
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(balance(&store, loss), Decimal::from(900));
}
fn store_add(store: &mut Store, code: &str, t: AccountType) -> ledger_core::AccountId {
store.add_account(code, code, t, None, false).unwrap()
}
/// Contract (rules.md B-13): each members close journals its rounding rule
/// and replays under it: "cumulative_since" with the close its count starts
/// at for new closes (P3), "cumulative" for the run rule of 2026-10-13 to
/// P3, "largest_remainder" for the per-close rule of earlier on 2026-10-13.
/// Three one-cent months, 50/50: both cumulative rules end A 0.02 / B
/// 0.01, per close A 0.03 / B 0.00. With February closed to retained
/// earnings instead, the two cumulative rules differ: cumulative_since
/// skips it (the running 0.02 splits A 0.01 / B 0.01), the run rule
/// restarts at March (A gets both cents: A 0.02 / B 0.00).
#[test]
fn members_closes_replay_under_the_rule_they_were_journaled_with() {
use ledger_core::{CloseTo, MemberClose};
type Rule = fn(Vec<MemberClose>) -> CloseTo;
let since: Rule = CloseTo::Members;
let run: Rule = CloseTo::MembersRun;
let per: Rule = CloseTo::MembersPerClose;
for (name, to, re_february, rule, a, b) in [
(
"since",
since,
false,
"\"rounding\":\"cumulative_since\"",
"0.02",
"0.01",
),
(
"run",
run,
false,
"\"rounding\":\"cumulative\"",
"0.02",
"0.01",
),
(
"per",
per,
false,
"\"rounding\":\"largest_remainder\"",
"0.03",
"0.00",
),
(
"since-re",
since,
true,
"\"rounding\":\"cumulative_since\"",
"0.01",
"0.01",
),
(
"run-re",
run,
true,
"\"rounding\":\"cumulative\"",
"0.02",
"0.00",
),
] {
let dir = test_dir(&format!("close-rule-{name}"));
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let caps = [
store
.add_account("3000", "A", AccountType::Equity, None, false)
.unwrap(),
store
.add_account("3001", "B", AccountType::Equity, None, false)
.unwrap(),
];
let rev = store
.add_account("4000", "Sales", AccountType::Revenue, None, false)
.unwrap();
let members: Vec<MemberClose> = caps
.iter()
.map(|&capital| MemberClose {
capital,
share: Decimal::new(5, 1),
distributions: None,
})
.collect();
let cent = Money::new(Decimal::new(1, 2), Currency::USD);
for m in 1..=3u32 {
let day = |d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let e = store.create_entry(day(10), "a cent").unwrap();
store.add_line(e, cash, Side::Debit, cent).unwrap();
store.add_line(e, rev, Side::Credit, cent).unwrap();
store.post_entry(e).unwrap();
let end = NaiveDate::from_ymd_opt(2026, m + 1, 1)
.unwrap()
.pred_opt()
.unwrap();
let p = store
.create_period(&format!("2026-{m:02}"), day(1), end)
.unwrap();
if re_february && m == 2 {
store.close_period(p).unwrap();
} else {
store.close_period_to(p, &to(members.clone())).unwrap();
}
}
let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let closes = if re_february { 2 } else { 3 };
assert_eq!(journal.matches(rule).count(), closes, "{name}");
if rule.contains("cumulative_since") {
// Every count starts at the first close: February's close to
// retained earnings is skipped, not a restart.
assert_eq!(journal.matches("\"since_seq\":1").count(), closes, "{name}");
}
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
let bal = |id| store.ledger().account_balance(id).unwrap();
assert_eq!(bal(caps[0]), a.parse::<Decimal>().unwrap(), "{name}");
assert_eq!(bal(caps[1]), b.parse::<Decimal>().unwrap(), "{name}");
}
}
/// The viewer's journal accessors: the lines it has read, in order, and
/// the hash the next line must chain onto (the SHA-256 of the last line's
/// canonical form; docs/receipt-store.md "Line format").
#[test]
fn the_viewer_exposes_the_lines_it_read_and_their_head() {
let dir = test_dir("viewer-head");
let store = build_books(&dir);
drop(store);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
let lines =
ledger_store::journal::read_and_verify_journal(&dir.join(ledger_store::JOURNAL_FILE))
.unwrap();
assert_eq!(snap.journal().len(), lines.len());
assert_eq!(snap.journal_seq(), lines.last().unwrap().seq);
assert_eq!(
snap.head_hash(),
ledger_store::journal::sha256_hex(lines.last().unwrap().to_canonical().as_bytes())
);
}
/// The scrap test's books: a 1,200 laptop, 12-month life, bought Jan 1,
/// paid in cash. Returns (store, asset, cash, gain/loss account).
fn laptop(dir: &std::path::Path) -> (Store, u64, u64, u64) {
let mut store = Store::create(dir, Currency::USD).unwrap();
let mut add = |code: &str, t| store_add(&mut store, code, t);
let cash = add("1000", AccountType::Asset);
let equipment = add("1500", AccountType::Asset);
let accum = add("1510", AccountType::Asset);
let gain = add("4900", AccountType::Revenue);
let depr = add("6200", AccountType::Expense);
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let buy = store.create_entry(day(1, 1), "laptop").unwrap();
store
.add_line(buy, equipment, Side::Debit, usd(1200))
.unwrap();
store.add_line(buy, cash, Side::Credit, usd(1200)).unwrap();
store.post_entry(buy).unwrap();
let asset = store
.register_asset(ledger_core::NewAsset {
description: "laptop".into(),
in_service_date: day(1, 1),
cost: usd(1200),
salvage_value: usd(0),
useful_life_months: 12,
cost_account: equipment,
accum_depr_account: accum,
expense_account: depr,
})
.unwrap();
(store, asset, cash, gain)
}
/// Hand-computed: January's 100 posted, sold Mar 31 for 1,000. Through
/// Mar 31 the schedule says 300, so the final catch-up is 300 - 100 = 200,
/// accumulated 300, book value 900, a gain of 100.
#[test]
fn a_disposal_catches_up_exactly_what_was_not_yet_posted() {
let dir = test_dir("dispose-catch-up");
let (mut store, asset, cash, gain) = laptop(&dir);
store.run_depreciation(asset, "2026-01").unwrap();
let day = NaiveDate::from_ymd_opt(2026, 3, 31).unwrap();
let p = store.dispose_preview(asset, day, usd(1000)).unwrap();
assert_eq!(
(
p.final_depreciation,
p.accumulated,
p.book_value,
p.gain_loss
),
(
Decimal::from(200),
Decimal::from(300),
Decimal::from(900),
Decimal::from(100)
)
);
store
.dispose_asset(asset, day, usd(1000), cash, gain)
.unwrap();
assert_eq!(
store.ledger().account_balance(gain).unwrap(),
Decimal::from(100)
);
store.verify().unwrap();
}
/// A disposal may be dated the day the asset was bought, never before it.
#[test]
fn a_disposal_may_fall_on_the_purchase_date_but_not_before() {
let dir = test_dir("dispose-dates");
let (mut store, asset, cash, gain) = laptop(&dir);
let jan1 = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let before = jan1.pred_opt().unwrap();
assert!(store.dispose_preview(asset, before, usd(1200)).is_err());
assert!(
store
.dispose_asset(asset, before, usd(1200), cash, gain)
.is_err()
);
let p = store.dispose_preview(asset, jan1, usd(1200)).unwrap();
assert_eq!(p.book_value, Decimal::from(1200) - p.accumulated);
store
.dispose_asset(asset, jan1, usd(1200), cash, gain)
.unwrap();
// Disposed: a second disposal is refused.
assert!(store.dispose_preview(asset, jan1, usd(1)).is_err());
store.verify().unwrap();
}
/// A close to retained earnings that sweeps a draws account journals the
/// sweep, so replay zeroes the draws again (the bare "retained_earnings"
/// form is only for a close with nothing to sweep).
#[test]
fn a_retained_earnings_close_with_a_sweep_replays_it() {
let dir = test_dir("re-sweep");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let mut add = |code: &str, t| store_add(&mut store, code, t);
let cash = add("1000", AccountType::Asset);
let draws = add("3100", AccountType::Equity);
let sales = add("4000", AccountType::Revenue);
let day = |d| NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
for (dr, cr, n) in [(cash, sales, 500u64), (draws, cash, 200)] {
let e = store.create_entry(day(10), "x").unwrap();
store.add_line(e, dr, Side::Debit, usd(n)).unwrap();
store.add_line(e, cr, Side::Credit, usd(n)).unwrap();
store.post_entry(e).unwrap();
}
let p = store.create_period("2026-01", day(1), day(31)).unwrap();
store
.close_period_to(
p,
&ledger_core::CloseTo::RetainedEarnings { sweep: vec![draws] },
)
.unwrap();
let re = store.ledger().chart().find_by_code("3999").unwrap().id;
let state = |s: &Store| {
(
s.ledger().account_balance(draws).unwrap(),
s.ledger().account_balance(re).unwrap(),
)
};
// Net income 500 in, draws 200 swept out: retained earnings 300.
assert_eq!(state(&store), (Decimal::ZERO, Decimal::from(300)));
drop(store);
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(state(&store), (Decimal::ZERO, Decimal::from(300)));
}
/// The viewer tells a database that hasn't applied the newest lines
/// (Behind) from one that has applied lines the viewer hasn't read yet
/// (Ahead), and neither is a false "differs".
#[test]
fn the_viewer_tells_behind_from_ahead() {
use ledger_store::{DiskCheck, Snapshot};
let dir = test_dir("behind-ahead");
let store = build_books(&dir);
drop(store);
// Ahead: the viewer loads, then the writer appends and applies.
let snap = Snapshot::load(&dir).unwrap();
let mut store = Store::open(&dir).unwrap();
store.add_customer("Acme").unwrap();
assert!(
matches!(snap.check_disk(), DiskCheck::Ahead { .. }),
"{:?}",
snap.check_disk()
);
// Behind: a database restored from before that write.
drop(store);
let mut store = Store::open(&dir).unwrap();
let before_write = std::fs::read(dir.join(ledger_store::db_file())).unwrap();
store.add_vendor("Supplier").unwrap();
drop(store);
std::fs::write(dir.join(ledger_store::db_file()), &before_write).unwrap();
let _ = std::fs::remove_file(dir.join(format!("{}-wal", ledger_store::db_file())));
let _ = std::fs::remove_file(dir.join(format!("{}-shm", ledger_store::db_file())));
let snap = Snapshot::load(&dir).unwrap();
match snap.check_disk() {
DiskCheck::Behind { applied, journal } => assert!(applied < journal),
other => panic!("expected Behind, got {other:?}"),
}
}
/// The table check says how many rows are missing and how many are
/// unexpected, so a person can tell a lost row from a stray one.
#[test]
fn the_table_check_counts_missing_and_unexpected_rows() {
let dir = test_dir("table-counts");
let store = build_books(&dir);
drop(store);
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
// The events table mirrors the journal and isn't read back into the
// ledger, so only the table check sees this.
conn.execute(
"DELETE FROM events WHERE seq = (SELECT MAX(seq) FROM events)",
[],
)
.unwrap();
drop(conn);
let store = Store::open(&dir).unwrap();
let err = store.verify().unwrap_err().to_string();
assert!(
err.contains("table events: 1 rows missing, 0 unexpected"),
"{err}"
);
}
/// A journal writer for a path whose directory doesn't exist yet creates
/// it; the blob store reports a stored file as existing, and an unknown
/// one as not.
#[test]
fn a_new_journal_makes_its_directory_and_blobs_exist_once_stored() {
let dir = test_dir("new-dirs");
let path = dir
.join("nested")
.join("deeper")
.join(ledger_store::JOURNAL_FILE);
let mut w = JournalWriter::open(&path).unwrap();
w.append("test.line", serde_json::json!({"n": 1})).unwrap();
assert!(path.exists());
let blobs = ledger_store::BlobStore::open(&dir.join("blobs")).unwrap();
let (sha, _) = blobs.put(b"receipt").unwrap();
assert!(blobs.exists(&sha));
assert!(!blobs.exists(&"0".repeat(64)));
}
/// Store::open SCENARIO 6: a database that records a base currency other
/// than the journal's is the projection's fault, not the journal's: it is
/// kept aside and rebuilt (before 2026-10-13 the books refused to open,
/// blaming the journal).
#[test]
fn a_database_with_the_wrong_currency_is_rebuilt() {
let dir = test_dir("db-currency");
let store = build_books(&dir);
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
drop(store);
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.execute(
"UPDATE meta SET value = 'EUR' WHERE key = 'base_currency'",
[],
)
.unwrap();
drop(conn);
let store = Store::open(&dir).unwrap();
assert_eq!(store.base_currency(), Currency::USD);
assert_eq!(
ledger_core::reports::snapshot(store.ledger()).unwrap(),
before
);
store.verify().unwrap();
let kept = std::fs::read_dir(&dir)
.unwrap()
.filter(|e| {
e.as_ref()
.unwrap()
.file_name()
.to_string_lossy()
.contains(".corrupt.")
})
.count();
assert_eq!(kept, 1, "the wrong database is kept for forensics");
}
/// A disposal's proceeds go to an asset account and its gain or loss to a
/// revenue or expense account; anything else is refused before anything
/// is journaled.
#[test]
fn a_disposal_refuses_accounts_of_the_wrong_type() {
let dir = test_dir("dispose-types");
let (mut store, asset, cash, gain) = laptop(&dir);
let jan31 = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
let journal = dir.join(ledger_store::JOURNAL_FILE);
let before = std::fs::read(&journal).unwrap();
// Proceeds into a revenue account, gain/loss into an asset account.
assert!(
store
.dispose_asset(asset, jan31, usd(900), gain, gain)
.is_err()
);
assert!(
store
.dispose_asset(asset, jan31, usd(900), cash, cash)
.is_err()
);
assert_eq!(std::fs::read(&journal).unwrap(), before);
store
.dispose_asset(asset, jan31, usd(900), cash, gain)
.unwrap();
}
/// A depreciation period is "YYYY-MM": month 1 to 12, year 1 or later.
#[test]
fn depreciation_periods_must_be_real_months() {
let dir = test_dir("periods");
let (mut store, asset, _, _) = laptop(&dir);
for bad in ["2026-13", "2026-00", "0-05", "2026-01-01", "2026", "x-05"] {
let err = store.run_depreciation(asset, bad).unwrap_err().to_string();
assert!(err.contains("bad period"), "{bad}: {err}");
}
// Year 1 is a real year (whatever else the asset makes of it).
if let Err(e) = store.run_depreciation(asset, "0001-01") {
assert!(!e.to_string().contains("bad period"), "{e}");
}
store.run_depreciation(asset, "2026-02").unwrap();
}
/// Searching posted entries: by account, date range, and text, newest
/// first, with a limit and offset; drafts never show.
#[test]
fn searching_entries_filters_and_pages() {
let dir = test_dir("search");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let mut add = |code: &str, t| store_add(&mut store, code, t);
let cash = add("1000", AccountType::Asset);
let sales = add("4000", AccountType::Revenue);
let rent = add("6000", AccountType::Expense);
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let mut post = |date, desc: &str, dr, cr| {
let e = store.create_entry(date, desc).unwrap();
store.add_line(e, dr, Side::Debit, usd(10)).unwrap();
store.add_line(e, cr, Side::Credit, usd(10)).unwrap();
store.post_entry(e).unwrap();
e
};
let a = post(day(1, 5), "Sale to Acme", cash, sales);
let b = post(day(2, 5), "Rent February", rent, cash);
let c = post(day(3, 5), "Sale to Beta", cash, sales);
store.create_entry(day(3, 6), "Sale draft").unwrap();
let ids =
|rows: Vec<ledger_store::store::EntryRow>| rows.iter().map(|r| r.id).collect::<Vec<_>>();
assert_eq!(
ids(store.search_entries(None, None, None, None, 10, 0).unwrap()),
[c, b, a]
);
assert_eq!(
ids(store
.search_entries(Some(rent), None, None, None, 10, 0)
.unwrap()),
[b]
);
assert_eq!(
ids(store
.search_entries(None, Some("2026-02-01"), Some("2026-03-01"), None, 10, 0)
.unwrap()),
[b]
);
assert_eq!(
ids(store
.search_entries(None, None, None, Some("Sale"), 10, 0)
.unwrap()),
[c, a]
);
assert_eq!(
ids(store.search_entries(None, None, None, None, 1, 1).unwrap()),
[b]
);
}
/// The database's invoice row keeps no figure of its own (schema 15,
/// rules.md B-38): what it has been paid is the ledger's, read from the
/// lines tagged with it, so a reversed payment no longer counts; the row
/// is the same before and after each payment, and verify agrees.
#[test]
fn the_database_tracks_what_an_invoice_has_been_paid() {
use ledger_core::arap::DocumentLine;
let dir = test_dir("invoice-paid");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store_add(&mut store, "1000", AccountType::Asset);
let who = store.add_customer("Acme").unwrap();
let day = |d| NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
let inv = store
.create_invoice(
who,
day(1),
day(31),
vec![DocumentLine {
description: "work".into(),
amount: Decimal::from(100),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
let row = |dir: &std::path::Path| -> String {
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.query_row("SELECT * FROM invoices WHERE id = ?", [inv as i64], |r| {
Ok(format!(
"{:?}",
(0..6)
.map(|i| r.get_ref(i).map(|v| format!("{v:?}")))
.collect::<Vec<_>>()
))
})
.unwrap()
};
let before = row(&dir);
let paid = |s: &Store| {
s.ledger()
.invoice_state(inv, day(31))
.unwrap()
.unwrap()
.paid
};
let p = store
.receive_payment(inv, day(10), Decimal::from(40), cash)
.unwrap();
assert_eq!(paid(&store), Decimal::from(40));
store.reverse_entry(p, day(12), "Bounced").unwrap();
assert_eq!(paid(&store), Decimal::ZERO);
store
.receive_payment(inv, day(20), Decimal::from(60), cash)
.unwrap();
assert_eq!(paid(&store), Decimal::from(60));
assert_eq!(row(&dir), before, "the row holds no figure");
store.verify().unwrap();
}
/// Rewrite a journal through a change to its lines, re-chaining it, so
/// the result passes the hash chain and reaches replay.
fn rechain(dir: &std::path::Path, change: impl Fn(&mut Vec<ledger_store::JournalLine>)) {
let path = dir.join(ledger_store::JOURNAL_FILE);
let mut lines = ledger_store::journal::read_and_verify_journal(&path).unwrap();
change(&mut lines);
std::fs::remove_file(&path).unwrap();
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let mut w = JournalWriter::open(&path).unwrap();
for l in lines {
w.append(&l.line_type, l.payload).unwrap();
}
}
/// Replay re-derives a closing entry and refuses a journal whose recorded
/// entry differs from it (a hand edit that re-chained the file), and a
/// database missing a table fails the table check.
#[test]
fn replay_refuses_a_journaled_entry_it_does_not_rederive() {
let dir = test_dir("rederive");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let cash = store_add(&mut store, "1000", AccountType::Asset);
let sales = store_add(&mut store, "4000", AccountType::Revenue);
let day = |d| NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
let e = store.create_entry(day(10), "sale").unwrap();
store.add_line(e, cash, Side::Debit, usd(500)).unwrap();
store.add_line(e, sales, Side::Credit, usd(500)).unwrap();
store.post_entry(e).unwrap();
let p = store.create_period("2026-01", day(1), day(31)).unwrap();
store.close_period(p).unwrap();
drop(store);
// An untouched re-chain opens fine (the helper itself is sound).
rechain(&dir, |_| {});
drop(Store::open(&dir).unwrap());
// The recorded closing entry says 400 where replay computes 500.
rechain(&dir, |lines| {
let close = lines
.iter_mut()
.find(|l| l.line_type == "period.closed")
.unwrap();
for l in close.payload["entry"]["lines"].as_array_mut().unwrap() {
l["amount"] = serde_json::json!("400.00");
}
});
let err = Store::open(&dir).err().expect("refused").to_string();
assert!(err.contains("differ"), "{err}");
// A projection missing a table.
let dir = test_dir("missing-table");
drop(build_books(&dir));
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
conn.execute("DROP TABLE events", []).unwrap();
drop(conn);
let store = Store::open(&dir);
let msg = match store {
Ok(s) => s.verify().unwrap_err().to_string(),
Err(e) => e.to_string(),
};
assert!(msg.contains("events"), "{msg}");
}
/// Replay re-derives a schedule's monthly posting and refuses a journal
/// whose recorded entry differs from it.
#[test]
fn replay_refuses_a_schedule_posting_it_does_not_rederive() {
let dir = test_dir("rederive-schedule");
let mut store = Store::create(&dir, Currency::USD).unwrap();
let prepaid = store_add(&mut store, "1300", AccountType::Asset);
let insurance = store_add(&mut store, "6300", AccountType::Expense);
let day = |m, d| NaiveDate::from_ymd_opt(2026, m, d).unwrap();
let id = store
.register_schedule(ledger_core::NewSchedule {
description: "Insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: usd(1200),
start: day(1, 1),
months: 12,
balance_account: prepaid,
pl_account: insurance,
convention: ledger_core::ScheduleConvention::MonthlyEven,
})
.unwrap();
store.post_schedule_month(id, day(1, 31)).unwrap();
drop(store);
rechain(&dir, |_| {});
drop(Store::open(&dir).unwrap());
rechain(&dir, |lines| {
let posted = lines
.iter_mut()
.find(|l| l.line_type == "schedule.posted")
.unwrap();
for l in posted.payload["entry"]["lines"].as_array_mut().unwrap() {
l["amount"] = serde_json::json!("99.00");
}
});
let err = Store::open(&dir).err().expect("refused").to_string();
assert!(err.contains("differ"), "{err}");
}
/// A disposal refused for an account's type says which type it needed.
#[test]
fn a_disposal_account_refusal_names_the_type_needed() {
let dir = test_dir("dispose-type-message");
let (mut store, asset, cash, gain) = laptop(&dir);
let jan31 = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap();
let proceeds = store
.dispose_asset(asset, jan31, usd(900), gain, gain)
.unwrap_err()
.to_string();
let gain_loss = store
.dispose_asset(asset, jan31, usd(900), cash, cash)
.unwrap_err()
.to_string();
assert!(proceeds.contains("asset"), "{proceeds}");
assert!(gain_loss.contains("revenue or expense"), "{gain_loss}");
}
/// A database whose pages are damaged though its settings still read
/// fails SQLite's integrity check, and is kept aside and rebuilt.
#[test]
fn a_database_with_damaged_pages_is_rebuilt() {
let dir = test_dir("damaged-pages");
let mut store = build_books(&dir);
for n in 0..200u64 {
store
.add_customer(&format!("customer {n} {}", "x".repeat(100)))
.unwrap();
}
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
drop(store);
let db = dir.join(ledger_store::db_file());
let mut bytes = std::fs::read(&db).unwrap();
// Scramble only the last pages (the customers just added); the header,
// schema, and settings at the front still read.
let tail = bytes.len() - bytes.len() / 4;
for b in bytes.iter_mut().skip(tail).step_by(7) {
*b ^= 0x5a;
}
std::fs::write(&db, &bytes).unwrap();
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(
ledger_core::reports::snapshot(store.ledger()).unwrap(),
before
);
let kept = std::fs::read_dir(&dir)
.unwrap()
.filter(|e| {
e.as_ref()
.unwrap()
.file_name()
.to_string_lossy()
.contains(".corrupt.")
})
.count();
assert_eq!(kept, 1);
}
// ---------------------------------------------------------------------------
// P3: renaming an account, and the tax category policy (defects 15 and 8;
// rules.md B-31, B-32).
// ---------------------------------------------------------------------------
/// Every row of every projection table but the journal's own (`events`,
/// `meta`), as text, with `accounts.name` blanked: what a rename may
/// change is that one column.
fn projection_but_names(dir: &std::path::Path) -> Vec<(String, Vec<String>)> {
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
// The journal's own rows (every line, and its head) grow with every
// command: what a rename adds there is checked from the journal.
tables.retain(|t| !t.starts_with("sqlite_") && t != "events" && t != "meta");
tables
.into_iter()
.map(|t| {
let mut stmt = conn.prepare(&format!("SELECT * FROM \"{t}\"")).unwrap();
let names: Vec<String> = stmt.column_names().iter().map(|c| c.to_string()).collect();
let mut rows: Vec<String> = stmt
.query_map([], |r| {
Ok((0..names.len())
.map(|i| {
if t == "accounts" && names[i] == "name" {
"-".to_string()
} else {
format!("{:?}", r.get_ref(i).unwrap())
}
})
.collect::<Vec<_>>()
.join("|"))
})
.unwrap()
.map(Result::unwrap)
.collect();
rows.sort();
(t, rows)
})
.collect()
}
/// Every figure of the books with account names left out: the trial
/// balance, the P&L and balance sheet (whole and for January), and every
/// entry's lines.
fn figures_but_names(l: &ledger_core::Ledger) -> serde_json::Value {
use ledger_core::reports;
let jan = |d: u32| NaiveDate::from_ymd_opt(2026, 1, d).unwrap();
let lines = |v: &[ledger_core::StatementLine]| {
v.iter()
.map(|x| (x.code.clone(), x.amount.normalize().to_string()))
.collect::<Vec<_>>()
};
let tb = reports::trial_balance(l);
let pl = reports::profit_and_loss_for_period(l, jan(1), jan(31), None).unwrap();
let bs = reports::balance_sheet_as_of(l, jan(31)).unwrap();
serde_json::json!({
"tb": tb.lines.iter().map(|x| (x.code.clone(), x.debit.normalize().to_string(), x.credit.normalize().to_string())).collect::<Vec<_>>(),
"tb_totals": (tb.total_debits.normalize().to_string(), tb.total_credits.normalize().to_string()),
"pl": (lines(&pl.revenue), lines(&pl.expenses), pl.net_income().normalize().to_string()),
"bs": (lines(&bs.assets), lines(&bs.liabilities), lines(&bs.equity), bs.total_assets.normalize().to_string()),
"entries": (1..l.next_entry_id()).filter_map(|id| l.get_entry(id)).collect::<Vec<_>>(),
})
}
/// rename_account (repro 1.2; rules.md B-31): a rename changes the
/// account's name and nothing else: no figure, entry or line, no earlier
/// journal line, no projection row but `accounts.name`; the journal gains
/// one `account.renamed` line (keyed by id, with the old name and the
/// reason) after the feature's declaration; a refused rename (unknown,
/// blank, unchanged, a sibling's name, nine digits, a bad reason) appends
/// nothing; the name survives a reopen, a rebuild and the viewer's read.
#[test]
fn rename_account_changes_only_the_name_defect_15() {
let dir = test_dir("rename-only-name");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let figures = figures_but_names(store.ledger());
let rows = projection_but_names(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
for (id, name, reason) in [
(999, "Office Rent", None),
(rent, "", None),
(rent, " Rent Expense ", None),
(rent, "cash", None),
(rent, "Lease 123456789", None),
(rent, "Office Rent", Some("")),
(rent, "Office Rent", Some("lease no. 123-45-6789")),
] {
assert!(
store.rename_account(id, name, reason).is_err(),
"{name:?} {reason:?}"
);
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{name:?}");
assert_eq!(projection_but_names(&dir), rows, "{name:?}");
}
let (seq, _) = store.head();
store
.rename_account(rent, " Office Rent ", Some("owner asked 2026-10-21"))
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
// Every earlier line kept, byte for byte; one line appended, the
// rename (its feature, account_renamed, the books declared as they
// were created, with the rest of this version's format).
assert!(after.starts_with(&text));
assert!(
store
.journal_format()
.has_feature(ledger_store::format::FEATURE_ACCOUNT_RENAMED)
);
let added: Vec<serde_json::Value> = after[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(added.len(), 1);
assert_eq!(added[0]["type"], "account.renamed");
assert_eq!(
added[0]["payload"],
serde_json::json!({
"account_id": rent, "code": "6000", "name": "Office Rent",
"previous": "Rent Expense", "reason": "owner asked 2026-10-21"
})
);
assert_eq!(store.head().0, seq + 1);
assert_eq!(
store.ledger().chart().get(rent).unwrap().name,
"Office Rent"
);
assert_eq!(figures_but_names(store.ledger()), figures);
assert_eq!(projection_but_names(&dir), rows);
// The one column a rename changes, read from the projection itself
// (projection_but_names blanks it).
let db_name = |dir: &std::path::Path| -> String {
rusqlite::Connection::open(dir.join(ledger_store::db_file()))
.unwrap()
.query_row(
"SELECT name FROM accounts WHERE id = ?",
[rent as i64],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(db_name(&dir), "Office Rent");
store.verify().unwrap();
// A second rename declares nothing more.
store.rename_account(rent, "Rent", None).unwrap();
let again = std::fs::read_to_string(&journal).unwrap();
let last: serde_json::Value = serde_json::from_str(again.lines().last().unwrap()).unwrap();
assert_eq!(again.lines().count(), after.lines().count() + 1);
assert_eq!(last["payload"]["previous"], "Office Rent");
assert_eq!(last["payload"]["reason"], serde_json::Value::Null);
drop(store);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert_eq!(reopened.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(figures_but_names(reopened.ledger()), figures);
drop(reopened);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(rebuilt.ledger().chart().get(rent).unwrap().name, "Rent");
assert_eq!(projection_but_names(&dir), rows);
assert_eq!(db_name(&dir), "Rent");
drop(rebuilt);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().chart().get(rent).unwrap().name, "Rent");
}
/// rules.md B-28's pair for renames (review R5): the command refuses a
/// blank name, a sibling's or nine digits; replay takes a journaled line
/// holding any of them (a later version may tighten the rules, never lock
/// out books it wrote), and refuses only an account that doesn't exist.
#[test]
fn a_journaled_rename_replays_past_the_command_rules() {
use serde_json::json;
for (name, opens) in [
("", true),
("Cash", true),
("Lease 123456789", true),
("Rent Expense", true),
] {
let dir = test_dir("rename-replay");
let store = build_books(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
drop(store);
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["account_renamed"]}),
)
.unwrap();
w.append(
"account.renamed",
json!({"account_id": rent, "code": "6000", "name": name, "previous": "Rent Expense", "reason": null}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir);
assert_eq!(store.is_ok(), opens, "{name:?}");
let store = store.unwrap();
store.verify().unwrap();
assert_eq!(store.ledger().chart().get(rent).unwrap().name, name);
}
// An account that doesn't exist: the line can't be replayed.
let dir = test_dir("rename-replay-unknown");
drop(build_books(&dir));
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
json!({"format": 2, "features": ["account_renamed"]}),
)
.unwrap();
w.append(
"account.renamed",
json!({"account_id": 99, "code": "9", "name": "x", "previous": "y", "reason": null}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
assert!(Store::open(&dir).is_err());
}
fn tax_policy(store: &mut Store, value: Option<&str>) {
store
.change_settings(
&[ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: value.map(|v| serde_json::json!(v)),
}],
&ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
}
/// add_account_with_tax and the policy (rules.md B-32): under warn an
/// account is created without a category; under refuse a revenue or
/// expense account that isn't a header is refused without one, journaling
/// nothing, and created with one as account.added then account.tax_set;
/// a needed category can't be cleared; the accounts the engine creates
/// for a control role are never refused. Setting the key needs the feature
/// require_tax_category, which new books declared as they were created:
/// the setting journals only itself.
#[test]
fn the_tax_category_policy_refuses_only_what_it_says() {
let dir = test_dir("tax-policy");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
store
.add_account("6150", "Software", AccountType::Expense, None, false)
.unwrap();
let before = std::fs::read_to_string(&journal).unwrap();
tax_policy(&mut store, Some("refuse"));
let declared = std::fs::read_to_string(&journal).unwrap();
let new: Vec<serde_json::Value> = declared[before.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(new.len(), 1);
assert_eq!(new[0]["type"], "settings.set");
for (code, t, header, tax, refused) in [
("6160", AccountType::Expense, false, None, true),
("4100", AccountType::Revenue, false, None, true),
("6170", AccountType::Expense, true, None, false),
("1100", AccountType::Asset, false, None, false),
("2100", AccountType::Liability, false, None, false),
("3100", AccountType::Equity, false, None, false),
(
"6180",
AccountType::Expense,
false,
Some(("bogus", None)),
true,
),
(
"4200",
AccountType::Revenue,
false,
Some(("supplies", None)),
true,
),
] {
let text = std::fs::read_to_string(&journal).unwrap();
let got = store.add_account_with_tax(code, &format!("Acct {code}"), t, None, header, tax);
assert_eq!(got.is_err(), refused, "{code} {got:?}");
if refused {
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text, "{code}");
assert!(store.ledger().chart().find_by_code(code).is_none());
}
}
let err = store
.add_account("6160", "Hosting", AccountType::Expense, None, false)
.unwrap_err()
.to_string();
assert!(
err.contains("policy.require_tax_category is refuse"),
"{err}"
);
assert!(err.contains("6160 Hosting"), "{err}");
// With a category: two lines, account.added then account.tax_set.
let text = std::fs::read_to_string(&journal).unwrap();
let id = store
.add_account_with_tax(
"6160",
"Hosting",
AccountType::Expense,
None,
false,
Some(("other_deductions", Some(Decimal::from(80)))),
)
.unwrap();
let added: Vec<serde_json::Value> = std::fs::read_to_string(&journal).unwrap()[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(
added
.iter()
.map(|l| l["type"].as_str().unwrap())
.collect::<Vec<_>>(),
["account.added", "account.tax_set"]
);
let a = store.ledger().chart().get(id).unwrap();
assert_eq!(a.tax_category.as_deref(), Some("other_deductions"));
assert_eq!(a.deductible_percent, Some(Decimal::from(80)));
// A needed category is never cleared; a header's or an asset's is no
// concern; an existing uncategorized account is left as it was.
let text = std::fs::read_to_string(&journal).unwrap();
assert!(store.set_account_tax(id, None, None).is_err());
let software = store.ledger().chart().find_by_code("6150").unwrap().id;
assert!(store.set_account_tax(software, None, None).is_err());
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text);
assert!(
store
.ledger()
.chart()
.get(software)
.unwrap()
.tax_category
.is_none()
);
store
.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
// The engine's own accounts: a bill makes 6999 General Expenses with
// no category, under refuse too.
let v = store.add_vendor("Coastline Hosting").unwrap();
store
.create_bill(
v,
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
vec![ledger_core::DocumentLine {
description: "hosting".into(),
amount: Decimal::new(4_200, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
let general = store.ledger().chart().find_by_code("6999").unwrap();
assert!(general.tax_category.is_none());
// Back to warn: no second declaration, and the account is created.
let text = std::fs::read_to_string(&journal).unwrap();
tax_policy(&mut store, Some("warn"));
let lines: Vec<serde_json::Value> = std::fs::read_to_string(&journal).unwrap()[text.len()..]
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0]["type"], "settings.set");
store
.add_account("6190", "Domains", AccountType::Expense, None, false)
.unwrap();
store.verify().unwrap();
drop(store);
Store::open(&dir).unwrap().verify().unwrap();
}
/// rules.md B-28's pair for the policy (review R5): the policy is the
/// commands', never replay's. A journal that sets refuse and then clears
/// a category, or adds an uncategorized expense account, replays.
#[test]
fn a_journaled_clear_replays_under_refuse() {
use serde_json::json;
let dir = test_dir("tax-policy-replay");
let mut store = build_books(&dir);
let rent = store.ledger().chart().find_by_code("6000").unwrap().id;
store
.set_account_tax(rent, Some("rent_property"), None)
.unwrap();
tax_policy(&mut store, Some("refuse"));
let next = store.ledger().next_account_id();
drop(store);
let mut w = JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"account.tax_set",
json!({"account_id": rent, "tax_category": null, "deductible_percent": null}),
)
.unwrap();
w.append(
"account.added",
json!({"id": next, "code": "6150", "name": "Software", "account_type": "Expense",
"parent": null, "placeholder": false, "currency": "USD"}),
)
.unwrap();
drop(w);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert!(
store
.ledger()
.chart()
.get(rent)
.unwrap()
.tax_category
.is_none()
);
assert!(store.ledger().chart().find_by_code("6150").is_some());
assert_eq!(
store.ledger().settings().require_tax_category(),
ledger_core::settings::TaxCategoryPolicy::Refuse
);
}
mod p3_properties {
use super::*;
use proptest::prelude::*;
const NAMES: [&str; 8] = [
"Office",
"office",
"Cash",
"",
"Lease 123456789",
"Rent Expense",
"Studio",
"Tools",
];
proptest! {
#![proptest_config(ProptestConfig {
cases: std::env::var("STORE_CASES").ok().and_then(|v| v.parse().ok()).unwrap_or(64),
failure_persistence: None,
..ProptestConfig::default()
})]
/// rules.md B-31 (the engine-side counterpart to the Lean note,
/// docs/verification.md law 15): on books with postings, any sequence
/// of renames changes no figure (the ledger's statements, without
/// names), no entry and no earlier journal line; live equals
/// replay; the projection equals a fresh one. The sets picked by
/// name (cash, card accounts) are presentation, outside this law.
#[test]
fn prop_rename_changes_no_figure(
renames in prop::collection::vec((1u64..6, 0usize..NAMES.len()), 1..12),
seed in any::<u64>(),
) {
let dir = test_dir(&format!("prop-rename-{seed}"));
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let figures = figures_but_names(store.ledger());
let rows = projection_but_names(&dir);
let mut text = std::fs::read_to_string(&journal).unwrap();
for (account, n) in renames {
let ok = store.rename_account(account, NAMES[n], None).is_ok();
let now = std::fs::read_to_string(&journal).unwrap();
prop_assert!(now.starts_with(&text));
prop_assert_eq!(ok, now.len() > text.len());
text = now;
prop_assert_eq!(&figures_but_names(store.ledger()), &figures);
prop_assert_eq!(&projection_but_names(&dir), &rows);
}
prop_assert!(store.verify().is_ok());
let live = store.ledger().clone();
drop(store);
let reopened = Store::open(&dir).unwrap();
prop_assert!(live.differs_from(reopened.ledger()).is_empty());
}
/// rules.md B-32: add_account_with_tax is refused exactly when the
/// policy is refuse, the account needs a category (revenue or
/// expense, not a header) and none is given, or when the category
/// doesn't fit; a refusal journals nothing.
#[test]
fn prop_tax_policy_refuses_exactly(
refuse in any::<bool>(),
t in 0usize..5,
header in any::<bool>(),
cat in 0usize..4,
seed in any::<u64>(),
) {
let types = [
AccountType::Asset,
AccountType::Liability,
AccountType::Equity,
AccountType::Revenue,
AccountType::Expense,
];
let cats = [None, Some("other_income"), Some("supplies"), Some("bogus")];
let dir = test_dir(&format!("prop-tax-{seed}"));
let mut store = build_books(&dir);
tax_policy(&mut store, Some(if refuse { "refuse" } else { "warn" }));
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
let t = types[t];
let needs = matches!(t, AccountType::Revenue | AccountType::Expense) && !header;
let fits = match cats[cat] {
None => true,
Some("other_income") => matches!(t, AccountType::Revenue | AccountType::Expense),
Some("supplies") => t == AccountType::Expense,
_ => false,
};
let want_refused = !fits || (refuse && needs && cats[cat].is_none());
let got = store.add_account_with_tax(
"7000", "Prop", t, None, header, cats[cat].map(|c| (c, None)),
);
prop_assert_eq!(got.is_err(), want_refused, "{:?}", got);
let after = std::fs::read_to_string(&journal).unwrap();
if want_refused {
prop_assert_eq!(after, text);
} else {
let lines = after[text.len()..].lines().count();
prop_assert_eq!(lines, if cats[cat].is_some() { 2 } else { 1 });
}
}
}
}
/// Each account as the books hold it, by id: what chart.json in the
/// p3_rename fixture records (expected.json's figures carry no names).
fn chart_of(l: &ledger_core::Ledger) -> serde_json::Value {
let mut accounts: Vec<_> = l.chart().all().collect();
accounts.sort_by_key(|a| a.id);
serde_json::json!(accounts)
}
fn p3_fixture() -> PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p3_rename")
}
/// How the p3_rename fixture was written (2026-10-20, P3): through the
/// Store API of the version that introduced `account.renamed` and
/// `policy.require_tax_category`, with their journal features, a header
/// and its children, refused commands between them. Run once with
/// `--ignored`; the journal it wrote is frozen in the fixture, and later
/// versions must open it to the same figures
/// (books_an_earlier_version_wrote_open_to_its_figures) and names
/// (books_written_under_p3_keep_their_names).
#[test]
#[ignore]
fn write_p3_rename_fixture() {
let dir = test_dir("p3-fixture");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(2026, m, d).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 ops = s
.add_account("6000", "Operating", AccountType::Expense, None, true)
.unwrap();
let software = s
.add_account("6150", "Software", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account("6160", "Hosting", AccountType::Expense, Some(ops), false)
.unwrap();
s.add_account_with_tax(
"4000",
"Sales",
AccountType::Revenue,
None,
false,
Some(("gross_receipts", None)),
)
.unwrap();
tax_policy(&mut s, Some("refuse"));
assert!(
s.add_account("6170", "Domains", AccountType::Expense, Some(ops), false)
.is_err()
);
s.add_account_with_tax(
"6170",
"Domains",
AccountType::Expense,
Some(ops),
false,
Some(("other_deductions", None)),
)
.unwrap();
for (m, d, dr, cr, cents) in [
(1u32, 5u32, bank, capital, 500_000i64),
(2, 3, software, bank, 4_999),
(3, 9, software, bank, 4_999),
] {
let e = s.create_entry(day(m, d), "fixture").unwrap();
let money = Money::new(Decimal::new(cents, 2), Currency::USD);
s.add_line(e, dr, Side::Debit, money).unwrap();
s.add_line(e, cr, Side::Credit, money).unwrap();
s.post_entry(e).unwrap();
}
assert!(s.rename_account(software, "hosting", None).is_err());
s.rename_account(
software,
"Software subscriptions",
Some("owner asked 2026-10-20"),
)
.unwrap();
s.rename_account(ops, "Operating expenses", None).unwrap();
assert!(s.set_account_tax(software, None, None).is_err());
s.set_account_tax(software, Some("other_deductions"), None)
.unwrap();
s.rename_account(software, "Software", Some("back to the short name"))
.unwrap();
tax_policy(&mut s, None);
s.verify().unwrap();
let out = p3_fixture();
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
std::fs::copy(
dir.join(ledger_store::JOURNAL_FILE),
out.join(ledger_store::JOURNAL_FILE),
)
.unwrap();
let mut figures = old_books_figures(s.ledger());
figures["verify"] = serde_json::json!({"Ok": null});
std::fs::write(
out.join("expected.json"),
serde_json::to_string_pretty(&figures).unwrap() + "\n",
)
.unwrap();
std::fs::write(
out.join("chart.json"),
serde_json::to_string_pretty(&chart_of(s.ledger())).unwrap() + "\n",
)
.unwrap();
}
/// Books written under P3 (fixture p3_rename): every account's name,
/// code, parent and tax category, as journaled through renames and the
/// tax category policy, read back the same by the writer, a rebuild and
/// the viewer; and the features it declared are the ones this version
/// knows.
#[test]
fn books_written_under_p3_keep_their_names() {
let fixture = p3_fixture();
let want: serde_json::Value =
serde_json::from_slice(&std::fs::read(fixture.join("chart.json")).unwrap()).unwrap();
let dir = old_books_copy(&fixture, "p3-names");
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(chart_of(store.ledger()), want);
assert_eq!(
store.journal_format().features,
["account_renamed", "require_tax_category"]
);
let names: Vec<&str> = want
.as_array()
.unwrap()
.iter()
.map(|a| a["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
[
"Bank",
"Capital",
"Operating expenses",
"Software",
"Hosting",
"Sales",
"Domains"
]
);
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let rebuilt = Store::open(&dir).unwrap();
assert_eq!(chart_of(rebuilt.ledger()), want);
drop(rebuilt);
let snap = ledger_store::Snapshot::load(&dir).unwrap();
assert_eq!(chart_of(snap.ledger()), want);
}
/// rules.md B-31: a new account's name is journaled trimmed, as a rename
/// journals one, so the live books, a reopen and the projection all read
/// the same name; the command's text rules refuse a name a report can't
/// print on one line, journaling nothing.
#[test]
fn a_new_accounts_name_is_journaled_trimmed() {
let dir = test_dir("add-trimmed");
let mut store = build_books(&dir);
let journal = dir.join(ledger_store::JOURNAL_FILE);
let text = std::fs::read_to_string(&journal).unwrap();
for name in ["Two\nlines", &"N".repeat(201)] {
assert!(
store
.add_account("6150", name, AccountType::Expense, None, false)
.is_err(),
"{name:?}"
);
assert_eq!(std::fs::read_to_string(&journal).unwrap(), text);
}
let id = store
.add_account("6150", " Hosting\t", AccountType::Expense, None, false)
.unwrap();
let after = std::fs::read_to_string(&journal).unwrap();
let line: serde_json::Value = serde_json::from_str(after.lines().last().unwrap()).unwrap();
assert_eq!(line["type"], "account.added");
assert_eq!(line["payload"]["name"], "Hosting");
assert_eq!(store.ledger().chart().get(id).unwrap().name, "Hosting");
let live = store.ledger().clone();
drop(store);
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let reopened = Store::open(&dir).unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
let name: String = conn
.query_row("SELECT name FROM accounts WHERE id = ?", [id as i64], |r| {
r.get(0)
})
.unwrap();
assert_eq!(name, "Hosting");
}
// ---------------------------------------------------------------------------
// Draft states, discards and the posting line (plan P4, `drafts_v2`).
// ---------------------------------------------------------------------------
fn jan(day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, day).unwrap()
}
/// A books dir with two accounts and one posted entry, in the current
/// format (new books declare every feature).
fn books_for_drafts(dir: &std::path::Path) -> (Store, u64, u64, u64) {
let mut s = Store::create(dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = s
.add_account("3000", "Equity", AccountType::Equity, None, false)
.unwrap();
let rent = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let seed = s.create_entry(jan(2), "Seed").unwrap();
s.add_line(seed, cash, Side::Debit, usd(500)).unwrap();
s.add_line(seed, equity, Side::Credit, usd(500)).unwrap();
s.post_entry(seed).unwrap();
(s, cash, rent, seed)
}
fn lines_of(dir: &std::path::Path) -> Vec<serde_json::Value> {
std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
/// R-B (plan P4): a draft's state journals `entry.draft_state_set`, replays
/// into the draft, and projects into `draft_states` (a row only while it
/// awaits evidence, gone when it posts or is discarded); a discard
/// journals `entry.draft_discarded` with its reason and replays into a
/// ledger without the draft. Live, replay and the projection agree.
#[test]
fn draft_states_replay_and_project() {
let dir = test_dir("draft-states");
let (mut s, cash, rent, _) = books_for_drafts(&dir);
let waiting = s.create_entry(jan(10), "Figma annual plan").unwrap();
s.add_line(waiting, rent, Side::Debit, usd(40)).unwrap();
s.add_line(waiting, cash, Side::Credit, usd(40)).unwrap();
let doomed = s.create_entry(jan(11), "Duplicate").unwrap();
s.add_line(doomed, rent, Side::Debit, usd(5)).unwrap();
s.add_line(doomed, cash, Side::Credit, usd(5)).unwrap();
let from = lines_of(&dir).len();
s.set_draft_state(
waiting,
ledger_core::DraftState::AwaitingEvidence {
note: "invoice due from Figma".into(),
},
)
.unwrap();
s.set_draft_state(doomed, ledger_core::DraftState::Building)
.unwrap();
s.discard_draft(doomed, "entered twice").unwrap();
let got: Vec<_> = lines_of(&dir).into_iter().skip(from).collect();
assert_eq!(got[0]["type"], "entry.draft_state_set");
assert_eq!(got[0]["payload"]["id"], waiting);
assert_eq!(got[0]["payload"]["state"], "awaiting_evidence");
assert_eq!(got[0]["payload"]["note"], "invoice due from Figma");
assert_eq!(got[1]["payload"]["state"], "building");
assert!(got[1]["payload"].get("note").is_none(), "absent, not empty");
assert_eq!(got[2]["type"], "entry.draft_discarded");
assert_eq!(got[2]["payload"]["reason"], "entered twice");
s.verify().unwrap();
let rows = |dir: &std::path::Path| -> Vec<(i64, String, Option<String>)> {
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
let mut q = conn
.prepare("SELECT entry_id, state, note FROM draft_states ORDER BY entry_id")
.unwrap();
q.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
.unwrap()
.map(|r| r.unwrap())
.collect()
};
assert_eq!(
rows(&dir),
vec![(
waiting as i64,
"awaiting_evidence".to_string(),
Some("invoice due from Figma".to_string())
)]
);
// The live ledger, and one rebuilt from the journal alone, agree.
let live = s.ledger().clone();
assert!(
live.get_entry(doomed).is_none(),
"the discarded draft is gone"
);
assert_eq!(
live.get_entry(waiting).unwrap().draft_state().key(),
"awaiting_evidence"
);
drop(s);
let mut reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert!(
live.differs_from(reopened.ledger()).is_empty(),
"{:?}",
live.differs_from(reopened.ledger())
);
// Posting the waiting draft clears its row and its state.
reopened.post_entry(waiting).unwrap();
assert_eq!(
reopened
.ledger()
.get_entry(waiting)
.unwrap()
.draft_state()
.key(),
"building"
);
reopened.verify().unwrap();
drop(reopened);
assert!(rows(&dir).is_empty());
// The refusals: a posted entry, an unknown one, an empty reason, a TIN.
let mut s = Store::open(&dir).unwrap();
let head = s.head();
assert!(s.discard_draft(waiting, "oops").is_err());
assert!(s.discard_draft(9999, "oops").is_err());
let again = s.create_entry(jan(12), "Another").unwrap();
let head = {
let _ = head;
s.head()
};
assert!(s.discard_draft(again, " ").is_err());
assert!(s.discard_draft(again, "ssn 123-45-6789").is_err());
assert!(
s.set_draft_state(
again,
ledger_core::DraftState::AwaitingEvidence {
note: "ein 12-3456789 pending".into()
}
)
.is_err()
);
assert!(
s.set_draft_state(waiting, ledger_core::DraftState::Building)
.is_err()
);
assert_eq!(s.head(), head, "a refusal journals nothing");
}
/// R-A step 3 (plan P4): the draft tools are `drafts_v2`'s, so books that
/// haven't declared it refuse them, journaling nothing; after the owner's
/// go-ahead they journal, and the reopened books hold them.
#[test]
fn drafts_v2_waits_for_the_upgrade() {
let dir = test_dir("drafts-wait");
let mut s =
Store::create_in_format(&dir, Currency::USD, &ledger_store::JournalFormat::default())
.unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let rent = s
.add_account("6000", "Rent", AccountType::Expense, None, false)
.unwrap();
let d = s.create_entry(jan(10), "Figma").unwrap();
s.add_line(d, rent, Side::Debit, usd(40)).unwrap();
s.add_line(d, cash, Side::Credit, usd(40)).unwrap();
let head = s.head();
let len = lines_of(&dir).len();
for refused in [
s.set_draft_state(
d,
ledger_core::DraftState::AwaitingEvidence { note: "x".into() },
),
s.discard_draft(d, "no longer needed"),
] {
match refused {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature drafts_v2")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
}
assert_eq!(s.head(), head, "a refusal journals nothing");
assert_eq!(lines_of(&dir).len(), len);
// Posting a draft needs nothing new: it is the old shape.
assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead");
s.set_draft_state(
d,
ledger_core::DraftState::AwaitingEvidence { note: "x".into() },
)
.unwrap();
s.discard_draft(d, "no longer needed").unwrap();
let live = s.ledger().clone();
drop(s);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
assert!(live.differs_from(reopened.ledger()).is_empty());
assert!(reopened.ledger().get_entry(d).is_none());
}
/// Plan P4: `posted_seq` is the journal line that posted the entry:
/// `entry.posted` (hand entries and the ones commands make), `entry.reversed`,
/// the closing entry's `period.closed`. A backdated entry has the line it
/// was posted at, not the line its date would suggest; replay and the live
/// books agree; the projection's `entries.posted_seq` is the same number.
#[test]
fn posted_seq_is_the_posting_lines_seq() {
let dir = test_dir("posted-seq");
let (mut s, cash, rent, seed) = books_for_drafts(&dir);
// Drafted first, posted later, and dated before the seed.
let backdated = s.create_entry(jan(1), "Backdated").unwrap();
s.add_line(backdated, rent, Side::Debit, usd(7)).unwrap();
s.add_line(backdated, cash, Side::Credit, usd(7)).unwrap();
let other = s.create_entry(jan(20), "Later date, earlier post").unwrap();
s.add_line(other, rent, Side::Debit, usd(3)).unwrap();
s.add_line(other, cash, Side::Credit, usd(3)).unwrap();
s.post_entry(other).unwrap();
s.post_entry(backdated).unwrap();
let reversal = s.reverse_entry(other, jan(21), "Undo").unwrap();
let jan_p = s
.create_period(
"2026-01",
jan(1),
NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(),
)
.unwrap();
let closing = s.close_period(jan_p).unwrap().expect("income to close");
// The journal line that carries each posting.
let lines = lines_of(&dir);
let seq_of = |ty: &str, id: u64| -> u64 {
lines
.iter()
.find(|l| l["type"] == ty && l["payload"]["id"] == id)
.unwrap_or_else(|| panic!("no {ty} for {id}"))["seq"]
.as_u64()
.unwrap()
};
let closing_seq = lines.iter().find(|l| l["type"] == "period.closed").unwrap()["seq"]
.as_u64()
.unwrap();
let want = [
(seed, seq_of("entry.posted", seed)),
(other, seq_of("entry.posted", other)),
(backdated, seq_of("entry.posted", backdated)),
(reversal, seq_of("entry.reversed", reversal)),
(closing, closing_seq),
];
for (id, seq) in want {
assert_eq!(s.ledger().posted_seq(id), Some(seq), "live: entry {id}");
}
assert!(
s.ledger().posted_seq(backdated) > s.ledger().posted_seq(other),
"posted later though dated earlier"
);
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let reopened = Store::open(&dir).unwrap();
reopened.verify().unwrap();
for (id, seq) in want {
assert_eq!(
reopened.ledger().posted_seq(id),
Some(seq),
"replay: entry {id}"
);
}
assert!(live.differs_from(reopened.ledger()).is_empty());
let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap();
for (id, seq) in want {
let got: i64 = conn
.query_row(
"SELECT posted_seq FROM entries WHERE id = ?",
[id as i64],
|r| r.get(0),
)
.unwrap();
assert_eq!(got as u64, seq, "projection: entry {id}");
}
}