From 4826a39a88bd43692e8eff752200f8f2d10a62a3 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 15 Oct 2026 13:51:17 -0600 Subject: [PATCH] fix: books with an asset linked to a since-reversed entry failed to open (the projection read-back re-checked the link against today's books); links restore as recorded, Ledger::acquisition_reversed reports ones to relink; regression test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- crates/ledger-core/src/ledger.rs | 30 +++++++++++++++++++++++ crates/ledger-store/src/store.rs | 2 +- crates/ledger-store/tests/asset_tags.rs | 32 +++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index f9d7ab6..9eee828 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -1585,6 +1585,36 @@ impl Ledger { Ok(()) } + /// Restore a link recorded earlier (a projection read-back): it was + /// checked when made, and a later correction (reversing that entry) + /// doesn't undo it; it only becomes a link to fix, by linking the + /// replacement. Checks only that the asset and entry exist. (Re-checking + /// against today's books refused to open them once a linked entry was + /// reversed, 2026-10-15.) + pub fn restore_asset_acquisition( + &mut self, + id: AssetId, + entry: EntryId, + ) -> Result<(), LedgerError> { + if !self.entries.contains_key(&entry) { + return Err(LedgerError::UnknownEntry(entry)); + } + self.assets + .get_mut(&id) + .ok_or(LedgerError::UnknownAsset(id))? + .acquired_by = Some(entry); + Ok(()) + } + + /// Whether an asset's acquisition link points at an entry that has + /// since been reversed (to relink the replacement). + pub fn acquisition_reversed(&self, id: AssetId) -> bool { + self.assets + .get(&id) + .and_then(|a| a.acquired_by) + .is_some_and(|e| self.entries.values().any(|x| x.reversal_of() == Some(e))) + } + /// The validation behind [`Self::set_asset_acquisition`]. pub fn check_asset_acquisition(&self, id: AssetId, entry: EntryId) -> Result<(), LedgerError> { let asset = self.assets.get(&id).ok_or(LedgerError::UnknownAsset(id))?; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 5e123a4..a146214 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -2981,7 +2981,7 @@ impl Store { let links = qstmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; for row in links { let (id, entry) = row?; - ledger.set_asset_acquisition(id as u64, entry as u64)?; + ledger.restore_asset_acquisition(id as u64, entry as u64)?; } // Payroll runs: id order; lines in insertion order. Amounts are // TEXT in SQLite, so they parse here with exact Decimal math. diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index 24e9937..929b99c 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -798,3 +798,35 @@ fn an_asset_is_corrected_until_depreciation_is_posted() { Err(StoreError::AssetDepreciationPosted(_)) )); } + +/// Regression (2026-10-15): reversing an asset's linked acquisition entry +/// (a correction) must not stop the books from opening. The link was valid +/// when made; the books open and verify, and the reversed link is reported +/// for relinking. +#[test] +fn reversing_a_linked_acquisition_still_opens_and_verifies() { + let (dir, mut s, a, x, _) = fixture("relink"); + let post = |s: &mut Store| { + let e = s.create_entry(date(2026, 1, 1), "contributed").unwrap(); + s.add_line(e, a.cost, ledger_core::Side::Debit, usd("1200.00")) + .unwrap(); + s.add_line(e, a.bank, ledger_core::Side::Credit, usd("1200.00")) + .unwrap(); + s.post_entry(e).unwrap(); + e + }; + let first = post(&mut s); + s.set_asset_acquisition(x, first).unwrap(); + s.reverse_entry(first, date(2026, 1, 2), "corrected") + .unwrap(); + assert!(s.ledger().acquisition_reversed(x)); + s.verify().unwrap(); + drop(s); + let mut s = Store::open(&dir).expect("books with a reversed link open"); + s.verify().unwrap(); + let second = post(&mut s); + s.set_asset_acquisition(x, second).unwrap(); + assert!(!s.ledger().acquisition_reversed(x)); + drop(s); + Store::open(&dir).unwrap().verify().unwrap(); +}