store: second mutation pass (345/373); trim what rehearsal covers; tests
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
d67e897f1b
commit
7559b4c808
2 files changed
+209
-62
No files matched your search
@@ -1344,11 +1344,7 @@ impl Store {
|
||||
due_date: NaiveDate,
|
||||
lines: Vec<DocumentLine>,
|
||||
) -> Result<InvoiceId, StoreError> {
|
||||
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<DocumentLine>,
|
||||
) -> Result<BillId, StoreError> {
|
||||
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<EntryId, StoreError> {
|
||||
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<EntryId, StoreError> {
|
||||
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<PayrollLineInput>,
|
||||
cash_account: AccountId,
|
||||
) -> Result<PayrollRunId, StoreError> {
|
||||
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<ClassId>,
|
||||
) -> 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
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<AccountId, LedgerError> {
|
||||
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,
|
||||
|
||||
@@ -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<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 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<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}");
|
||||
}
|
||||
Reference in new issue
Block a user