//! Spec-derived tests for attachment links, statements, and //! reconciliations in the store. //! //! Oracles: the doc comments on `Store::link_attachment`, //! `unlink_attachment`, `attachment_links`, `attachments_for`, //! `record_statement`, `statement`, `statements`, `complete_reconciliation`, //! `void_reconciliation`, `reconciliations`, `withdraw_attachment`; the //! `Subject`, `Statement`, `Reconciliation` types; the "Attachments" and //! "Reconciliations" sections of docs/parties-and-attachments.md; //! `Store::verify` (a fresh replay equals the live state); and the store //! errors (UnknownSubject, AlreadyLinked, NotLinkedTo, InvalidStatement, //! NotReconciled{difference}, ReconciliationRefused). Expected values are //! worked by hand from those texts, not from the method bodies. use chrono::NaiveDate; use ledger_core::arap::DocumentLine; use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side}; use ledger_store::journal::read_and_verify_journal; use ledger_store::store::{Reconciliation, Statement, Subject}; use ledger_store::{Attachment, DB_FILE, JOURNAL_FILE, Store, StoreError}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::str::FromStr; // ---------------------------------------------------------------- helpers fn test_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("wb-attach-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } fn dec(s: &str) -> Decimal { Decimal::from_str(s).unwrap() } fn usd(s: &str) -> Money { Money::new(dec(s), Currency::USD) } fn date(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).unwrap() } /// The journal's (type, payload) pairs, verified. fn journal(dir: &Path) -> Vec<(String, Value)> { read_and_verify_journal(&dir.join(JOURNAL_FILE)) .unwrap() .into_iter() .map(|l| (l.line_type, l.payload)) .collect() } fn journal_bytes(dir: &Path) -> Vec { std::fs::read(dir.join(JOURNAL_FILE)).unwrap() } /// Run `f`; it must journal exactly the lines of types `tys`, in order. /// Returns the payloads. fn events( s: &mut Store, dir: &Path, tys: &[&str], f: impl FnOnce(&mut Store) -> T, ) -> (T, Vec) { let seq = s.journal_seq(); let len = journal(dir).len(); let out = f(s); assert_eq!( s.journal_seq(), seq + tys.len() as u64, "{tys:?}: line count" ); let j = journal(dir); assert_eq!(j.len(), len + tys.len(), "{tys:?}: lines on disk"); let new = &j[len..]; let types: Vec<&str> = new.iter().map(|(t, _)| t.as_str()).collect(); assert_eq!(types, tys); (out, new.iter().map(|(_, p)| p.clone()).collect()) } /// Run `f`; it must journal exactly one line, of type `ty`. Returns that /// line's payload. fn one_event( s: &mut Store, dir: &Path, ty: &str, f: impl FnOnce(&mut Store) -> T, ) -> (T, Value) { let (out, mut ps) = events(s, dir, &[ty], f); (out, ps.remove(0)) } /// Run `f`; it must fail and journal nothing. Returns the error. fn refused_nothing_journaled( s: &mut Store, dir: &Path, what: &str, f: impl FnOnce(&mut Store) -> Result, ) -> StoreError { let seq = s.journal_seq(); let bytes = journal_bytes(dir); let before = s.ledger().clone(); let r = f(s); assert_eq!(s.journal_seq(), seq, "{what}: journaled"); assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed"); assert!(*s.ledger() == before, "{what}: ledger changed"); match r { Ok(v) => panic!("{what}: expected refusal, got {v:?}"), Err(e) => e, } } fn delete_db(dir: &Path) { for suffix in ["", "-wal", "-shm", "-journal"] { let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); } } fn ids(v: &[Attachment]) -> Vec { let mut v: Vec = v.iter().map(|a| a.id).collect(); v.sort(); v } fn sorted(v: &[T]) -> Vec { let mut v = v.to_vec(); v.sort(); v } fn doc(s: &mut Store, name: &str) -> u64 { s.file_document( "accounting", name, "application/pdf", format!("%PDF {name}").as_bytes(), Some(name), ) .unwrap() .0 } struct Fx { cash: AccountId, capital: AccountId, expense: AccountId, card: AccountId, revenue: AccountId, /// 2026-01-01 Dr cash 500.00 (line 0) / Cr capital 500.00 (line 1). e1: EntryId, /// 2026-01-15 Dr expense 120.00 (line 0) / Cr cash 120.00 (line 1). e2: EntryId, /// 2026-02-03 Dr expense 30.00 (line 0) / Cr cash 30.00 (line 1). e3: EntryId, /// 2026-01-10 Dr expense 75.00 (line 0) / Cr card 75.00 (line 1). e4: EntryId, } fn post( s: &mut Store, d: NaiveDate, desc: &str, dr: AccountId, cr: AccountId, amt: &str, ) -> EntryId { let e = s.create_entry(d, desc).unwrap(); s.add_line(e, dr, Side::Debit, usd(amt)).unwrap(); s.add_line(e, cr, Side::Credit, usd(amt)).unwrap(); s.post_entry(e).unwrap(); e } fn fixture(dir: &Path) -> (Store, Fx) { let mut s = Store::create(dir, Currency::USD).unwrap(); let cash = s .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); let card = s .add_account("2050", "Card", AccountType::Liability, None, false) .unwrap(); let capital = s .add_account("3000", "Owner Capital", AccountType::Equity, None, false) .unwrap(); let revenue = s .add_account("4000", "App Revenue", AccountType::Revenue, None, false) .unwrap(); let expense = s .add_account("6000", "Software", AccountType::Expense, None, false) .unwrap(); let e1 = post(&mut s, date(2026, 1, 1), "Opening", cash, capital, "500.00"); let e2 = post(&mut s, date(2026, 1, 15), "Tool", expense, cash, "120.00"); let e3 = post(&mut s, date(2026, 2, 3), "Tool 2", expense, cash, "30.00"); let e4 = post( &mut s, date(2026, 1, 10), "Card buy", expense, card, "75.00", ); ( s, Fx { cash, capital, expense, card, revenue, e1, e2, e3, e4, }, ) } fn stmt( attachment_id: u64, account_id: AccountId, start: NaiveDate, end: NaiveDate, opening: &str, closing: &str, ) -> Statement { Statement { attachment_id, account_id, period_start: start, period_end: end, opening_balance: dec(opening), closing_balance: dec(closing), } } /// January's cash statement: 0.00 -> 380.00 (500.00 in, 120.00 out). fn jan_cash(s: &mut Store, fx: &Fx, name: &str) -> u64 { let a = doc(s, name); s.record_statement(stmt( a, fx.cash, date(2026, 1, 1), date(2026, 1, 31), "0.00", "380.00", )) .unwrap(); a } fn clear_jan(s: &mut Store, fx: &Fx) { s.mark_cleared(fx.e1, 0, true).unwrap(); s.mark_cleared(fx.e2, 1, true).unwrap(); } fn subject_json(subject: Subject) -> Value { match subject { Subject::Company => json!({"kind": "company"}), s => json!({"kind": s.kind(), "id": s.id()}), } } /// One of every subject kind, created in the fixture's store. fn every_subject(s: &mut Store, fx: &Fx) -> Vec { let cost = s .add_account("1500", "Equipment", AccountType::Asset, None, false) .unwrap(); let accum = s .add_account("1501", "Accum Depr", AccountType::Asset, None, false) .unwrap(); let depr = s .add_account("6400", "Depreciation", AccountType::Expense, None, false) .unwrap(); let prepaid = s .add_account("1300", "Prepaid", AccountType::Asset, None, false) .unwrap(); let asset = s .register_asset(NewAsset { description: "Laptop".into(), purchase_date: date(2026, 1, 5), cost: usd("3000.00"), salvage_value: usd("0"), useful_life_months: 36, cost_account: cost, accum_depr_account: accum, expense_account: depr, }) .unwrap(); let party = s.add_party("Dev Shop", &[]).unwrap(); let cust = s.add_customer("Acme").unwrap(); let invoice = s .create_invoice( cust, date(2026, 3, 1), date(2026, 3, 31), vec![DocumentLine { description: "App".into(), amount: dec("100.00"), }], ) .unwrap(); let vend = s.add_vendor("Dana").unwrap(); let bill = s .create_bill( vend, date(2026, 3, 1), date(2026, 3, 31), vec![DocumentLine { description: "Design".into(), amount: dec("200.00"), }], ) .unwrap(); let period = s .create_period("2026-01", date(2026, 1, 1), date(2026, 1, 31)) .unwrap(); let run = s .run_payroll( date(2026, 3, 27), vec![ledger_core::PayrollLine { employee_name: "Pat".into(), employee: None, gross_pay: dec("1000.00"), federal_withholding: dec("100.00"), state_withholding: dec("0"), ss_employee: dec("62.00"), medicare_employee: dec("14.50"), ss_employer: dec("62.00"), medicare_employer: dec("14.50"), other_deductions: dec("0"), }], fx.cash, ) .unwrap(); let schedule = s .register_schedule(ledger_core::NewSchedule { description: "Insurance".into(), kind: ledger_core::ScheduleKind::PrepaidExpense, total: usd("1200.00"), start: date(2026, 1, 1), months: 12, balance_account: prepaid, pl_account: fx.expense, }) .unwrap(); clear_jan(s, fx); let st = jan_cash(s, fx, "subject-stmt.pdf"); let rec = s.complete_reconciliation(st).unwrap(); vec![ Subject::Entry(fx.e2), Subject::Account(fx.revenue), Subject::Asset(asset), Subject::Party(party), Subject::Invoice(invoice), Subject::Bill(bill), Subject::Period(period), Subject::PayrollRun(run), Subject::Schedule(schedule), Subject::Reconciliation(rec.id), Subject::Company, ] } // ---------------------------------------------------------------- linking /// Oracle: link_attachment ("another entry, an account, an asset, a party, /// an invoice, a bill, a period, a payroll run, a schedule, a /// reconciliation, or the company"; journaled as `attachment.linked /// {attachment_id, subject}`); Subject's serde (tag "kind", content "id", /// snake_case); attachment_links; attachments_for. #[test] fn links_to_every_subject_kind() { let dir = test_dir("every-kind"); let (mut s, fx) = fixture(&dir); let subjects = every_subject(&mut s, &fx); let a = doc(&mut s, "contract.pdf"); for &subject in &subjects { let (r, p) = one_event(&mut s, &dir, "attachment.linked", |s| { s.link_attachment(a, subject) }); r.unwrap(); assert_eq!(p["attachment_id"], json!(a), "{subject:?}"); assert_eq!(p["subject"], subject_json(subject), "{subject:?}"); assert!( ids(&s.attachments_for(subject).unwrap()).contains(&a), "attachments_for({subject:?}) lacks the file" ); } // Filed by file_document: no filing entry, so just the links. assert_eq!(sorted(&s.attachment_links(a).unwrap()), sorted(&subjects)); s.verify().unwrap(); } /// Oracle: link_attachment SCENARIO 1 ("the subject doesn't exist: SHOULD /// refuse"), INVARIANT 3 (a refusal journals nothing); UnknownSubject. #[test] fn refuses_unknown_subjects() { let dir = test_dir("unknown-subject"); let (mut s, _) = fixture(&dir); let a = doc(&mut s, "x.pdf"); let missing = 9_999; for subject in [ Subject::Entry(missing), Subject::Account(missing), Subject::Asset(missing), Subject::Party(missing), Subject::Invoice(missing), Subject::Bill(missing), Subject::Period(missing), Subject::PayrollRun(missing), Subject::Schedule(missing), Subject::Reconciliation(missing), ] { let e = refused_nothing_journaled(&mut s, &dir, &format!("{subject:?}"), |s| { s.link_attachment(a, subject) }); assert!( matches!(e, StoreError::UnknownSubject(_)), "{subject:?}: {e:?}" ); } assert!(s.attachment_links(a).unwrap().is_empty()); } /// Oracle: link_attachment SCENARIO 1 (unknown or withdrawn attachment). #[test] fn refuses_unknown_or_withdrawn_attachment() { let dir = test_dir("link-bad-attachment"); let (mut s, _) = fixture(&dir); let e = refused_nothing_journaled(&mut s, &dir, "unknown", |s| { s.link_attachment(9_999, Subject::Company) }); assert!(matches!(e, StoreError::UnknownAttachment(9_999)), "{e:?}"); let a = doc(&mut s, "gone.pdf"); s.withdraw_attachment(a, "wrong file").unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| { s.link_attachment(a, Subject::Company) }); assert!( matches!(e, StoreError::AttachmentWithdrawn(x) if x == a), "{e:?}" ); } /// Oracle: link_attachment SCENARIO 2 (already linked to that subject, or /// filed on that entry: refuse); AlreadyLinked(id, subject words); /// attachment_links ("its filing entry, if any, first"). #[test] fn refuses_duplicate_links_and_the_filing_entry() { let dir = test_dir("dup-link"); let (mut s, fx) = fixture(&dir); let a = doc(&mut s, "agreement.pdf"); s.link_attachment(a, Subject::Account(fx.cash)).unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "dup account", |s| { s.link_attachment(a, Subject::Account(fx.cash)) }); assert!( matches!(&e, StoreError::AlreadyLinked(x, w) if *x == a && *w == Subject::Account(fx.cash).describe()), "{e:?}" ); s.link_attachment(a, Subject::Company).unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "dup company", |s| { s.link_attachment(a, Subject::Company) }); assert!(matches!(e, StoreError::AlreadyLinked(..)), "{e:?}"); let (r, _, _) = s .attach_receipt(Some(fx.e1), "r.pdf", "application/pdf", b"%PDF r", None) .unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "filing entry", |s| { s.link_attachment(r, Subject::Entry(fx.e1)) }); assert!( matches!(e, StoreError::AlreadyLinked(x, _) if x == r), "{e:?}" ); // The same receipt is evidence for a second entry, without refiling. s.link_attachment(r, Subject::Entry(fx.e2)).unwrap(); s.link_attachment(r, Subject::Account(fx.cash)).unwrap(); let links = s.attachment_links(r).unwrap(); assert_eq!(links[0], Subject::Entry(fx.e1), "filing entry first"); assert_eq!( sorted(&links[1..]), sorted(&[Subject::Entry(fx.e2), Subject::Account(fx.cash)]) ); assert_eq!( ids(&s.attachments_for(Subject::Entry(fx.e1)).unwrap()), vec![r] ); assert_eq!( ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()), vec![r] ); s.verify().unwrap(); } /// Oracle: link_attachment INVARIANT 2 ("an entry's receipts are the files /// filed on it and the files linked to it"); attachments_for ("for an /// entry: filed on it or linked to it"); docs "A receipt is an attachment /// linked to an entry". The store is policy-free, so the receipt rule's /// count is what attachments_for(Entry) returns. #[test] fn a_document_linked_to_an_entry_is_its_receipt() { let dir = test_dir("doc-as-receipt"); let (mut s, fx) = fixture(&dir); assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty()); let a = doc(&mut s, "invoice.pdf"); s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); assert_eq!( ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()), vec![a] ); let (r, _, _) = s .attach_receipt( Some(fx.e2), "paid.pdf", "application/pdf", b"%PDF paid", None, ) .unwrap(); assert_eq!( ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()), sorted(&[a, r]) ); // Other entries are untouched. assert!(s.attachments_for(Subject::Entry(fx.e3)).unwrap().is_empty()); // A draft supported only by a linked document. let d = s.create_entry(date(2026, 1, 20), "Draft").unwrap(); s.add_line(d, fx.expense, Side::Debit, usd("10.00")) .unwrap(); s.add_line(d, fx.cash, Side::Credit, usd("10.00")).unwrap(); let b = doc(&mut s, "draft-support.pdf"); s.link_attachment(b, Subject::Entry(d)).unwrap(); assert_eq!(ids(&s.attachments_for(Subject::Entry(d)).unwrap()), vec![b]); s.post_entry(d).unwrap(); assert_eq!(ids(&s.attachments_for(Subject::Entry(d)).unwrap()), vec![b]); s.verify().unwrap(); } // ---------------------------------------------------------------- unlinking /// Oracle: unlink_attachment SCENARIOS 1–2 (blank reason or not linked: /// refuse; otherwise `attachment.unlinked {attachment_id, subject, /// reason}`); ReasonRequired; NotLinkedTo; "the file stays filed". #[test] fn unlink_needs_a_reason_and_a_link() { let dir = test_dir("unlink"); let (mut s, fx) = fixture(&dir); let a = doc(&mut s, "misfiled.pdf"); s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); for blank in ["", " "] { let e = refused_nothing_journaled(&mut s, &dir, &format!("blank {blank:?}"), |s| { s.unlink_attachment(a, Subject::Entry(fx.e2), blank) }); assert!(matches!(e, StoreError::ReasonRequired), "{e:?}"); } let e = refused_nothing_journaled(&mut s, &dir, "not linked", |s| { s.unlink_attachment(a, Subject::Entry(fx.e3), "wrong entry") }); assert!( matches!(&e, StoreError::NotLinkedTo(x, w) if *x == a && *w == Subject::Entry(fx.e3).describe()), "{e:?}" ); let e = refused_nothing_journaled(&mut s, &dir, "unknown attachment", |s| { s.unlink_attachment(9_999, Subject::Entry(fx.e2), "why") }); let _ = e; let (r, p) = one_event(&mut s, &dir, "attachment.unlinked", |s| { s.unlink_attachment(a, Subject::Entry(fx.e2), "belongs to e3") }); r.unwrap(); assert_eq!(p["attachment_id"], json!(a)); assert_eq!(p["subject"], subject_json(Subject::Entry(fx.e2))); assert_eq!(p["reason"], json!("belongs to e3")); assert!(s.attachment_links(a).unwrap().is_empty()); assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty()); // Still filed, and it can go where it belongs. assert!(ids(&s.list_attachments(None).unwrap()).contains(&a)); s.link_attachment(a, Subject::Entry(fx.e3)).unwrap(); assert_eq!(s.attachment_links(a).unwrap(), vec![Subject::Entry(fx.e3)]); // Unlinking twice: no longer linked. s.unlink_attachment(a, Subject::Entry(fx.e3), "done") .unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "twice", |s| { s.unlink_attachment(a, Subject::Entry(fx.e3), "again") }); assert!(matches!(e, StoreError::NotLinkedTo(..)), "{e:?}"); s.verify().unwrap(); } /// Oracle: unlink_attachment ("a receipt filed on an entry comes off with /// `unlink_receipt` instead"; "isn't linked to the subject (through this /// table): SHOULD refuse"). #[test] fn unlink_attachment_does_not_take_a_filed_receipt_off() { let dir = test_dir("unlink-filed"); let (mut s, fx) = fixture(&dir); let (r, _, _) = s .attach_receipt(Some(fx.e1), "r.pdf", "application/pdf", b"%PDF r1", None) .unwrap(); refused_nothing_journaled(&mut s, &dir, "filed receipt", |s| { s.unlink_attachment(r, Subject::Entry(fx.e1), "wrong") }); assert_eq!( ids(&s.attachments_for(Subject::Entry(fx.e1)).unwrap()), vec![r] ); } // ---------------------------------------------------------------- withdrawal /// Oracle: withdraw_attachment SCENARIO 2 ("IF it is linked to any subject /// (link_attachment): SHOULD refuse (AlreadyLinked, naming the first /// subject)"). Linked through link_attachment rather than filed on the entry. #[test] fn withdraw_while_linked_to_an_entry_is_already_linked() { let dir = test_dir("withdraw-variant"); let (mut s, fx) = fixture(&dir); let a = doc(&mut s, "dup.pdf"); s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "linked", |s| { s.withdraw_attachment(a, "duplicate") }); assert!( matches!(&e, StoreError::AlreadyLinked(x, sub) if *x == a && *sub == Subject::Entry(fx.e2).describe()), "{e:?}" ); } /// Oracle: withdraw_attachment SCENARIO 2 ("linked to any subject: SHOULD /// refuse"), SCENARIO 3 (blank reason), SCENARIO 4, and /// "can never be linked again"; attachments_for ("not withdrawn"). #[test] fn withdraw_refuses_while_linked_to_an_entry_and_works_after_unlink() { let dir = test_dir("withdraw"); let (mut s, fx) = fixture(&dir); let a = doc(&mut s, "dup.pdf"); s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); refused_nothing_journaled(&mut s, &dir, "linked", |s| { s.withdraw_attachment(a, "duplicate") }); s.unlink_attachment(a, Subject::Entry(fx.e2), "duplicate") .unwrap(); refused_nothing_journaled(&mut s, &dir, "blank reason", |s| { s.withdraw_attachment(a, " ") }); let (r, p) = one_event(&mut s, &dir, "attachment.withdrawn", |s| { s.withdraw_attachment(a, "duplicate") }); r.unwrap(); assert_eq!(p["attachment_id"], json!(a)); let listed = s .list_attachments(None) .unwrap() .into_iter() .find(|x| x.id == a) .unwrap(); assert_eq!(listed.withdrawn_reason.as_deref(), Some("duplicate")); assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty()); let e = refused_nothing_journaled(&mut s, &dir, "relink", |s| { s.link_attachment(a, Subject::Entry(fx.e2)) }); assert!( matches!(e, StoreError::AttachmentWithdrawn(x) if x == a), "{e:?}" ); refused_nothing_journaled(&mut s, &dir, "withdraw twice", |s| { s.withdraw_attachment(a, "again") }); s.verify().unwrap(); } // ---------------------------------------------------------------- statements /// Oracle: record_statement INVARIANT 1 (`statement.recorded /// {attachment_id, account_id, period_start, period_end, opening_balance, /// closing_balance}` then `attachment.linked` to the account); statement; /// statements ("optionally one account's, by period end"). #[test] fn record_statement_journals_and_links_to_the_account() { let dir = test_dir("statement"); let (mut s, fx) = fixture(&dir); let a = doc(&mut s, "jan.pdf"); let st = stmt( a, fx.cash, date(2026, 1, 1), date(2026, 1, 31), "0.00", "380.00", ); let (r, ps) = events( &mut s, &dir, &["statement.recorded", "attachment.linked"], |s| s.record_statement(st.clone()), ); r.unwrap(); let p = &ps[0]; assert_eq!(p["attachment_id"], json!(a)); assert_eq!(p["account_id"], json!(fx.cash)); assert_eq!(p["period_start"], json!("2026-01-01")); assert_eq!(p["period_end"], json!("2026-01-31")); let money = |v: &Value| dec(v.as_str().unwrap_or(&v.to_string())); assert_eq!(money(&p["opening_balance"]), dec("0")); assert_eq!(money(&p["closing_balance"]), dec("380.00")); assert_eq!(ps[1]["attachment_id"], json!(a)); assert_eq!(ps[1]["subject"], subject_json(Subject::Account(fx.cash))); assert_eq!(s.statement(a).unwrap(), Some(st.clone())); assert_eq!( s.attachment_links(a).unwrap(), vec![Subject::Account(fx.cash)] ); assert!(ids(&s.attachments_for(Subject::Account(fx.cash)).unwrap()).contains(&a)); // A plain document records no statement. let plain = doc(&mut s, "plain.pdf"); assert_eq!(s.statement(plain).unwrap(), None); // A card (liability) statement, and February's cash one recorded // before January's in time order of calls but sorted by period end. let feb = doc(&mut s, "feb.pdf"); let st_feb = stmt( feb, fx.cash, date(2026, 2, 1), date(2026, 2, 28), "380.00", "350.00", ); let c = doc(&mut s, "card.pdf"); let st_card = stmt( c, fx.card, date(2026, 1, 1), date(2026, 1, 31), "0.00", "75.00", ); s.record_statement(st_card.clone()).unwrap(); s.record_statement(st_feb.clone()).unwrap(); assert_eq!( s.statements(Some(fx.cash)).unwrap(), vec![st.clone(), st_feb.clone()] ); assert_eq!(s.statements(Some(fx.card)).unwrap(), vec![st_card.clone()]); assert!(s.statements(Some(fx.capital)).unwrap().is_empty()); let all = s.statements(None).unwrap(); assert_eq!(all.len(), 3); assert_eq!(all.last().unwrap(), &st_feb, "by period end"); s.verify().unwrap(); } /// Oracle: record_statement SCENARIO 1 (unknown or withdrawn attachment, /// already records a statement, unknown account or not an asset or /// liability, period backwards, a balance with more than two decimal /// places: refuse, journaling nothing); InvalidStatement. #[test] fn record_statement_refusals_journal_nothing() { let dir = test_dir("statement-refusals"); let (mut s, fx) = fixture(&dir); let a = doc(&mut s, "stmt.pdf"); let jan = |acct| { stmt( a, acct, date(2026, 1, 1), date(2026, 1, 31), "0.00", "380.00", ) }; for acct in [fx.capital, fx.revenue, fx.expense] { let e = refused_nothing_journaled(&mut s, &dir, &format!("account {acct}"), |s| { s.record_statement(jan(acct)) }); assert!( matches!(e, StoreError::InvalidStatement(_)), "{acct}: {e:?}" ); } refused_nothing_journaled(&mut s, &dir, "unknown account", |s| { s.record_statement(jan(9_999)) }); let e = refused_nothing_journaled(&mut s, &dir, "backwards", |s| { s.record_statement(stmt( a, fx.cash, date(2026, 1, 31), date(2026, 1, 1), "0", "380.00", )) }); assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}"); for (open, close) in [("0.001", "380.00"), ("0.00", "380.005")] { let e = refused_nothing_journaled(&mut s, &dir, &format!("sub-cent {open} {close}"), |s| { s.record_statement(stmt( a, fx.cash, date(2026, 1, 1), date(2026, 1, 31), open, close, )) }); assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}"); } let e = refused_nothing_journaled(&mut s, &dir, "unknown attachment", |s| { s.record_statement(stmt( 9_999, fx.cash, date(2026, 1, 1), date(2026, 1, 31), "0", "1", )) }); assert!(matches!(e, StoreError::UnknownAttachment(9_999)), "{e:?}"); let w = doc(&mut s, "withdrawn.pdf"); s.withdraw_attachment(w, "wrong month").unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| { s.record_statement(stmt( w, fx.cash, date(2026, 1, 1), date(2026, 1, 31), "0", "1", )) }); assert!( matches!(e, StoreError::AttachmentWithdrawn(x) if x == w), "{e:?}" ); // Nothing was recorded by the refusals. assert_eq!(s.statement(a).unwrap(), None); assert!(s.statements(None).unwrap().is_empty()); // A one-day period does not run backwards; a liability is allowed. s.record_statement(stmt( a, fx.card, date(2026, 1, 31), date(2026, 1, 31), "0", "75.00", )) .unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "duplicate", |s| { s.record_statement(jan(fx.cash)) }); let _ = e; assert_eq!(s.statements(None).unwrap().len(), 1); s.verify().unwrap(); } // ---------------------------------------------------------------- reconciliations /// Oracle: complete_reconciliation SCENARIO 2 (NotReconciled naming the /// difference); NotReconciled's doc ("cleared balance less the statement /// balance"); Ledger::reconciliation_report (cleared lines dated on or /// before the statement date). #[test] fn refuses_with_the_exact_difference() { let dir = test_dir("not-reconciled"); let (mut s, fx) = fixture(&dir); let a = jan_cash(&mut s, &fx, "jan.pdf"); // Nothing cleared: 0.00 - 380.00. let e = refused_nothing_journaled(&mut s, &dir, "none cleared", |s| { s.complete_reconciliation(a) }); assert!( matches!(e, StoreError::NotReconciled { difference } if difference == dec("-380.00")), "{e:?}" ); // Only the deposit: 500.00 - 380.00. s.mark_cleared(fx.e1, 0, true).unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "deposit only", |s| { s.complete_reconciliation(a) }); assert!( matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")), "{e:?}" ); // A February line, cleared, is after the statement date and doesn't count. s.mark_cleared(fx.e3, 1, true).unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "feb cleared", |s| { s.complete_reconciliation(a) }); assert!( matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")), "{e:?}" ); // Clearing the non-cash side of an entry doesn't move the cash balance. s.mark_cleared(fx.e2, 0, true).unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "expense side cleared", |s| { s.complete_reconciliation(a) }); assert!( matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")), "{e:?}" ); assert!(s.reconciliations(None).unwrap().is_empty()); } /// Oracle: complete_reconciliation SCENARIO 1 (no statement: refuse). #[test] fn refuses_without_a_statement() { let dir = test_dir("no-statement"); let (mut s, fx) = fixture(&dir); clear_jan(&mut s, &fx); let plain = doc(&mut s, "plain.pdf"); refused_nothing_journaled(&mut s, &dir, "plain doc", |s| { s.complete_reconciliation(plain) }); refused_nothing_journaled(&mut s, &dir, "unknown", |s| { s.complete_reconciliation(9_999) }); } /// Oracle: complete_reconciliation INVARIANTS 1, 2, 4 (records the cleared /// lines on the account dated on or before the statement date, links the /// statement to it; journaled as `reconciliation.completed`); the docs' /// payload `{id, account_id, statement_date, statement_balance, cleared, /// attachment_id}`; Reconciliation; reconciliations. #[test] fn completes_when_the_cleared_balance_matches() { let dir = test_dir("reconciled"); let (mut s, fx) = fixture(&dir); let a = jan_cash(&mut s, &fx, "jan.pdf"); clear_jan(&mut s, &fx); // Cleared but out of scope: another account's line, and a cash line // after the statement date. s.mark_cleared(fx.e1, 1, true).unwrap(); s.mark_cleared(fx.e3, 1, true).unwrap(); // 500.00 - 120.00 = 380.00: reconciled. let seq = s.journal_seq(); let len = journal(&dir).len(); let rec = s.complete_reconciliation(a).unwrap(); let new: Vec<(String, Value)> = journal(&dir)[len..].to_vec(); assert_eq!(s.journal_seq(), seq + new.len() as u64); let completed: Vec<&Value> = new .iter() .filter(|(t, _)| t == "reconciliation.completed") .map(|(_, p)| p) .collect(); assert_eq!(completed.len(), 1, "one reconciliation.completed: {new:?}"); for (t, _) in &new { assert!( t == "reconciliation.completed" || t == "attachment.linked", "unexpected line {t}" ); } assert_eq!(rec.account_id, fx.cash); assert_eq!(rec.statement_date, date(2026, 1, 31)); assert_eq!(rec.statement_balance, dec("380.00")); assert_eq!(rec.attachment_id, a); assert_eq!(rec.voided, None); assert_eq!(sorted(&rec.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1)])); let p = completed[0]; assert_eq!(p["id"], json!(rec.id)); assert_eq!(p["account_id"], json!(fx.cash)); assert_eq!(p["statement_date"], json!("2026-01-31")); assert_eq!(p["attachment_id"], json!(a)); let mut cleared: Vec<(u64, u64)> = p["cleared"] .as_array() .unwrap() .iter() .map(|x| (x[0].as_u64().unwrap(), x[1].as_u64().unwrap())) .collect(); cleared.sort(); assert_eq!(cleared, vec![(fx.e1, 0), (fx.e2, 1)]); let links = s.attachment_links(a).unwrap(); assert!(links.contains(&Subject::Account(fx.cash)), "{links:?}"); assert!( links.contains(&Subject::Reconciliation(rec.id)), "{links:?}" ); assert_eq!( ids(&s.attachments_for(Subject::Reconciliation(rec.id)).unwrap()), vec![a] ); assert_eq!(s.reconciliations(Some(fx.cash)).unwrap(), vec![rec.clone()]); assert_eq!(s.reconciliations(None).unwrap(), vec![rec.clone()]); assert!(s.reconciliations(Some(fx.card)).unwrap().is_empty()); s.verify().unwrap(); } /// Oracle: Statement ("closing balance, in the account's normal /// direction"); complete_reconciliation INVARIANT 1. A card (liability) /// owing 75.00 states 75.00. #[test] fn reconciles_a_liability_in_its_normal_direction() { let dir = test_dir("liability"); let (mut s, fx) = fixture(&dir); let c = doc(&mut s, "card.pdf"); s.record_statement(stmt( c, fx.card, date(2026, 1, 1), date(2026, 1, 31), "0.00", "75.00", )) .unwrap(); s.mark_cleared(fx.e4, 1, true).unwrap(); let rec = s.complete_reconciliation(c).unwrap(); assert_eq!(rec.account_id, fx.card); assert_eq!(rec.cleared, vec![(fx.e4, 1)]); assert_eq!(rec.statement_balance, dec("75.00")); } /// Oracle: complete_reconciliation INVARIANT 3 / SCENARIO 3 ("a /// reconciliation not voided is dated on or after it: SHOULD refuse"); /// ReconciliationRefused; reconciliations ("by date"). #[test] fn reconciliations_go_forward() { let dir = test_dir("forward"); let (mut s, fx) = fixture(&dir); clear_jan(&mut s, &fx); let jan = jan_cash(&mut s, &fx, "jan.pdf"); let r1 = s.complete_reconciliation(jan).unwrap(); // Same date, another copy of the statement. let jan2 = jan_cash(&mut s, &fx, "jan-again.pdf"); let e = refused_nothing_journaled(&mut s, &dir, "same date", |s| { s.complete_reconciliation(jan2) }); assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); // Earlier date: cleared through 01-20 is also 380.00, but it's backwards. let mid = doc(&mut s, "mid.pdf"); s.record_statement(stmt( mid, fx.cash, date(2026, 1, 1), date(2026, 1, 20), "0", "380.00", )) .unwrap(); let e = refused_nothing_journaled(&mut s, &dir, "earlier", |s| s.complete_reconciliation(mid)); assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); // The same January statement, again. let e = refused_nothing_journaled(&mut s, &dir, "jan again", |s| { s.complete_reconciliation(jan) }); assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); // Another account's sequence is its own. let c = doc(&mut s, "card.pdf"); s.record_statement(stmt( c, fx.card, date(2026, 1, 1), date(2026, 1, 15), "0", "75.00", )) .unwrap(); s.mark_cleared(fx.e4, 1, true).unwrap(); s.complete_reconciliation(c).unwrap(); // February goes forward: 380.00 - 30.00 = 350.00. s.mark_cleared(fx.e3, 1, true).unwrap(); let feb = doc(&mut s, "feb.pdf"); s.record_statement(stmt( feb, fx.cash, date(2026, 2, 1), date(2026, 2, 28), "380.00", "350.00", )) .unwrap(); let r2 = s.complete_reconciliation(feb).unwrap(); assert_ne!(r2.id, r1.id); assert_eq!( sorted(&r2.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1), (fx.e3, 1)]) ); let cash_recs = s.reconciliations(Some(fx.cash)).unwrap(); assert_eq!(cash_recs, vec![r1, r2]); assert_eq!(s.reconciliations(None).unwrap().len(), 3); s.verify().unwrap(); } /// Oracle: void_reconciliation ("with the reason (to redo it)"; /// `reconciliation.voided {id, reason}`); complete_reconciliation /// INVARIANT 3 (only reconciliations not voided bound the next); /// Reconciliation.voided. #[test] fn void_and_redo() { let dir = test_dir("void"); let (mut s, fx) = fixture(&dir); clear_jan(&mut s, &fx); let jan = jan_cash(&mut s, &fx, "jan.pdf"); let r1 = s.complete_reconciliation(jan).unwrap(); for blank in ["", " "] { refused_nothing_journaled(&mut s, &dir, &format!("blank {blank:?}"), |s| { s.void_reconciliation(r1.id, blank) }); } refused_nothing_journaled(&mut s, &dir, "unknown", |s| { s.void_reconciliation(r1.id + 1_000, "why") }); let (r, p) = one_event(&mut s, &dir, "reconciliation.voided", |s| { s.void_reconciliation(r1.id, "cleared the wrong line") }); r.unwrap(); assert_eq!(p["id"], json!(r1.id)); assert_eq!(p["reason"], json!("cleared the wrong line")); refused_nothing_journaled(&mut s, &dir, "double void", |s| { s.void_reconciliation(r1.id, "again") }); let listed = s.reconciliations(Some(fx.cash)).unwrap(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].voided.as_deref(), Some("cleared the wrong line")); assert_eq!( Reconciliation { voided: None, ..listed[0].clone() }, r1, "voiding changes nothing else" ); // Redo the same statement. let r2 = s.complete_reconciliation(jan).unwrap(); assert_ne!(r2.id, r1.id); assert_eq!(r2.voided, None); assert_eq!(r2.statement_date, date(2026, 1, 31)); assert_eq!(sorted(&r2.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1)])); let mut got: Vec = s .reconciliations(Some(fx.cash)) .unwrap() .iter() .map(|r| r.id) .collect(); got.sort(); assert_eq!(got, sorted(&[r1.id, r2.id])); assert!( s.attachment_links(jan) .unwrap() .contains(&Subject::Reconciliation(r2.id)) ); // And the redo bounds the next one again. let e = refused_nothing_journaled(&mut s, &dir, "after redo", |s| { s.complete_reconciliation(jan) }); assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); s.verify().unwrap(); } // ---------------------------------------------------------------- replay #[derive(Debug, PartialEq)] struct View { /// Attachment has no PartialEq: compared by its Debug form. attachments: Vec, links: Vec<(u64, Vec)>, for_subjects: Vec<(Subject, Vec)>, statements: Vec, statement_of: Vec<(u64, Option)>, reconciliations: Vec, } fn view(s: &Store, subjects: &[Subject]) -> View { let attachments = s.list_attachments(None).unwrap(); View { links: attachments .iter() .map(|a| (a.id, s.attachment_links(a.id).unwrap())) .collect(), for_subjects: subjects .iter() .map(|&x| (x, ids(&s.attachments_for(x).unwrap()))) .collect(), statements: s.statements(None).unwrap(), statement_of: attachments .iter() .map(|a| (a.id, s.statement(a.id).unwrap())) .collect(), reconciliations: s.reconciliations(None).unwrap(), attachments: attachments.iter().map(|a| format!("{a:?}")).collect(), } } /// Oracle: Store::open (the ledger is the replay of every journal line; /// a missing database is rebuilt from the journal); Store::verify. #[test] fn everything_survives_replay_and_verifies() { let dir = test_dir("replay"); let (mut s, fx) = fixture(&dir); let mut subjects = every_subject(&mut s, &fx); let a = doc(&mut s, "multi.pdf"); for &x in &subjects { s.link_attachment(a, x).unwrap(); } s.unlink_attachment(a, Subject::Company, "not formation") .unwrap(); let (r, _, _) = s .attach_receipt(Some(fx.e3), "r.pdf", "application/pdf", b"%PDF r3", None) .unwrap(); s.link_attachment(r, Subject::Entry(fx.e2)).unwrap(); let w = doc(&mut s, "gone.pdf"); s.link_attachment(w, Subject::Entry(fx.e1)).unwrap(); s.unlink_attachment(w, Subject::Entry(fx.e1), "wrong") .unwrap(); s.withdraw_attachment(w, "wrong").unwrap(); // every_subject reconciled January; void it, redo, then February. let first = s.reconciliations(Some(fx.cash)).unwrap()[0].clone(); s.void_reconciliation(first.id, "redo").unwrap(); let redo = s.complete_reconciliation(first.attachment_id).unwrap(); s.mark_cleared(fx.e3, 1, true).unwrap(); let feb = doc(&mut s, "feb.pdf"); s.record_statement(stmt( feb, fx.cash, date(2026, 2, 1), date(2026, 2, 28), "380.00", "350.00", )) .unwrap(); let r_feb = s.complete_reconciliation(feb).unwrap(); let c = doc(&mut s, "card.pdf"); s.record_statement(stmt( c, fx.card, date(2026, 1, 1), date(2026, 1, 31), "0", "75.00", )) .unwrap(); subjects.extend([ Subject::Entry(fx.e1), Subject::Entry(fx.e3), Subject::Account(fx.cash), Subject::Account(fx.card), Subject::Reconciliation(first.id), Subject::Reconciliation(redo.id), Subject::Reconciliation(r_feb.id), ]); s.verify().unwrap(); let before = view(&s, &subjects); assert_eq!(before.statements.len(), 3); assert_eq!(before.reconciliations.len(), 3); drop(s); let s = Store::open(&dir).unwrap(); assert_eq!(view(&s, &subjects), before, "reopen"); s.verify().unwrap(); drop(s); delete_db(&dir); let s = Store::open(&dir).unwrap(); assert_eq!(view(&s, &subjects), before, "full replay"); s.verify().unwrap(); }