From 3a1f4fd4eb27ab4ea1f6448a5064850c054fe353 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 08:23:51 -0600 Subject: [PATCH] test(store): a write-off whose notice is another entry's receipt is refused before posting The defect found merging S7 and P6: under P6's dedup the notice's bytes resolve to the record already on file, and the one-receipt-one- entry rule refused it only after the write-off had journaled its entry. The store now checks the deduplicated record before the journal line (`6c26160e`'s companion fix in `write_off_invoice`); this test pins it: the refusal names the record and its entry, the head and the journal are unchanged, no entry posts, the invoice stays open, and the books verify. Also formats the B-27 test `ci-fixes` brought in. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-store/tests/arap_standing.rs | 56 ++++++++++++++++++++++ crates/ledger-store/tests/format.rs | 3 +- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index 461ee53..5c7ac97 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -1468,6 +1468,62 @@ fn collection_letter() -> &'static [u8] { b"%PDF-1.4\nFinal demand, unpaid\n%%EOF\n" } +/// A notice whose bytes are already another entry's receipt is that one +/// record (plan P6 step 4), and the one-receipt-one-entry rule refuses it +/// before the write-off is journaled: no entry, no link, no journal line +/// (found merging S7 and P6, 2026-10-28: the refusal came after the entry +/// had posted). +#[test] +fn a_write_off_whose_notice_is_anothers_receipt_is_refused_before_posting() { + let dir = fixture("write-off-dedup"); + let mut s = Store::open(&dir.0).unwrap(); + s.upgrade_format().unwrap(); + // The letter's bytes, filed first as a receipt on an unrelated entry. + let other = s.ledger().posted_entries().next().unwrap().id(); + let (letter, _, dedup) = s + .attach_receipt( + Some(other), + "letter.pdf", + "application/pdf", + collection_letter(), + None, + ) + .unwrap(); + assert!(!dedup); + let head = s.head(); + let len = journal_len(&dir.0); + let next_entry = s.ledger().next_entry_id(); + match s.write_off_invoice( + 1, + d(2026, 2, 1), + cents(10_000), + "uncollectible", + Some(WriteOffEvidence::File { + file_name: "collection-letter.pdf", + mime_type: "application/pdf", + bytes: collection_letter(), + description: Some("Collection attempts"), + }), + ) { + Err(StoreError::AttachmentLinked(a, e)) => { + assert_eq!(a, letter); + assert_eq!(e, other); + } + other => panic!("expected AttachmentLinked, got {other:?}"), + } + assert_eq!(s.head(), head, "nothing journaled"); + assert_eq!(journal_len(&dir.0), len); + assert_eq!(s.ledger().next_entry_id(), next_entry, "no entry posted"); + let st = s + .ledger() + .invoice_state(1, d(2026, 12, 31)) + .unwrap() + .unwrap(); + assert_eq!(st.status, ledger_core::DocStatus::Open); + assert!(s.attachments_for(Subject::Invoice(1)).unwrap().is_empty()); + s.verify().unwrap(); +} + /// A write-off needs journal feature documents_v2 (its event type is new; /// an older version refuses the unknown type): refused, journaling /// nothing, until the owner's upgrade; then it is taken and replays to the diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 66edee7..d0abea8 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -1574,7 +1574,8 @@ fn an_unknown_convention_kind_is_newer_for_the_writer_and_the_viewer_alike() { } let store = ledger_store::Store::open(dir); let snap = ledger_store::Snapshot::load(dir); - let newer = |e: &ledger_store::StoreError| matches!(e, ledger_store::StoreError::NewerJournal { .. }); + let newer = + |e: &ledger_store::StoreError| matches!(e, ledger_store::StoreError::NewerJournal { .. }); match (store, snap) { (Err(a), Err(b)) => assert!( newer(&a) && newer(&b),