feat: receipt unlink and attachment withdrawal
Posted entries stay immutable (R-3), but their evidence can now change the
same way the ledger does: by appending events, never by editing or
deleting. New R-13a.
- books.unlink_receipt {attachment_id, reason} journals receipt.unlinked
and leaves the receipt filed, unlinked. The receipt policy holds: an
entry that requires a receipt can't lose its last one (structured
receipt_required error), so a replacement is attach-new, then
unlink-old. Drafts and reversals are exempt, as at posting.
- books.withdraw_attachment {attachment_id, reason} journals
attachment.withdrawn for an unlinked receipt or a filed document: hidden
from list_receipts (unless include_withdrawn), never linkable again. The
blob stays in the store, hash-verified and readable.
- link_receipt now refuses an already-linked or withdrawn attachment.
Before, re-linking silently moved a receipt off a posted entry,
bypassing the policy; evidence now leaves an entry only through a
recorded unlink. dispose_asset checks its receipt ids before posting
instead of after.
- Schema v5 adds attachments.withdrawn_at/withdrawn_reason; existing
stores migrate on open and replay rebuilds the projection.
Tests: store test (errors, reopen, rebuild, verify, journal history) and
an MCP stdio test (policy refusal, replace flow, withdraw, listings,
under-threshold unlink). Dry-run and live use on the Wanderland Labs books:
8 migrated .eml attachments swapped for their PDF renders, trial balance
unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
1 parent
3bfea47a59
commit
e59e8001c6
13 files changed
+580
-21
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::{JournalWriter, Store};
|
||||
use ledger_store::{JournalWriter, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -347,3 +347,113 @@ fn corrupt_projection_is_quarantined_and_rebuilt() {
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user