Files
wanderbooks/crates/ledger-store/tests/parties.rs
T
swaitsandClaude Opus 5.5 e265ed4339
check / check (push) Waiting to run
Parties, attachment links, statements, reconciliations (store), TIN vault crate
Parties: one directory for vendors, contractors, customers, employees,
members, platforms, agencies, banks, landlords (ledger-core parties.rs).
W-9/W-4 facts without the number (last four only; any nine-digit run in a
party field is refused, Treas. Reg. 301.6109-4). Vendor and customer
records link to parties on replay; core tags every bill, invoice, payment,
and reversal with its counterparty. Journal events party.*,
entry.party_set; payroll lines name the employee party.

MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4,
parties, party, set_entry_party; draft_entry party; run_payroll employee;
show party. Viewer: Parties directory and party pages, search, journal
text, W-9 attention, demo data.

One definition of a party's money (ledger_core::parties::activity) for
MCP and viewer. 1099 amounts count payments from cash and financing
accounts (a member paying for the company counts; a card doesn't; a
prepayment counts when made): vendor_1099_report_with, B-19.

Store: attachment links to any subject, statements, reconciliations
(schema v11), with replay and projection. ledger-vault: encrypted TIN and
W-9 records (ChaCha20-Poly1305, key file outside the books).

Spec-blind tests for parties (core, store, MCP, viewer) and attachments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-15 14:43:40 -06:00

755 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-derived tests for the party directory in the store.
//!
//! Oracles: the `Store` party methods' doc comments (each "journaled as"
//! its event: `party.added {id, name, roles}`, `party.updated {id,
//! change}`, `party.role_set {id, role, on}`, `party.w9_set {id, facts}`,
//! `party.w4_set {id, facts}`, `entry.party_set {entry_id, party_id}`;
//! `payroll.run` lines carry `employee`), the `ledger_core::parties` spec
//! (INVARIANTS 1–4), `Store::verify` (a fresh replay equals the live
//! ledger), and `docs/parties-and-attachments.md` "Migration" (old journals
//! replay unchanged; vendors and customers get their parties by replay).
//! Expected values are worked by hand from those texts.
use chrono::NaiveDate;
use ledger_core::{
AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId,
PayrollLine, PayrollRun, Role, Side, W4Facts, W9Facts,
};
use ledger_store::journal::read_and_verify_journal;
use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store};
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-parties-{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<u8> {
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
}
/// Run `f`; it must journal exactly one line, of type `ty`. Returns that
/// line's payload.
fn one_event<T>(
s: &mut Store,
dir: &Path,
ty: &str,
f: impl FnOnce(&mut Store) -> T,
) -> (T, Value) {
let seq = s.journal_seq();
let len = journal(dir).len();
let out = f(s);
assert_eq!(s.journal_seq(), seq + 1, "{ty}: exactly one line");
let j = journal(dir);
assert_eq!(j.len(), len + 1, "{ty}: exactly one line on disk");
let (t, p) = j.last().unwrap().clone();
assert_eq!(t, ty);
(out, p)
}
/// Run `f`; it must fail and journal nothing.
fn refused_nothing_journaled<T: std::fmt::Debug, E>(
s: &mut Store,
dir: &Path,
what: &str,
f: impl FnOnce(&mut Store) -> Result<T, E>,
) {
let seq = s.journal_seq();
let bytes = journal_bytes(dir);
let before = s.ledger().clone();
let r = f(s);
assert!(r.is_err(), "{what}: expected refusal, got {:?}", r.ok());
assert_eq!(s.journal_seq(), seq, "{what}: journaled");
assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed");
assert!(*s.ledger() == before, "{what}: ledger changed");
}
fn w9(last4: &str) -> W9Facts {
W9Facts {
received: date(2026, 3, 1),
entity_type: "individual/sole proprietor".to_string(),
exempt_payee_code: None,
fatca_code: None,
tin_last4: last4.to_string(),
kept_at: "wanderbooks vault".to_string(),
vault_record: None,
}
}
fn w4(state: &str) -> W4Facts {
W4Facts {
received: date(2026, 3, 1),
state: state.to_string(),
kept_at: "CPA portal".to_string(),
}
}
fn pay_line(name: &str, employee: Option<PartyId>) -> PayrollLine {
PayrollLine {
employee_name: name.to_string(),
employee,
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"),
}
}
struct Fx {
cash: AccountId,
entry: EntryId,
}
/// A store with cash, capital, and one posted $500.00 entry.
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 capital = s
.add_account("3000", "Owner Capital", AccountType::Equity, None, false)
.unwrap();
let entry = s.create_entry(date(2026, 1, 1), "Opening").unwrap();
s.add_line(entry, cash, Side::Debit, usd("500.00")).unwrap();
s.add_line(entry, capital, Side::Credit, usd("500.00"))
.unwrap();
s.post_entry(entry).unwrap();
(s, Fx { cash, entry })
}
/// What "replays to an equal ledger" compares: every party, every entry's
/// party and vendor tags, every payroll run.
#[derive(Debug, PartialEq)]
struct View {
parties: Vec<Party>,
tags: Vec<(EntryId, Option<PartyId>, Option<u64>)>,
runs: Vec<PayrollRun>,
}
fn view(l: &Ledger) -> View {
View {
parties: l.parties().cloned().collect(),
tags: (0..l.next_entry_id() + 2)
.map(|e| (e, l.entry_party(e), l.entry_vendor(e)))
.collect(),
runs: l.list_payroll_runs().into_iter().cloned().collect(),
}
}
fn delete_db(dir: &Path) {
for suffix in ["", "-wal", "-shm", "-journal"] {
let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}")));
}
}
/// A store exercising every party event.
/// `named_employee`: whether the payroll run names Alice's party.
fn rich(dir: &Path, named_employee: bool) -> Store {
let (mut s, fx) = fixture(dir);
let irs = s.add_party("IRS", &[Role::TaxAgency]).unwrap();
let dev = s.add_party("Dev Shop", &[Role::Contractor]).unwrap();
let alice = s.add_party("Alice", &[Role::Employee]).unwrap();
s.update_party(dev, PartyField::Website(Some("https://dev.example".into())))
.unwrap();
s.update_party(dev, PartyField::DisplayName(Some("Dev".into())))
.unwrap();
s.update_party(irs, PartyField::Notes(Some("EFTPS".into())))
.unwrap();
s.set_party_role(dev, Role::Landlord, true).unwrap();
s.set_party_role(dev, Role::Landlord, false).unwrap();
s.set_party_w9(dev, Some(w9("6789"))).unwrap();
s.set_party_w4(alice, Some(w4("UT"))).unwrap();
let v = s.add_vendor("dev shop").unwrap();
let c = s.add_customer("Acme").unwrap();
s.add_vendor("Acme").unwrap();
let _ = c;
s.set_entry_party(fx.entry, Some(irs)).unwrap();
s.set_entry_vendor(fx.entry, Some(v)).unwrap();
let old = s.add_party("Old Name", &[]).unwrap();
s.update_party(old, PartyField::Archived(true)).unwrap();
s.add_vendor("Old Name").unwrap();
s.run_payroll(
date(2026, 2, 27),
vec![
pay_line("Alice", named_employee.then_some(alice)),
pay_line("Bob", None),
],
fx.cash,
)
.unwrap();
s
}
// ---------------------------------------------------------------- journaling
/// Oracle: Store::add_party doc ("journaled as party.added") and the
/// task's payload {id, name, roles}; roles by wire key.
#[test]
fn add_party_journals_party_added() {
let dir = test_dir("added");
let (mut s, _) = fixture(&dir);
let next = s.ledger().next_party_id();
let (id, p) = one_event(&mut s, &dir, "party.added", |s| {
s.add_party("Internal Revenue Service", &[Role::TaxAgency])
.unwrap()
});
assert_eq!(id, next);
assert_eq!(p["id"], json!(id));
assert_eq!(p["name"], json!("Internal Revenue Service"));
assert_eq!(p["roles"], json!(["tax_agency"]));
let (id2, p) = one_event(&mut s, &dir, "party.added", |s| {
s.add_party("Stephen", &[Role::Member, Role::Employee])
.unwrap()
});
assert_eq!(p["id"], json!(id2));
let mut roles: Vec<String> = p["roles"]
.as_array()
.unwrap()
.iter()
.map(|r| r.as_str().unwrap().to_string())
.collect();
roles.sort();
assert_eq!(roles, vec!["employee".to_string(), "member".to_string()]);
}
/// Oracle: Store::update_party doc ("journaled as party.updated") with
/// {id, change}; PartyField's wire form is `{field, value}` (its serde
/// attributes: snake_case, tag "field", content "value").
#[test]
fn update_party_journals_party_updated() {
let dir = test_dir("updated");
let (mut s, _) = fixture(&dir);
let id = s.add_party("Acme", &[]).unwrap();
let cases = [
(
PartyField::Website(Some("https://acme.example".into())),
json!({"field": "website", "value": "https://acme.example"}),
),
(
PartyField::DisplayName(Some("ACME".into())),
json!({"field": "display_name", "value": "ACME"}),
),
(
PartyField::DisplayName(None),
json!({"field": "display_name", "value": null}),
),
(
PartyField::Address(Some("1 Main St".into())),
json!({"field": "address", "value": "1 Main St"}),
),
(
PartyField::Notes(Some("net 30".into())),
json!({"field": "notes", "value": "net 30"}),
),
(
PartyField::Name("Acme LLC".into()),
json!({"field": "name", "value": "Acme LLC"}),
),
(
PartyField::Archived(true),
json!({"field": "archived", "value": true}),
),
];
for (field, want) in cases {
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
s.update_party(id, field.clone()).unwrap()
});
assert_eq!(p["id"], json!(id));
assert_eq!(p["change"], want, "{field:?}");
}
let got = s.ledger().get_party(id).unwrap();
assert_eq!(got.name, "Acme LLC");
assert!(got.archived);
assert_eq!(got.notes.as_deref(), Some("net 30"));
}
/// Oracle: Store::set_party_role doc ("journaled as party.role_set") with
/// {id, role, on}.
#[test]
fn set_party_role_journals_party_role_set() {
let dir = test_dir("role");
let (mut s, _) = fixture(&dir);
let id = s.add_party("Kat", &[]).unwrap();
let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| {
s.set_party_role(id, Role::PayoutPlatform, true).unwrap()
});
assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": true}));
let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| {
s.set_party_role(id, Role::PayoutPlatform, false).unwrap()
});
assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": false}));
}
/// Oracle: Store::set_party_w9 / set_party_w4 docs (`party.w9_set` /
/// `party.w4_set` {id, facts}); facts null when cleared.
#[test]
fn w9_and_w4_journal_their_facts() {
let dir = test_dir("facts");
let (mut s, _) = fixture(&dir);
let c = s.add_party("Contractor", &[Role::Contractor]).unwrap();
let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| {
s.set_party_w9(c, Some(w9("6789"))).unwrap()
});
assert_eq!(p["id"], json!(c));
assert_eq!(p["facts"]["tin_last4"], json!("6789"));
assert_eq!(p["facts"]["received"], json!("2026-03-01"));
assert_eq!(
p["facts"]["entity_type"],
json!("individual/sole proprietor")
);
assert_eq!(p["facts"]["kept_at"], json!("wanderbooks vault"));
let back: W9Facts = serde_json::from_value(p["facts"].clone()).unwrap();
assert_eq!(back, w9("6789"));
let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| {
s.set_party_w9(c, None).unwrap()
});
assert_eq!(p, json!({"id": c, "facts": null}));
let e = s.add_party("Employee", &[Role::Employee]).unwrap();
let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| {
s.set_party_w4(e, Some(w4("UT"))).unwrap()
});
assert_eq!(
p,
json!({"id": e, "facts": {"received": "2026-03-01", "state": "UT", "kept_at": "CPA portal"}})
);
let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| {
s.set_party_w4(e, None).unwrap()
});
assert_eq!(p, json!({"id": e, "facts": null}));
}
/// Oracle: Store::set_entry_party doc (`entry.party_set {entry_id,
/// party_id}`, null when cleared).
#[test]
fn set_entry_party_journals_entry_party_set() {
let dir = test_dir("entry");
let (mut s, fx) = fixture(&dir);
let p = s.add_party("Utah", &[Role::TaxAgency]).unwrap();
let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| {
s.set_entry_party(fx.entry, Some(p)).unwrap()
});
assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": p}));
assert_eq!(s.ledger().entry_party(fx.entry), Some(p));
let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| {
s.set_entry_party(fx.entry, None).unwrap()
});
assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": null}));
assert_eq!(s.ledger().entry_party(fx.entry), None);
}
/// Oracle: add_vendor / add_customer docs ("journaled as vendor.added" /
/// "customer.added"): one event each; the party comes from the core
/// auto-link (so from replay), not a second event.
#[test]
fn add_vendor_and_customer_journal_one_event_and_link_a_party() {
let dir = test_dir("records");
let (mut s, fx) = fixture(&dir);
let (v, _) = one_event(&mut s, &dir, "vendor.added", |s| {
s.add_vendor("Acme").unwrap()
});
let (c, _) = one_event(&mut s, &dir, "customer.added", |s| {
s.add_customer("ACME").unwrap()
});
let pv = s.ledger().party_of_vendor(v).unwrap();
assert_eq!(s.ledger().party_of_customer(c), Some(pv));
// set_entry_vendor: one entry.vendor_set line, and the party is tagged.
let ((), pl) = one_event(&mut s, &dir, "entry.vendor_set", |s| {
s.set_entry_vendor(fx.entry, Some(v)).unwrap()
});
assert_eq!(pl["entry_id"], json!(fx.entry));
assert_eq!(s.ledger().entry_party(fx.entry), Some(pv));
}
/// Oracle: payroll.run lines carry "employee" (the party id, or null).
#[test]
fn payroll_run_lines_carry_employee() {
let dir = test_dir("payroll");
let (mut s, fx) = fixture(&dir);
let alice = s.add_party("Alice", &[Role::Employee]).unwrap();
s.run_payroll(
date(2026, 2, 27),
vec![pay_line("Alice", Some(alice)), pay_line("Bob", None)],
fx.cash,
)
.unwrap();
let j = journal(&dir);
let (_, p) = j.iter().rev().find(|(t, _)| t == "payroll.run").unwrap();
let lines = p["lines"].as_array().unwrap();
assert_eq!(lines.len(), 2);
let by_name = |n: &str| {
lines
.iter()
.find(|l| l["employee_name"] == json!(n))
.unwrap()
.clone()
};
assert_eq!(by_name("Alice")["employee"], json!(alice));
assert_eq!(by_name("Bob")["employee"], Value::Null);
}
// ---------------------------------------------------------------- refusals
/// Oracle: the core SCENARIOS (refusals) and the store's journal-first
/// contract: a refusal journals nothing.
#[test]
fn refusals_journal_nothing() {
let dir = test_dir("refusals");
let (mut s, fx) = fixture(&dir);
let a = s.add_party("Alpha", &[Role::Member]).unwrap();
s.add_party("Beta", &[]).unwrap();
let emp = s.add_party("Emp", &[Role::Employee]).unwrap();
let ghost = s.ledger().next_party_id() + 50;
refused_nothing_journaled(&mut s, &dir, "blank", |s| s.add_party(" ", &[]));
refused_nothing_journaled(&mut s, &dir, "tin", |s| s.add_party("Bob 123-45-6789", &[]));
refused_nothing_journaled(&mut s, &dir, "dup", |s| s.add_party(" alpha", &[]));
refused_nothing_journaled(&mut s, &dir, "vendor role", |s| {
s.add_party("Gamma", &[Role::Vendor])
});
refused_nothing_journaled(&mut s, &dir, "update unknown", |s| {
s.update_party(ghost, PartyField::Notes(None))
});
refused_nothing_journaled(&mut s, &dir, "update tin", |s| {
s.update_party(a, PartyField::Notes(Some("12-3456789".into())))
});
refused_nothing_journaled(&mut s, &dir, "rename taken", |s| {
s.update_party(a, PartyField::Name("BETA".into()))
});
refused_nothing_journaled(&mut s, &dir, "customer role", |s| {
s.set_party_role(a, Role::Customer, true)
});
refused_nothing_journaled(&mut s, &dir, "w9 without 1099 role", |s| {
s.set_party_w9(a, Some(w9("6789")))
});
refused_nothing_journaled(&mut s, &dir, "w4 without employee", |s| {
s.set_party_w4(a, Some(w4("UT")))
});
refused_nothing_journaled(&mut s, &dir, "bad w4", |s| {
s.set_party_w4(emp, Some(w4("Utah")))
});
s.set_party_w4(emp, Some(w4("UT"))).unwrap();
refused_nothing_journaled(&mut s, &dir, "drop employee with w4", |s| {
s.set_party_role(emp, Role::Employee, false)
});
refused_nothing_journaled(&mut s, &dir, "entry party unknown", |s| {
s.set_entry_party(fx.entry, Some(ghost))
});
refused_nothing_journaled(&mut s, &dir, "entry unknown", |s| {
s.set_entry_party(fx.entry + 99, Some(a))
});
refused_nothing_journaled(&mut s, &dir, "payroll non-employee", |s| {
s.run_payroll(date(2026, 2, 27), vec![pay_line("Alpha", Some(a))], fx.cash)
});
refused_nothing_journaled(&mut s, &dir, "payroll unknown party", |s| {
s.run_payroll(
date(2026, 2, 27),
vec![pay_line("Ghost", Some(ghost))],
fx.cash,
)
});
}
// ---------------------------------------------------------------- replay
/// Oracle: Store::verify INVARIANT 2 (a fresh replay equals the live
/// ledger) and the journal as system of record: reopening, with or
/// without the database, gives the same directory, tags, and runs.
fn replay_check(name: &str, named_employee: bool, verify: bool) {
let dir = test_dir(name);
let s = rich(&dir, named_employee);
if verify {
s.verify().unwrap();
}
let live = view(s.ledger());
let live_ledger = s.ledger().clone();
assert!(live.parties.len() >= 5);
drop(s);
let s = Store::open(&dir).unwrap();
assert_eq!(view(s.ledger()), live, "reopen");
assert!(
*s.ledger() == live_ledger,
"reopen: {:?}",
s.ledger().differs_from(&live_ledger)
);
if verify {
s.verify().unwrap();
}
drop(s);
delete_db(&dir);
let s = Store::open(&dir).unwrap();
assert_eq!(view(s.ledger()), live, "replay without the database");
assert!(
*s.ledger() == live_ledger,
"no db: {:?}",
s.ledger().differs_from(&live_ledger)
);
if verify {
s.verify().unwrap();
}
}
#[test]
fn replay_on_reopen_and_without_db_gives_an_equal_ledger() {
replay_check("replay", false, true);
}
/// Same, with the payroll run naming its employee's party: replay must
/// still agree (verify is the next test).
#[test]
fn replay_with_a_named_employee_gives_an_equal_ledger() {
replay_check("replay-employee", true, false);
}
/// Oracle: Store::verify INVARIANT 2 ("the SQLite read-back's ... payroll
/// runs equal the replay's") with a run whose line names its employee.
#[test]
fn verify_passes_with_a_named_employee() {
replay_check("verify-employee", true, true);
}
// ---------------------------------------------------------------- corruption
/// Append `ty`/`payload` with a valid chain, then expect open to refuse.
fn assert_corrupt_on_open(name: &str, setup: impl FnOnce(&mut Store) -> Value, ty: &str) {
let dir = test_dir(name);
let (mut s, _) = fixture(&dir);
let payload = setup(&mut s);
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(ty, payload.clone()).unwrap();
drop(w);
if let Ok(_s) = Store::open(&dir) {
panic!("{name}: a corrupt {ty} line {payload} opened");
}
delete_db(&dir);
if let Ok(_s) = Store::open(&dir) {
panic!("{name}: a corrupt {ty} line {payload} opened without the db");
}
}
/// Oracle: the payloads' documented shapes (Role keys, W9Facts/W4Facts
/// and their check, integer ids, party.added's id = the next id) and the
/// invariants: a line that cannot replay is refused on open.
#[test]
fn corrupt_party_payloads_are_refused_on_open() {
assert_corrupt_on_open(
"bad-role",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "boss", "on": true}),
"party.role_set",
);
assert_corrupt_on_open(
"record-role",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "vendor", "on": true}),
"party.role_set",
);
assert_corrupt_on_open(
"role-on-not-bool",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "member", "on": "yes"}),
"party.role_set",
);
assert_corrupt_on_open(
"w9-shape",
|s| json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": {"nonsense": 1}}),
"party.w9_set",
);
assert_corrupt_on_open(
"w9-full-tin",
|s| {
let mut f = serde_json::to_value(w9("6789")).unwrap();
f["tin_last4"] = json!("123456789");
json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": f})
},
"party.w9_set",
);
assert_corrupt_on_open(
"w9-no-1099-role",
|s| {
json!({"id": s.add_party("A", &[Role::Member]).unwrap(),
"facts": serde_json::to_value(w9("6789")).unwrap()})
},
"party.w9_set",
);
assert_corrupt_on_open(
"w4-bad-state",
|s| {
json!({"id": s.add_party("A", &[Role::Employee]).unwrap(),
"facts": {"received": "2026-03-01", "state": "Utah", "kept_at": "x"}})
},
"party.w4_set",
);
assert_corrupt_on_open(
"updated-bad-field",
|s| json!({"id": s.add_party("A", &[]).unwrap(), "change": {"field": "ssn", "value": "x"}}),
"party.updated",
);
for (i, bad) in [json!("3"), json!(-3), json!({"id": 1}), json!(true)]
.into_iter()
.enumerate()
{
assert_corrupt_on_open(
&format!("party-id-{i}"),
|s| {
s.add_party("A", &[]).unwrap();
json!({"entry_id": 0, "party_id": bad})
},
"entry.party_set",
);
}
assert_corrupt_on_open(
"added-id-mismatch",
|s| json!({"id": s.ledger().next_party_id() + 5, "name": "X", "roles": []}),
"party.added",
);
assert_corrupt_on_open(
"added-bad-role",
|s| json!({"id": s.ledger().next_party_id(), "name": "X", "roles": ["boss"]}),
"party.added",
);
assert_corrupt_on_open(
"added-full-tin",
|s| json!({"id": s.ledger().next_party_id(), "name": "X 123456789", "roles": []}),
"party.added",
);
}
/// Control for the corruption test: a well-formed hand-appended
/// `party.added` and `entry.party_set` open and replay (so the refusals
/// above are about the payloads, not about appending by hand).
#[test]
fn a_well_formed_appended_party_line_opens() {
let dir = test_dir("well-formed");
let (s, fx) = fixture(&dir);
let next = s.ledger().next_party_id();
drop(s);
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append(
"party.added",
json!({"id": next, "name": "Apple", "roles": ["payout_platform"]}),
)
.unwrap();
w.append(
"entry.party_set",
json!({"entry_id": fx.entry, "party_id": next}),
)
.unwrap();
drop(w);
let s = Store::open(&dir).unwrap();
let p = s.ledger().get_party(next).unwrap();
assert_eq!(p.name, "Apple");
assert_eq!(p.roles, [Role::PayoutPlatform].into_iter().collect());
assert_eq!(s.ledger().entry_party(fx.entry), Some(next));
s.verify().unwrap();
}
// ---------------------------------------------------------------- migration
/// Copy `from`'s journal into a fresh dir via `JournalWriter`, letting
/// `edit` drop or rewrite lines (a valid chain, old-format payloads).
fn rewrite_journal(from: &Path, to: &Path, edit: impl Fn(&str, Value) -> Option<Value>) {
let mut w = JournalWriter::open(&to.join(JOURNAL_FILE)).unwrap();
for (t, p) in journal(from) {
if let Some(p) = edit(&t, p) {
w.append(&t, p).unwrap();
}
}
}
/// Oracle: docs "Migration": old journals replay unchanged; vendor.added
/// and customer.added create parties; payroll lines from before carry the
/// name only (`employee` absent). INVARIANT 4 holds after replay.
#[test]
fn books_from_before_parties_open_and_get_parties_by_replay() {
let src = test_dir("old-src");
let (mut s, fx) = fixture(&src);
let v1 = s.add_vendor("Namecheap").unwrap();
let v2 = s.add_vendor("Apple").unwrap();
let c1 = s.add_customer("apple").unwrap();
let c2 = s.add_customer("Beta Tester").unwrap();
s.set_entry_vendor(fx.entry, Some(v1)).unwrap();
s.run_payroll(date(2026, 2, 27), vec![pay_line("Alice", None)], fx.cash)
.unwrap();
drop(s);
let dst = test_dir("old-dst");
rewrite_journal(&src, &dst, |t, mut p| {
assert!(
!t.starts_with("party.") && t != "entry.party_set",
"{t} in old books"
);
if t == "payroll.run" {
for l in p["lines"].as_array_mut().unwrap() {
l.as_object_mut().unwrap().remove("employee");
}
}
Some(p)
});
let s = Store::open(&dst).unwrap();
let l = s.ledger();
// INVARIANT 4: each record links to exactly one party.
for (vid, name) in [(v1, "Namecheap"), (v2, "Apple")] {
let pid = l.party_of_vendor(vid).expect("vendor party");
let owners: Vec<_> = l.parties().filter(|p| p.vendor == Some(vid)).collect();
assert_eq!(owners.len(), 1);
assert_eq!(owners[0].id, pid);
assert_eq!(owners[0].name, name);
assert!(owners[0].all_roles().contains(&Role::Vendor));
}
for cid in [c1, c2] {
let owners: Vec<_> = l.parties().filter(|p| p.customer == Some(cid)).collect();
assert_eq!(owners.len(), 1);
assert_eq!(l.party_of_customer(cid), Some(owners[0].id));
}
// The auto-link spec: "apple" the customer joins "Apple" the vendor.
assert_eq!(l.party_of_customer(c1), l.party_of_vendor(v2));
assert_eq!(l.parties().count(), 3);
// entry.vendor_set replays as the party tag too.
assert_eq!(l.entry_vendor(fx.entry), Some(v1));
assert_eq!(l.entry_party(fx.entry), l.party_of_vendor(v1));
// The old payroll run has the name only.
let runs = l.list_payroll_runs();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].lines[0].employee_name, "Alice");
assert_eq!(runs[0].lines[0].employee, None);
s.verify().unwrap();
let before = view(s.ledger());
drop(s);
delete_db(&dst);
let s = Store::open(&dst).unwrap();
assert_eq!(view(s.ledger()), before);
}