wip: pause checkpoint — P6 feature complete, gate interrupted (not for merge)

This commit is contained in:
swaits committed 2026-10-27 14:41:34 -06:00
1 parent cc4f104f06
commit ce36640874
68 files changed
+4592 -374

No files matched your search

+316
View File
@@ -1452,3 +1452,319 @@ fn a_file_linked_to_an_entry_cannot_also_be_filed_on_it() {
s.link_receipt(a, fx.e3).unwrap();
s.verify().unwrap();
}
// ---------------------------------------------------------------- evidence (P6)
use ledger_store::format::{ATTACHMENT_TEXT, JournalFormat, current};
use ledger_store::{BlobStore, ExtractionStatus, JournalWriter};
/// Plan P6 step 2: reading a file's text is journaled once per extractor
/// and version; the text is a content-addressed blob; `verify` checks it.
#[test]
fn extraction_is_journaled_once_per_version() {
let dir = test_dir("extract-once");
let (mut s, fx) = fixture(&dir);
let text = b"Zelle payment to Harbor Design, 12.34, 2026-02-10\n";
let (id, _, _) = s
.attach_receipt(Some(fx.e2), "zelle.txt", "text/plain", text, None)
.unwrap();
let x = s.extraction(id).unwrap().expect("an extraction record");
assert_eq!(x.status, ExtractionStatus::Ok);
assert_eq!(x.pages, 1);
assert_eq!(x.extractor, ledger_import::extract::EXTRACTOR);
assert_eq!(x.version, ledger_import::extract::VERSION);
assert_eq!(
s.extraction_text(id).unwrap().as_deref(),
Some(String::from_utf8_lossy(text).as_ref())
);
// One record: journaled once, and the text is a stored blob.
let records = s
.extractions()
.unwrap()
.into_iter()
.filter(|x| x.attachment_id == id)
.count();
assert_eq!(records, 1);
let sha = x.text_sha256.expect("text");
assert!(s.stored_blob_shas().unwrap().contains(&sha));
let seq = s.journal_seq();
let again = s.extract_attachment(id).unwrap();
assert_eq!(again.seq, x.seq, "the same record");
assert_eq!(s.journal_seq(), seq, "a repeat journals nothing");
s.verify().unwrap();
// And it survives a reopen: the record and the text are the journal's.
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(
s.extraction_text(id).unwrap().as_deref(),
Some("Zelle payment to Harbor Design, 12.34, 2026-02-10\n")
);
assert_eq!(s.extractions().unwrap().len(), 1);
s.verify().unwrap();
}
/// Plan P6 step 2, R-A: reading a file's text waits for the books to
/// declare `attachment_text`, journaling nothing until then; attaching a
/// file works on old books (the extraction is the backfill's job).
#[test]
fn extract_waits_for_attachment_text() {
let dir = test_dir("extract-upgrade");
let mut s = Store::create_in_format(&dir, Currency::USD, &JournalFormat::default()).unwrap();
let (id, _, _) = s
.attach_receipt(None, "note.txt", "text/plain", b"a note", None)
.unwrap();
assert!(s.extractions().unwrap().is_empty(), "no feature, no record");
let head = journal_bytes(&dir);
match s.extract_attachment(id) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, format!("the journal feature {ATTACHMENT_TEXT}"))
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(journal_bytes(&dir), head, "a refusal journals nothing");
s.upgrade_format().unwrap();
assert_eq!(s.journal_format(), &current());
let x = s.extract_attachment(id).unwrap();
assert_eq!(x.status, ExtractionStatus::Ok);
assert_eq!(s.extraction_text(id).unwrap().as_deref(), Some("a note"));
// The books replay as written, live and in the projection.
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.extraction_text(id).unwrap().as_deref(), Some("a note"));
s.verify().unwrap();
}
// Plan P6 step 4: identical bytes are one live attachment, linked to
// every subject they support (property: for any number of filings).
proptest::proptest! {
// Each case makes books on disk (fsync per line): eight is a property
// over the count, not a bulk run.
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(8))]
#[test]
fn same_bytes_one_record_many_links(n in 1usize..=4) {
let dir = test_dir(&format!("dedup-{n}"));
let (mut s, fx) = fixture(&dir);
let entries = [fx.e1, fx.e2, fx.e3, fx.e4];
let bytes = b"one receipt, four takers";
let mut ids = Vec::new();
for e in entries.iter().take(n) {
let (id, sha, dedup) = s
.attach_receipt(Some(*e), "r.txt", "text/plain", bytes, None)
.unwrap();
proptest::prop_assert_eq!(
sha,
ledger_store::journal::sha256_hex(bytes)
);
proptest::prop_assert!(dedup || ids.is_empty());
ids.push(id);
}
let all: Vec<Attachment> = s.list_attachments(None).unwrap();
proptest::prop_assert_eq!(all.len(), 1, "one record");
proptest::prop_assert!(ids.iter().all(|id| *id == all[0].id));
let links = s.attachment_links(all[0].id).unwrap();
proptest::prop_assert_eq!(links.len(), n);
proptest::prop_assert_eq!(
s.extractions().unwrap().len(),
1,
"extraction once"
);
s.verify().unwrap();
}
}
/// Plan P6 step 4: records an older version filed twice open as written;
/// withdrawing one as a duplicate records what it duplicates.
#[test]
fn legacy_duplicates_replay_as_written() {
let dir = test_dir("legacy-dupes");
let s = Store::create(&dir, Currency::USD).unwrap();
drop(s);
let bytes = b"filed twice by an older version";
let (sha, _) = BlobStore::open(&dir.join("blobs"))
.unwrap()
.put(bytes)
.unwrap();
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] {
w.append(
"receipt.attached",
json!({
"attachment_id": id, "blob_sha": sha, "entry_id": null,
"category": "receipts", "file_name": name,
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
"description": null,
}),
)
.unwrap();
}
drop(w);
let mut s = Store::open(&dir).unwrap();
let all = s.list_attachments(None).unwrap();
assert_eq!(all.len(), 2, "both, as written");
assert_eq!(all[0].blob_sha, all[1].blob_sha);
assert!(all.iter().all(|a| a.duplicate_of.is_none()));
s.withdraw_attachment_duplicate(2, "duplicate of #1", 1)
.unwrap();
assert_eq!(
s.attachment_by_id(2).unwrap().unwrap().duplicate_of,
Some(1)
);
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
let again = s.attachment_by_id(2).unwrap().unwrap();
assert_eq!(again.duplicate_of, Some(1));
assert!(again.withdrawn_at.is_some());
s.verify().unwrap();
}
/// Plan P6 step 5: annotations replay and project, replacing an earlier
/// one, and the engine's next receipt number runs from the highest.
#[test]
fn annotations_replay_and_project() {
let dir = test_dir("annotations");
let (mut s, fx) = fixture(&dir);
let (id, _, _) = s
.attach_receipt(Some(fx.e2), "r.pdf", "application/pdf", b"%PDF r", None)
.unwrap();
let first = ledger_core::Annotation {
attachment_id: id,
fields: ledger_core::AnnotationFields {
receipt_number: Some(4),
payee: Some("Harbor Design".into()),
amount: Some(dec("12.34")),
date: Some(date(2026, 2, 1)),
instrument: Some("Check 1234".into()),
category: None,
source: Some("the owner's email".into()),
reimbursable_to: Some("Avery Quill".into()),
},
note: Some("read on 2026-02-10".into()),
};
s.annotate_attachment(first.clone()).unwrap();
assert_eq!(s.ledger().annotation(id), Some(&first), "the live ledger");
assert_eq!(s.annotations().unwrap(), vec![first.clone()]);
assert_eq!(s.ledger().next_receipt_number(), 5);
// A later annotation replaces it (one projection row, one ledger map
// entry).
let second = ledger_core::Annotation {
attachment_id: id,
fields: ledger_core::AnnotationFields {
receipt_number: Some(6),
payee: Some("Harbor Design LLC".into()),
..Default::default()
},
note: None,
};
s.annotate_attachment(second.clone()).unwrap();
assert_eq!(s.annotations().unwrap(), vec![second.clone()]);
assert_eq!(s.ledger().next_receipt_number(), 7);
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(s.ledger().annotation(id), Some(&second));
assert_eq!(s.annotations().unwrap(), vec![second]);
s.verify().unwrap();
}
/// Plan P6 step 5: receipt numbers run from the maximum annotated.
#[test]
fn receipt_numbers_run_from_the_max() {
let dir = test_dir("receipt-numbers");
let (mut s, fx) = fixture(&dir);
let price = |n: u32| ledger_core::Annotation {
attachment_id: 0,
fields: ledger_core::AnnotationFields {
receipt_number: Some(n),
..Default::default()
},
note: None,
};
assert_eq!(s.ledger().next_receipt_number(), 1, "nothing annotated");
let ids: Vec<u64> = (0..3)
.map(|i| {
s.attach_receipt(
Some([fx.e1, fx.e2, fx.e3][i]),
&format!("r{i}.txt"),
"text/plain",
format!("receipt {i}").as_bytes(),
None,
)
.unwrap()
.0
})
.collect();
for (id, (n, next)) in ids.iter().zip([(3, 4), (9, 10), (7, 10)]) {
let mut a = price(n);
a.attachment_id = *id;
s.annotate_attachment(a).unwrap();
assert_eq!(s.ledger().next_receipt_number(), next, "{n}");
}
assert_eq!(s.ledger().next_receipt_number(), 10);
s.verify().unwrap();
}
/// Plan P6 step 5: "reimbursed" is derived from `entry.settles` only;
/// nothing about a member-paid entry's shape makes it paid back.
#[test]
fn reimbursed_is_derived_from_settles_only() {
let dir = test_dir("settles");
let mut s = Store::create(&dir, Currency::USD).unwrap();
let cash = s
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let due = s
.add_account(
"2100",
"Due to Members",
AccountType::Liability,
None,
false,
)
.unwrap();
let expense = s
.add_account("6000", "Hosting", AccountType::Expense, None, false)
.unwrap();
let paid = post(
&mut s,
date(2026, 2, 1),
"Hosting, paid by a member",
expense,
due,
"20.00",
);
let reimb = post(
&mut s,
date(2026, 2, 10),
"Reimburse the member",
due,
cash,
"20.00",
);
assert!(!s.ledger().settles_entry(paid), "no record yet");
assert!(s.ledger().entry_settles(reimb).is_empty());
s.set_entry_settles(reimb, &[paid]).unwrap();
assert!(s.ledger().settles_entry(paid));
assert_eq!(s.ledger().entry_settles(reimb), [paid]);
// Refusals journal nothing and change no record.
let head = journal_bytes(&dir);
for (entry, settles) in [
(paid, vec![paid]),
(reimb, vec![paid, paid]),
(reimb, vec![999]),
] {
assert!(
s.set_entry_settles(entry, &settles).is_err(),
"{entry} {settles:?}"
);
}
assert_eq!(journal_bytes(&dir), head, "refusals journal nothing");
s.verify().unwrap();
drop(s);
let s = Store::open(&dir).unwrap();
assert!(s.ledger().settles_entry(paid));
assert_eq!(s.ledger().entry_settles(reimb), [paid]);
s.verify().unwrap();
}
@@ -0,0 +1,3 @@
# Records
Kept with the company's papers.
@@ -0,0 +1 @@
Zelle payment to A. Quill, 20.00, 2026-02-10
@@ -0,0 +1,187 @@
{
"assets": [],
"bills": [],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-01",
"description": "Web hosting, paid by a member",
"id": 2,
"lines": [
{
"account": 4,
"amount": {
"amount": "20.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "20.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-10",
"description": "Reimburse the member",
"id": 3,
"lines": [
{
"account": 2,
"amount": {
"amount": "20.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "20.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-03-01",
"description": "March rent",
"id": 4,
"lines": [
{
"account": 4,
"amount": {
"amount": "100.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "100.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [],
"invoices": [],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "380",
"balanced": true,
"current_earnings": "-120",
"equity": "380",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "120",
"net_income": "-120",
"revenue": "0"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "380",
"name": "Cash"
},
{
"code": "3000",
"credit": "500",
"debit": "0",
"name": "Capital"
},
{
"code": "6000",
"credit": "0",
"debit": "120",
"name": "Rent"
}
],
"total_credits": "500",
"total_debits": "500"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,25 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T15:18:09Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"7f124ed3e91db7042661abf4e5007b1dcae9903a767bea76e7e727a96075bfa6","seq":2,"ts":"2026-10-27T15:18:09Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Cash","parent":null,"placeholder":false},"prev":"955f321d1a0bbd460640ba5339b2d3e1313038f3ba615e5ffff223ba3c8c728f","seq":3,"ts":"2026-10-27T15:18:09Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2100","currency":"USD","id":2,"name":"Due to Members","parent":null,"placeholder":false},"prev":"7b619dff5f0fb25b516b96b459b39ef67713bef25689946837a28f58e7692db5","seq":4,"ts":"2026-10-27T15:18:09Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":3,"name":"Capital","parent":null,"placeholder":false},"prev":"d55d7fb14bbfbd1aa43f067617901d7086ecbfa16a693f0aa651b98ba525e4f8","seq":5,"ts":"2026-10-27T15:18:09Z","type":"account.added"}
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"6cd464539b1b1aa60ae1093728da6a48658cc7bf3f962c88a21c863b85b17523","seq":6,"ts":"2026-10-27T15:18:09Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"ed316ff3ac6c630502dd56101b39b53fac83e4554fd78e271c91d0d468ae6c93","seq":7,"ts":"2026-10-27T15:18:09Z","type":"entry.created"}
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":1,"side":"debit"},"prev":"cf19ca8e1f8992a73c249c3d5e06997b681799a19d39261082d66ecee54a7c78","seq":8,"ts":"2026-10-27T15:18:09Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"500.00","currency":"USD","id":1,"side":"credit"},"prev":"31d98c34346a4ca836f1b7a51e993a7c8b682eaab36f061d92e8659eafcf3691","seq":9,"ts":"2026-10-27T15:18:09Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"500.00","currency":"USD","side":"debit"},{"account":3,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1589ea4608834aab2019ebd960438eed2ebd843abef29fbcf2174f65b88a34e8","seq":10,"ts":"2026-10-27T15:18:09Z","type":"entry.posted"}
{"payload":{"date":"2026-02-01","description":"Web hosting, paid by a member","id":2},"prev":"ea67596a958a0c100bc81e4d4b7058c3145803ea765a3989d3da58eef343c775","seq":11,"ts":"2026-10-27T15:18:09Z","type":"entry.created"}
{"payload":{"account":4,"amount":"20.00","currency":"USD","id":2,"side":"debit"},"prev":"1e78ebd8c378a723511d3e054f216c02587d9c0795a2958b64a7ef681405301f","seq":12,"ts":"2026-10-27T15:18:09Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"20.00","currency":"USD","id":2,"side":"credit"},"prev":"06d014884d44765a38cb4ca9220cef0b913ef8a8b561997fdea7d59c0094dc0e","seq":13,"ts":"2026-10-27T15:18:09Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-01","description":"Web hosting, paid by a member","id":2,"lines":[{"account":4,"amount":"20.00","currency":"USD","side":"debit"},{"account":2,"amount":"20.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1863ad9b2fb040fd24c1398b7ec995f083153fbacc5e252e88bcd98541e3c33a","seq":14,"ts":"2026-10-27T15:18:10Z","type":"entry.posted"}
{"payload":{"date":"2026-02-10","description":"Reimburse the member","id":3},"prev":"6e7ec8d7738b85d7e79548f22eb6e314a370a31757c1651a67fa752b7adc4651","seq":15,"ts":"2026-10-27T15:18:10Z","type":"entry.created"}
{"payload":{"account":2,"amount":"20.00","currency":"USD","id":3,"side":"debit"},"prev":"a26929387cbba06f0e89c9b1dd4118320f22be51d8ee499ee398e411f16b890f","seq":16,"ts":"2026-10-27T15:18:10Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"20.00","currency":"USD","id":3,"side":"credit"},"prev":"b2e9405890eb89ae0e5afd17d3e5cf741e330960f3f01449bd7a82c2e1dea440","seq":17,"ts":"2026-10-27T15:18:10Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-10","description":"Reimburse the member","id":3,"lines":[{"account":2,"amount":"20.00","currency":"USD","side":"debit"},{"account":1,"amount":"20.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"94f2dbba114035ebe433c985c6b4f5ace26bd4588903fff15bef8717ab82bc77","seq":18,"ts":"2026-10-27T15:18:10Z","type":"entry.posted"}
{"payload":{"date":"2026-03-01","description":"March rent","id":4},"prev":"b1c37b5614c19fdbe10e16ded9072b31bc0f2576cd6419a86c14d258871ba6dc","seq":19,"ts":"2026-10-27T15:18:10Z","type":"entry.created"}
{"payload":{"account":4,"amount":"100.00","currency":"USD","id":4,"side":"debit"},"prev":"66f50f061925b1be9ad46a015ab7359729324b58419ee97abdc2e430c19cd335","seq":20,"ts":"2026-10-27T15:18:10Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"100.00","currency":"USD","id":4,"side":"credit"},"prev":"1e5e7bd1e14131c980d43b5da0fb6a21673dd3f7120dfe87873cd7e88eb49340","seq":21,"ts":"2026-10-27T15:18:10Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-01","description":"March rent","id":4,"lines":[{"account":4,"amount":"100.00","currency":"USD","side":"debit"},{"account":1,"amount":"100.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c3ee5fc9f8cf08b452969df15921f6bcaf61514c608971b6180e8b89bdbabfbb","seq":22,"ts":"2026-10-27T15:18:10Z","type":"entry.posted"}
{"payload":{"attachment_id":1,"blob_sha":"5837c77f55b72825a8a6be69e2aecc3e962fba90277e12547ae2d6b7f1ad0435","category":"receipts","description":"March rent invoice","entry_id":4,"file_name":"receipt.pdf","mime_type":"application/pdf","size_bytes":20748},"prev":"4820680a8b8f00fbfee12dcdb515f6c7232fe002b17f6a0d875cbc7b37f9301c","seq":23,"ts":"2026-10-27T15:18:10Z","type":"receipt.attached"}
{"payload":{"attachment_id":2,"blob_sha":"2d6666a14d257ebe2f29c5a10380afaafed93efee2b80d79af3d3d53c10af699","category":"receipts","description":null,"entry_id":3,"file_name":"zelle.txt","mime_type":"text/plain","size_bytes":45},"prev":"06043343802dce570e36d0502cc359bc05dd00f5ccf0831f34c516648ee0c9dc","seq":24,"ts":"2026-10-27T15:18:10Z","type":"receipt.attached"}
{"payload":{"attachment_id":3,"blob_sha":"1697fa59fa7f1f7e6d4e3b53940e9208596242b8451ddb01bef8b00b71040c2b","category":"other","description":null,"entry_id":null,"file_name":"note.md","mime_type":"text/markdown","size_bytes":43},"prev":"9cad59cd4384dc0769bc925032cad7366c814e827d094795404b9b6d9e5ce861","seq":25,"ts":"2026-10-27T15:18:10Z","type":"document.filed"}
+43 -34
View File
@@ -940,11 +940,14 @@ fn attach_stores_under_sha256() {
assert_eq!(b.store.verify_blobs().unwrap(), 1);
}
/// Oracle: Store::attach_receipt INVARIANTS 2 and 3 — the same bytes filed
/// twice share one stored file, the second says deduplicated, and each
/// filing is its own journaled attachment with its name, type, description.
/// Oracle: Store::attach_receipt INVARIANTS 2 and 3, as plan P6 step 4
/// changes them — the same bytes filed twice share one stored file and one
/// live attachment, the second says deduplicated, and that attachment is
/// linked to the second subject (its name, type and description are the
/// first filing's). Records an older version filed twice stay as written
/// (`legacy_duplicates_replay_as_written`).
#[test]
fn attach_dedups_bytes_but_not_attachments() {
fn same_bytes_are_one_attachment_linked_to_both() {
let dir = test_dir("attach-dedup");
let mut b = books(&dir);
let bytes = b"same bytes";
@@ -962,52 +965,61 @@ fn attach_dedups_bytes_but_not_attachments() {
.store
.attach_receipt(Some(b.e2), "two.png", "image/png", bytes, Some("second"))
.unwrap();
assert_eq!((a1, a2), (a1, a1), "one attachment");
assert_eq!(s1, s2);
assert!(!d1);
assert!(d2);
assert_ne!(a1, a2);
let blob_files: Vec<_> = tree(&dir)
.into_keys()
.filter(|p| p.starts_with("blobs"))
.collect();
assert_eq!(blob_files.len(), 1, "one stored file: {blob_files:?}");
let all = b.store.list_attachments(None).unwrap();
let one = all.iter().find(|a| a.id == a1).expect("first listed");
let two = all.iter().find(|a| a.id == a2).expect("second listed");
assert_eq!(all.len(), 1, "one record: {all:?}");
let one = all.iter().find(|a| a.id == a1).expect("listed");
assert_eq!(one.entry_id, Some(b.e1));
assert_eq!(two.entry_id, Some(b.e2));
assert_eq!(
(one.file_name.as_str(), one.mime_type.as_str()),
("one.pdf", "application/pdf")
);
assert_eq!(one.description.as_deref(), Some("first"));
assert_eq!(two.description.as_deref(), Some("second"));
assert_eq!(one.size_bytes, bytes.len() as u64);
assert_eq!(one.blob_sha, s1);
// The second subject is linked, and counts as its receipt.
assert!(
b.store
.attachment_links(a1)
.unwrap()
.contains(&ledger_store::Subject::Entry(b.e2))
);
assert_eq!(
b.store
.attachments_for(ledger_store::Subject::Entry(b.e2))
.unwrap()
.len(),
1
);
// One filing line: the second added a link, not a record.
let attached: Vec<Value> = parsed_lines(&dir)
.into_iter()
.filter(|l| l["type"] == "receipt.attached")
.collect();
assert_eq!(attached.len(), 2);
for (l, (name, mime, desc)) in attached.iter().zip([
("one.pdf", "application/pdf", "first"),
("two.png", "image/png", "second"),
]) {
for needle in [name, mime, desc, s1.as_str()] {
assert!(
contains_value(&l["payload"], needle),
"{needle} missing from {l}"
);
}
assert_eq!(attached.len(), 1);
for needle in ["one.pdf", "application/pdf", "first", s1.as_str()] {
assert!(
contains_value(&attached[0]["payload"], needle),
"{needle} missing from {}",
attached[0]
);
}
assert_eq!(b.store.attachment_bytes(a2).unwrap(), bytes);
assert_eq!(b.store.attachment_bytes(a1).unwrap(), bytes);
}
/// Oracle: Store::attach_receipt INVARIANT 3 — each filing is its own
/// attachment with ITS name and type, even when its bytes deduplicate
/// against an earlier filing.
/// Oracle: Store::attach_receipt INVARIANT 3, as plan P6 step 4 changes
/// it — one file, one record: the first filing's own name and type stay,
/// in the writer and in the viewer.
#[test]
fn attach_dedup_keeps_each_filings_name_and_type() {
fn the_first_filings_name_and_type_stay() {
let dir = test_dir("attach-dedup-name");
let mut b = books(&dir);
let bytes = b"same bytes, two names";
@@ -1019,28 +1031,25 @@ fn attach_dedup_keeps_each_filings_name_and_type() {
.store
.attach_receipt(Some(b.e2), "two.png", "image/png", bytes, None)
.unwrap();
assert_eq!((a1, a2), (a1, a1));
assert!(d2);
let all = b.store.list_attachments(None).unwrap();
assert_eq!(all.len(), 1);
let one = all.iter().find(|a| a.id == a1).unwrap();
let two = all.iter().find(|a| a.id == a2).unwrap();
assert_eq!(
(one.file_name.as_str(), one.mime_type.as_str()),
("one.pdf", "application/pdf")
);
assert_eq!(
(two.file_name.as_str(), two.mime_type.as_str()),
("two.png", "image/png")
);
let snap = Snapshot::load(&dir).unwrap();
let s2 = snap
let s1 = snap
.attachments(None)
.unwrap()
.into_iter()
.find(|a| a.id == a2)
.find(|a| a.id == a1)
.unwrap();
assert_eq!(
(s2.file_name.as_str(), s2.mime_type.as_str()),
("two.png", "image/png")
(s1.file_name.as_str(), s1.mime_type.as_str()),
("one.pdf", "application/pdf")
);
}
+55
View File
@@ -1855,6 +1855,46 @@ fn books_written_under_older_rules_still_open() {
"source": {"decided_by": "Owner", "decided_on": "2026-10-26"}}),
)
.unwrap();
// P6's new shapes, hand-written as a later version writes them: a file's
// extraction record, an annotation of it, and a settlement. This reader
// takes them; the version before it stopped at the declaration.
w.append(
"attachment.text_extracted",
json!({"attachment_id": 5, "text_sha256": null, "extractor": "wanderbooks",
"version": "1", "pages": 0, "status": "none"}),
)
.unwrap();
w.append(
"attachment.annotated",
json!({"attachment_id": 5, "fields": {"receipt_number": 3, "payee": "Acme"},
"note": "read by hand"}),
)
.unwrap();
w.append(
"entry.created",
json!({"id": 2, "date": "2026-03-02", "description": "Reimburse"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 2, "account": 2, "side": "debit", "amount": "5.00", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.line_added",
json!({"id": 2, "account": 2, "side": "credit", "amount": "5.00", "currency": "USD"}),
)
.unwrap();
w.append(
"entry.posted",
json!({"id": 2, "date": "2026-03-02", "description": "Reimburse",
"lines": [{"account": 2, "side": "debit", "amount": "5.00", "currency": "USD"},
{"account": 2, "side": "credit", "amount": "5.00", "currency": "USD"}],
"reversal_of": null}),
)
.unwrap();
w.append("entry.settles", json!({"entry_id": 2, "settles": [1]}))
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
@@ -1871,6 +1911,21 @@ fn books_written_under_older_rules_still_open() {
"108.34567".parse::<Decimal>().unwrap()
);
// The new shapes replay as written: the party's facts and the setting.
// P6's shapes replayed: the extraction, the annotation and the
// settlement.
let extractions = store.extractions().unwrap();
assert_eq!(extractions.len(), 1);
assert_eq!(extractions[0].attachment_id, 5);
assert_eq!(extractions[0].status, ledger_store::ExtractionStatus::None);
let annotation = store
.ledger()
.annotation(5)
.expect("the annotation, replayed");
assert_eq!(annotation.fields.receipt_number, Some(3));
assert_eq!(annotation.fields.payee.as_deref(), Some("Acme"));
assert_eq!(store.ledger().next_receipt_number(), 4);
assert_eq!(store.ledger().entry_settles(2), [1]);
assert!(store.ledger().settles_entry(1));
let acme = store.ledger().get_party(1).expect("the party");
assert_eq!(acme.email.as_deref(), Some("billing@acme.example"));
assert_eq!(