From 7559b4c8089be0713149bd82ad07edff21354783 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 13 Oct 2026 19:46:00 -0600 Subject: [PATCH] store: second mutation pass (345/373); trim what rehearsal covers; tests Store mutation rerun: 345 caught of 373 viable (92.5%, from 82%). The survivors, triaged: - Removed code the survivors showed could never matter: set_line_class's hand-written checks (its rehearsal refuses first), ensure_on (the engine creates missing control accounts itself), and verify's live-vs-replay halves of the asset, class, and payroll comparisons (the whole-ledger comparison above them already checks that; the SQLite read-back halves stay). - New tests: disposal account types; depreciation periods must be real months; search_entries (filters, newest first, paging, no drafts); the database's invoice amount paid and status; replay refusing a re-chained journal whose closing entry differs from what it re-derives; a projection missing a table. - Equivalent: sort_value's arms (serde_json's maps are already sorted without preserve_order, which nothing enables; the function stays as the guard if anything ever does), OpenFlags | vs ^ (disjoint bits), and writing the long form of a sweepless retained-earnings close (replay reads both). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-store/src/store.rs | 73 ++--------- crates/ledger-store/tests/store.rs | 198 +++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 62 deletions(-) diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 399ebea..30338ef 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1344,11 +1344,7 @@ impl Store { due_date: NaiveDate, lines: Vec, ) -> Result { - self.rehearse(|l| { - ensure_on(l, &ledger_core::RECEIVABLES)?; - ensure_on(l, &ledger_core::REVENUE)?; - l.create_invoice(customer_id, date, due_date, lines.clone()) - })?; + self.rehearse(|l| l.create_invoice(customer_id, date, due_date, lines.clone()))?; // Pure pre-validation mirroring Ledger::create_invoice, so the // journal writes below cannot be followed by a failed mutation. // (The auto-posted entry balances by construction and its accounts @@ -1408,11 +1404,7 @@ impl Store { due_date: NaiveDate, lines: Vec, ) -> Result { - self.rehearse(|l| { - ensure_on(l, &ledger_core::GENERAL_EXPENSES)?; - ensure_on(l, &ledger_core::PAYABLES)?; - l.create_bill(vendor_id, date, due_date, lines.clone()) - })?; + self.rehearse(|l| l.create_bill(vendor_id, date, due_date, lines.clone()))?; if self.ledger.get_vendor(vendor_id).is_none() { return Err(LedgerError::UnknownVendor(vendor_id).into()); } @@ -1463,10 +1455,7 @@ impl Store { amount: Decimal, cash_account: AccountId, ) -> Result { - self.rehearse(|l| { - ensure_on(l, &ledger_core::RECEIVABLES)?; - l.receive_payment(invoice_id, date, amount, cash_account) - })?; + self.rehearse(|l| l.receive_payment(invoice_id, date, amount, cash_account))?; // The rehearsal above is the whole check: hand-written mirrors of // the engine's refusals drift from it (and were never reached). let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; @@ -1502,10 +1491,7 @@ impl Store { amount: Decimal, cash_account: AccountId, ) -> Result { - self.rehearse(|l| { - ensure_on(l, &ledger_core::PAYABLES)?; - l.pay_bill(bill_id, date, amount, cash_account) - })?; + self.rehearse(|l| l.pay_bill(bill_id, date, amount, cash_account))?; let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; let description = format!("Payment on bill {bill_id}"); let (entry_id, mut journaled) = @@ -1640,12 +1626,7 @@ impl Store { lines: Vec, cash_account: AccountId, ) -> Result { - self.rehearse(|l| { - for c in &ledger_core::PAYROLL { - ensure_on(l, c)?; - } - l.run_payroll(date, lines.clone(), cash_account) - })?; + self.rehearse(|l| l.run_payroll(date, lines.clone(), cash_account))?; // Pure pre-validation mirroring Ledger::run_payroll, so the journal // writes below cannot be followed by a failed mutation. self.ledger.validate_payroll(date, &lines, cash_account)?; @@ -1827,22 +1808,6 @@ impl Store { class_id: Option, ) -> Result<(), StoreError> { self.rehearse(|l| l.set_line_class(entry_id, line_index, class_id))?; - // Draft-only check via the ledger, before anything is journaled. - let entry = self - .ledger - .get_entry(entry_id) - .ok_or(LedgerError::UnknownEntry(entry_id))?; - if entry.status() != EntryStatus::Draft { - return Err(LedgerError::PostedEntryImmutable.into()); - } - if let Some(cid) = class_id - && self.ledger.get_class(cid).is_none() - { - return Err(LedgerError::UnknownClass(cid).into()); - } - if line_index >= entry.lines().len() { - return Err(LedgerError::LineIndexOutOfRange(line_index, entry_id).into()); - } let line = self.append_journal( "entry.line_class_set", json!({ @@ -2461,25 +2426,18 @@ impl Store { parts.join(", ") ))); } - // The accounting snapshot ignores the asset register, so compare it - // explicitly: journal replay, live ledger, and projection must agree. + // The live ledger equals the replay (checked above, in full); the + // SQLite read-back's asset register, classes, and payroll runs + // must equal it too (the accounting snapshot ignores them). let assets_of = |l: &Ledger| l.registered_assets().cloned().collect::>(); - let (ra, rb, rc) = (assets_of(&replayed), assets_of(&self.ledger), assets_of(&c)); - if ra != rb || ra != rc { + if assets_of(&replayed) != assets_of(&c) { return Err(StoreError::VerifyMismatch( "asset register diverged between journal, live ledger, and sqlite projection" .into(), )); } - // Classes and payroll runs are master data the snapshot also - // ignores; compare them explicitly the same way. let classes_of = |l: &Ledger| l.list_classes().into_iter().cloned().collect::>(); - let (ca, cb, cc) = ( - classes_of(&replayed), - classes_of(&self.ledger), - classes_of(&c), - ); - if ca != cb || ca != cc { + if classes_of(&replayed) != classes_of(&c) { return Err(StoreError::VerifyMismatch( "class list diverged between journal, live ledger, and sqlite projection".into(), )); @@ -2490,8 +2448,7 @@ impl Store { .cloned() .collect::>() }; - let (pa, pb, pc) = (runs_of(&replayed), runs_of(&self.ledger), runs_of(&c)); - if pa != pb || pa != pc { + if runs_of(&replayed) != runs_of(&c) { return Err(StoreError::VerifyMismatch( "payroll runs diverged between journal, live ledger, and sqlite projection".into(), )); @@ -3075,14 +3032,6 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> { /// Replay one journal line into a ledger, asserting recorded ids. Used by /// hydration, `verify`, and `rebuild`: the journal is the script, the engine /// is the actor. -/// [`Store::ensure_control_account`] on a bare ledger, for rehearsals. -fn ensure_on(l: &mut Ledger, c: &ledger_core::ControlAccount) -> Result { - match l.chart().find_by_code(c.code) { - Some(a) => Ok(a.id), - None => l.add_account(c.code, c.name, c.account_type, None, false), - } -} - pub(crate) fn replay_line_into_ledger( ledger: &mut Ledger, line: &JournalLine, diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index f0239f3..0378a38 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -2046,3 +2046,201 @@ fn a_database_with_the_wrong_currency_is_rebuilt() { .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| rows.iter().map(|r| r.id).collect::>(); + 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 follows its payments: amount paid and +/// open / partial / paid, matching the ledger. +#[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), + }], + ) + .unwrap(); + let row = |dir: &std::path::Path| -> (String, String) { + let conn = rusqlite::Connection::open(dir.join(ledger_store::DB_FILE)).unwrap(); + conn.query_row( + "SELECT amount_paid, status FROM invoices WHERE id = ?", + [inv as i64], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap() + }; + assert_eq!(row(&dir).1, "open"); + store + .receive_payment(inv, day(10), Decimal::from(40), cash) + .unwrap(); + assert_eq!(row(&dir), ("40".into(), "partial".into())); + store + .receive_payment(inv, day(20), Decimal::from(60), cash) + .unwrap(); + assert_eq!(row(&dir), ("100".into(), "paid".into())); + 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)) { + 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}"); +}